From 8cbd1856ee2c42cafbf250598aeb6c65b63cf7c4 Mon Sep 17 00:00:00 2001 From: mkdev11 Date: Fri, 13 Mar 2026 07:15:13 +0100 Subject: [PATCH 1/2] feat: add Kalshi support with grammar-based ticker parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements issue #19 — Synth Overlay now works on Kalshi pages. Architecture: - Grammar-based Kalshi ticker parser: derives asset and market type from ticker structure (KX + asset_code + suffix), not lookup tables. Adding a new Kalshi asset = 1 entry in _KX_CODES. - Rule-chain Polymarket inference: ordered (matcher, type) tuples replace if/else chains. - Config-driven platform definitions: data dicts, not classes. - Structured ResolveResult with typed error codes for actionable API error responses. - /api/platforms introspection endpoint. - platforms.js: centralized JS platform config. Extension changes: - content.js: Kalshi price scraper (3-pass: trading panel, fallback DOM, order book pattern), slug extraction, context invalidation. - background.js: platform-aware notifications and watchlist polling. - sidepanel.js/html: dynamic Kalshi/Poly labels, suggested markets. - alerts.js: watchlist stores platform field. - manifest.json: Kalshi permissions. Tests: 213 passing (74 matcher + 139 server). --- tools/synth-overlay/README.md | 41 +- tools/synth-overlay/extension/alerts.js | 16 +- tools/synth-overlay/extension/background.js | 76 ++- tools/synth-overlay/extension/content.js | 354 ++++++++++-- tools/synth-overlay/extension/manifest.json | 10 +- tools/synth-overlay/extension/platforms.js | 75 +++ tools/synth-overlay/extension/sidepanel.html | 3 +- tools/synth-overlay/extension/sidepanel.js | 62 ++- tools/synth-overlay/matcher.py | 549 +++++++++++++++++-- tools/synth-overlay/server.py | 69 ++- tools/synth-overlay/tests/test_matcher.py | 526 +++++++++++++++++- tools/synth-overlay/tests/test_server.py | 204 +++++++ 12 files changed, 1808 insertions(+), 177 deletions(-) create mode 100644 tools/synth-overlay/extension/platforms.js diff --git a/tools/synth-overlay/README.md b/tools/synth-overlay/README.md index 420efe0..b53ed04 100644 --- a/tools/synth-overlay/README.md +++ b/tools/synth-overlay/README.md @@ -1,6 +1,6 @@ -# Synth Overlay — Polymarket Edge & Position Sizing Extension +# Synth Overlay — Polymarket & Kalshi Edge Extension -Chrome extension that uses Chrome's **native Side Panel** to show Synth market context on Polymarket and convert that edge into a **concrete position size**. The panel is data-first: Synth Up/Down prices, edge, confidence, signal explanation, invalidation conditions, and a **Kelly-based position sizing calculator**. +Chrome extension that uses Chrome's **native Side Panel** to show Synth market context on **Polymarket** and **Kalshi** and convert that edge into a **concrete position size**. The panel is data-first: Synth Up/Down prices, edge, confidence, signal explanation, invalidation conditions, and a **Kelly-based position sizing calculator**. ## What it does @@ -10,16 +10,25 @@ Chrome extension that uses Chrome's **native Side Panel** to show Synth market c - **Synth-sourced prices only**: Displays prices from the Synth API to avoid sync issues with DOM-scraped market data. - **Manual + auto refresh**: Refresh button in panel plus automatic 15s refresh. "Data as of" timestamp shows when the Synth data was generated. - **Clear confidence colors**: red (<40%), amber (40–70%), green (≥70%). -- **Contextual only**: Enabled on Polymarket pages; panel shows guidance when page/slug is unsupported. +- **Multi-platform**: Works on both Polymarket and Kalshi. Platform is auto-detected from URL; the UI dynamically shows "Poly" or "Kalshi" labels. +- **Contextual only**: Enabled on supported platform pages; panel shows guidance when page/slug is unsupported. + +## Architecture + +The codebase uses a **platform registry pattern** — each platform (Polymarket, Kalshi) is a self-contained module with its own URL patterns, asset maps, slug normalisation, and market-type detection. Adding a third platform means adding one entry to the registry; no if/else scattering across the codebase. + +- **`matcher.py`** — Platform registry (Python, server-side). Polymarket checked first to prevent slug collisions with Kalshi legacy tickers. +- **`extension/platforms.js`** — Platform registry (JS, extension-side). Single source of truth for origins, domain hints, URL templates. +- **`extension/content.js`** — Strategy pattern: platform detected once at init, scrapers dispatched per platform. ## How it works -1. **Content script** (on `polymarket.com`) reads the market slug from the page URL and scrapes **live prices** and **balance** from the DOM. -2. **Side panel page** requests context from the content script and fetches Synth edge data from local API (`GET /api/edge?slug=...`). -3. **Panel rendering** displays Synth forecast data (prices, edge, signal, confidence, analysis, invalidation) and updates every 15s or on manual refresh. -4. **Position Sizing card** combines Synth probabilities, Polymarket-implied odds, forecast confidence, and user balance into a Kelly-based recommendation. -5. **Background service worker** enables/disables side panel per-tab based on URL and runs the alert polling engine. -6. **Edge alerts** poll watched markets every 60s via `chrome.alarms`. When edge exceeds the user's threshold, a browser notification fires with asset, edge size, signal direction, and confidence. Clicking the notification focuses or opens the relevant Polymarket page. Notifications are suppressed when the user is already viewing the market and have a 5-minute cooldown per market to avoid spam. +1. **Content script** (on `polymarket.com` and `kalshi.com`) detects platform from hostname, reads the market slug using platform-specific extraction, and scrapes **live prices** and **balance** from the DOM. +2. **Side panel page** requests context from the content script and fetches Synth edge data from local API (`GET /api/edge?slug=...&platform=...`). +3. **Panel rendering** displays Synth forecast data (prices, edge, signal, confidence, analysis, invalidation) with dynamic platform labels and updates every 30s or on manual refresh. +4. **Position Sizing card** combines Synth probabilities, market-implied odds, forecast confidence, and user balance into a Kelly-based recommendation. +5. **Background service worker** enables/disables side panel per-tab based on URL (any supported platform) and runs the alert polling engine. +6. **Edge alerts** poll watched markets every 60s via `chrome.alarms`. Each watchlist entry stores its platform. When edge exceeds the user's threshold, a browser notification fires with asset, edge size, signal direction, and confidence. Clicking the notification focuses or opens the relevant market page on the correct platform. Notifications are suppressed when the user is already viewing the market and have a 5-minute cooldown per market to avoid spam. ## Synth API usage @@ -37,7 +46,7 @@ The **Position Sizing** card in the side panel answers “how much should I bet? ### Balance detection - The content script (`content.js`) runs on `polymarket.com` and: - - Scrapes **wallet / account balance** from compact DOM text such as `Balance 123.45 USDC` or `$123.45`. + - Scrapes **wallet / account balance** from compact DOM text such as `Balance 123.45 USDC` or `$123.45` (works on both Polymarket and Kalshi). - Exposes this numeric balance as `balance` in the context returned to the side panel. - In the side panel (`sidepanel.html` / `sidepanel.js`): - The **Balance** field is pre-filled with the scraped value when available. @@ -113,9 +122,9 @@ The UI shows: ## Run locally 1. Install: `pip install -r requirements.txt` (from repo root: `pip install -r tools/synth-overlay/requirements.txt`). -2. Start server (from repo root): `python tools/synth-overlay/server.py` (or from `tools/synth-overlay`: `python server.py`). Listens on `127.0.0.1:8765`. +2. Start server (from repo root): `python tools/synth-overlay/server.py` (or from `tools/synth-overlay`: `python server.py`). Listens on `127.0.0.1:8765`. Set `SERVER_HOST` env var to change bind address. 3. Load extension: Chrome → Extensions → Load unpacked → select `tools/synth-overlay/extension`. -4. Click the extension icon to open **Chrome Side Panel** (or pin and open from Side Panel UI). On Polymarket pages, the panel auto-enables. +4. Click the extension icon to open **Chrome Side Panel** (or pin and open from Side Panel UI). On Polymarket or Kalshi pages, the panel auto-enables. ## Verify the side panel (before recording) @@ -126,9 +135,11 @@ The UI shows: You should see JSON with `"signal"`, `"edge_pct"`, etc. If you see `"error"` or 404, the slug is not supported for the current mock/API. 2. **Open the exact URL** in Chrome (with the extension loaded from `extension/`): - - Daily (BTC): `https://polymarket.com/event/bitcoin-up-or-down-on-february-26` - - Hourly (ETH): `https://polymarket.com/event/ethereum-up-or-down-february-25-6pm-et` - - 15-Min (SOL): `https://polymarket.com/event/sol-updown-15m-1772204400` + - **Polymarket** Daily (BTC): `https://polymarket.com/event/bitcoin-up-or-down-on-february-26` + - **Polymarket** Hourly (ETH): `https://polymarket.com/event/ethereum-up-or-down-february-25-6pm-et` + - **Polymarket** 15-Min (SOL): `https://polymarket.com/event/sol-updown-15m-1772204400` + - **Kalshi** Daily (BTC): `https://kalshi.com/markets/kxbtcd` + - **Kalshi** Range (BTC): `https://kalshi.com/markets/kxbtc` - The side panel requests the slug from the page and fetches Synth data from the local API. If API returns 200, panel fields populate. 3. **Interaction:** diff --git a/tools/synth-overlay/extension/alerts.js b/tools/synth-overlay/extension/alerts.js index 0684c0f..a5da511 100644 --- a/tools/synth-overlay/extension/alerts.js +++ b/tools/synth-overlay/extension/alerts.js @@ -7,7 +7,7 @@ * Storage keys (chrome.storage.local): * synth_alerts_enabled : boolean * synth_alerts_threshold : number (edge pp, default 3.0) - * synth_alerts_watchlist : Array<{ slug, asset, label, addedAt }> + * synth_alerts_watchlist : Array<{ slug, asset, label, platform, addedAt }> * synth_alerts_cooldowns : Object { slug: timestamp } * synth_alerts_history : Array<{ slug, label, title, message, edgePct, signal, timestamp }> * synth_alerts_auto_dismiss : boolean (default false) @@ -72,8 +72,10 @@ var SynthAlerts = (function () { // ---- Watchlist ---- - function addToWatchlist(slug, asset, label, callback) { + function addToWatchlist(slug, asset, label, platform, callback) { if (!slug) { if (callback) callback([]); return; } + // Backward compat: if platform is a function, it's the old callback signature + if (typeof platform === "function") { callback = platform; platform = "polymarket"; } load(function (settings) { var exists = settings.watchlist.some(function (w) { return w.slug === slug; }); if (exists) { if (callback) callback(settings.watchlist); return; } @@ -82,6 +84,7 @@ var SynthAlerts = (function () { slug: slug, asset: asset || "BTC", label: label || slug, + platform: platform || "polymarket", addedAt: Date.now(), }); saveWatchlist(settings.watchlist); @@ -180,9 +183,14 @@ var SynthAlerts = (function () { // ---- Format ---- - function formatMarketLabel(asset, marketType) { + function formatMarketLabel(asset, marketType, platform) { var typeMap = { daily: "24h", hourly: "1h", "15min": "15m", "5min": "5m" }; - return (asset || "BTC") + " " + (typeMap[marketType] || marketType || "daily"); + var prefix = ""; + if (platform && platform !== "polymarket" && typeof SynthPlatforms !== "undefined") { + var p = SynthPlatforms.get(platform); + if (p) prefix = p.label + " "; + } + return prefix + (asset || "BTC") + " " + (typeMap[marketType] || marketType || "daily"); } return { diff --git a/tools/synth-overlay/extension/background.js b/tools/synth-overlay/extension/background.js index 163bf7e..514376e 100644 --- a/tools/synth-overlay/extension/background.js +++ b/tools/synth-overlay/extension/background.js @@ -1,6 +1,8 @@ -var SUPPORTED_ORIGINS = [ - "https://polymarket.com/" -]; +// ---- Platform-aware background service worker ---- +// Uses SynthPlatforms (loaded via importScripts) as single source of truth +// for supported origins and market URLs. + +try { importScripts("platforms.js"); } catch (_e) {} var API_BASE = "http://127.0.0.1:8765"; var ALARM_NAME = "synth-alert-poll"; @@ -20,10 +22,8 @@ var STORE_KEYS = { var COOLDOWN_MS = 5 * 60 * 1000; function isSupportedUrl(url) { - for (var i = 0; i < SUPPORTED_ORIGINS.length; i++) { - if (url.indexOf(SUPPORTED_ORIGINS[i]) === 0) return true; - } - return false; + if (typeof SynthPlatforms !== "undefined") return SynthPlatforms.isSupportedUrl(url); + return url && url.indexOf("polymarket.com") !== -1; } // ---- Side Panel ---- @@ -47,8 +47,7 @@ chrome.tabs.onUpdated.addListener(function (tabId, info, tab) { } }); -// Poll immediately when user switches away from a Polymarket tab. -// If tab.url is unavailable (e.g. chrome:// pages), assume non-Polymarket. +// Poll immediately when user switches away from a supported tab. chrome.tabs.onActivated.addListener(function (activeInfo) { chrome.tabs.get(activeInfo.tabId, function (tab) { if (chrome.runtime.lastError || !tab) { pollWatchlist(); return; } @@ -143,7 +142,9 @@ function pollWatchlist() { } function checkMarketEdge(item, threshold) { - var url = API_BASE + "/api/edge?slug=" + encodeURIComponent(item.slug); + var platform = item.platform || "polymarket"; + var url = API_BASE + "/api/edge?slug=" + encodeURIComponent(item.slug) + + "&platform=" + encodeURIComponent(platform); fetch(url) .then(function (res) { if (!res.ok) throw new Error("HTTP " + res.status); @@ -170,7 +171,7 @@ function suppressAndNotify(item, data, cooldowns) { chrome.tabs.query({ active: true, lastFocusedWindow: true }, function (tabs) { var activeUrl = (tabs && tabs[0] && tabs[0].url) || ""; - if (activeUrl.indexOf("polymarket.com") !== -1 && activeUrl.indexOf(item.slug) !== -1) { + if (isSupportedUrl(activeUrl) && activeUrl.indexOf(item.slug) !== -1) { return; } cooldowns[item.slug] = Date.now(); @@ -196,12 +197,15 @@ function createEdgeNotification(notifId, item, data) { var conf = data.confidence_score != null ? Math.round(data.confidence_score * 100) + "%" : "—"; var confLabel = data.confidence_score >= 0.7 ? "High" : data.confidence_score >= 0.4 ? "Med" : "Low"; + var platform = item.platform || "polymarket"; + var platformLabel = (typeof SynthPlatforms !== "undefined" && SynthPlatforms.get(platform)) + ? SynthPlatforms.get(platform).label : "Poly"; var synthUp = fmtProb(data.synth_probability_up != null ? data.synth_probability_up : data.synth_probability); var polyUp = fmtProb(data.polymarket_probability_up != null ? data.polymarket_probability_up : data.polymarket_probability); var title = (item.label || item.slug) + " — " + direction + " " + sign + edge + "pp"; var lines = [ - "Synth " + synthUp + " vs Poly " + polyUp + " | " + strength, + "Synth " + synthUp + " vs " + platformLabel + " " + polyUp + " | " + strength, "Confidence: " + confLabel + " (" + conf + ")", ]; if (data.explanation) { @@ -211,6 +215,7 @@ function createEdgeNotification(notifId, item, data) { // Save to notification history var historyEntry = { slug: item.slug, + platform: platform, label: item.label || item.slug, title: title, message: lines.join("\n"), @@ -242,29 +247,42 @@ function createEdgeNotification(notifId, item, data) { }); } -// Focus or open the Polymarket page for the clicked notification +// Focus or open the correct market page for the clicked notification. +// Uses stored platform from watchlist to build the right URL. chrome.notifications.onClicked.addListener(function (notifId) { if (notifId.indexOf("synth-edge::") !== 0) return; var slug = notifId.replace("synth-edge::", ""); if (!slug) { chrome.notifications.clear(notifId); return; } - var targetUrl = "https://polymarket.com/event/" + slug; - - chrome.tabs.query({ url: "https://polymarket.com/*" }, function (tabs) { - var match = null; - for (var i = 0; i < tabs.length; i++) { - if (tabs[i].url && tabs[i].url.indexOf(slug) !== -1) { - match = tabs[i]; - break; - } - } - if (match) { - chrome.tabs.update(match.id, { active: true }); - chrome.windows.update(match.windowId, { focused: true }); - } else { - chrome.tabs.create({ url: targetUrl }); + // Look up the watchlist to get the platform for this slug + chrome.storage.local.get([STORE_KEYS.watchlist], function (result) { + var watchlist = Array.isArray(result[STORE_KEYS.watchlist]) ? result[STORE_KEYS.watchlist] : []; + var item = null; + for (var i = 0; i < watchlist.length; i++) { + if (watchlist[i].slug === slug) { item = watchlist[i]; break; } } - chrome.notifications.clear(notifId); + var platform = (item && item.platform) || "polymarket"; + var targetUrl = (typeof SynthPlatforms !== "undefined") + ? SynthPlatforms.marketUrl(platform, slug) + : "https://polymarket.com/event/" + slug; + + // Search across all supported platform tabs + chrome.tabs.query({}, function (tabs) { + var match = null; + for (var j = 0; j < tabs.length; j++) { + if (tabs[j].url && isSupportedUrl(tabs[j].url) && tabs[j].url.indexOf(slug) !== -1) { + match = tabs[j]; + break; + } + } + if (match) { + chrome.tabs.update(match.id, { active: true }); + chrome.windows.update(match.windowId, { focused: true }); + } else { + chrome.tabs.create({ url: targetUrl }); + } + chrome.notifications.clear(notifId); + }); }); }); diff --git a/tools/synth-overlay/extension/content.js b/tools/synth-overlay/extension/content.js index 1837e83..f77392b 100644 --- a/tools/synth-overlay/extension/content.js +++ b/tools/synth-overlay/extension/content.js @@ -1,24 +1,72 @@ (function () { "use strict"; + // ── Context-invalidation guard ────────────────────────────────────── + var _contextValid = true; + var _pollInterval = null; + function isContextValid() { + if (!_contextValid) return false; + try { void chrome.runtime.id; return true; } + catch (_e) { _contextValid = false; teardown(); return false; } + } + function teardown() { + if (_pollInterval) { clearInterval(_pollInterval); _pollInterval = null; } + if (observer) { observer.disconnect(); } + console.log("[Synth-Overlay] Extension context invalidated, content script stopped."); + } + + // ── Platform detection (once at init) ─────────────────────────────── + var currentPlatform = (function () { + var host = (window.location.hostname || "").toLowerCase(); + if (host.indexOf("kalshi.com") !== -1) return "kalshi"; + if (host.indexOf("polymarket.com") !== -1) return "polymarket"; + return null; + })(); + // Track last known prices to detect changes var lastPrices = { upPrice: null, downPrice: null }; - function slugFromPage() { - var host = window.location.hostname || ""; - var path = window.location.pathname || ""; - var segments = path.split("/").filter(Boolean); - - if (host.indexOf("polymarket.com") !== -1) { - var first = segments[0]; - var second = segments[1] || segments[0]; - if (first === "event" || first === "market") return second || null; - return first || null; + // ── Slug extraction — strategy per platform ───────────────────────── + + function slugFromPolymarket() { + var segments = (window.location.pathname || "").split("/").filter(Boolean); + var first = segments[0]; + var second = segments[1] || segments[0]; + if (first === "event" || first === "market") return second || null; + return first || null; + } + + function slugFromKalshi() { + var segments = (window.location.pathname || "").split("/").filter(Boolean); + if (segments[0] === "browse" || segments[0] === "portfolio") return null; + // Find the /markets/ or /events/ prefix index + var baseIdx = -1; + for (var i = 0; i < segments.length; i++) { + if (segments[i] === "markets" || segments[i] === "events" || segments[i] === "market") { + baseIdx = i; break; + } + } + if (baseIdx >= 0 && baseIdx < segments.length - 1) { + // Prefer the last segment — it's the most specific ticker + // e.g. /markets/kxbtcd/bitcoin-daily/KXBTCD-26MAR1317 → KXBTCD-26MAR1317 + var last = segments[segments.length - 1]; + if (last && last !== "markets" && last !== "events" && last !== "market") return last; + // Fallback to first segment after /markets/ + return segments[baseIdx + 1]; } + // Fallback: last path segment + return segments[segments.length - 1] || null; + } + function slugFromPage() { + if (currentPlatform === "kalshi") return slugFromKalshi(); + if (currentPlatform === "polymarket") return slugFromPolymarket(); + var segments = (window.location.pathname || "").split("/").filter(Boolean); return segments[segments.length - 1] || null; } + // ── Shared helpers ────────────────────────────────────────────────── + /** * Validate that a pair of binary market prices sums to roughly 100¢. * Allows spread of 90-110 to account for market maker spread. @@ -63,6 +111,22 @@ return null; } + /** Try to infer a missing side from the found side. */ + function inferPair(yesPrice, noPrice) { + if (yesPrice !== null && noPrice !== null && validatePricePair(yesPrice, noPrice)) { + return { upPrice: yesPrice, downPrice: noPrice }; + } + if (yesPrice !== null && yesPrice >= 0.01 && yesPrice <= 0.99) { + return { upPrice: yesPrice, downPrice: 1 - yesPrice }; + } + if (noPrice !== null && noPrice >= 0.01 && noPrice <= 0.99) { + return { upPrice: 1 - noPrice, downPrice: noPrice }; + } + return null; + } + + // ── Polymarket price scraper ──────────────────────────────────────── + /** * Scrape live Polymarket prices from the DOM. * Returns { upPrice: 0.XX, downPrice: 0.XX } or null if not found. @@ -72,7 +136,7 @@ * 2. Price-only leaf elements with parent context walk (live React state) * 3. __NEXT_DATA__ JSON (fallback — SSR snapshot, may be stale) */ - function scrapeLivePrices() { + function scrapePolymarketPrices() { var upPrice = null; var downPrice = null; @@ -130,17 +194,10 @@ if (upPrice !== null && downPrice !== null) break; } - if (upPrice !== null && downPrice !== null && validatePricePair(upPrice, downPrice)) { - console.log("[Synth-Overlay] Prices from leaf walk:", { upPrice: upPrice, downPrice: downPrice }); - return { upPrice: upPrice, downPrice: downPrice }; - } - - // If only one DOM price found, infer the other - if (upPrice !== null && upPrice >= 0.01 && upPrice <= 0.99) { - return { upPrice: upPrice, downPrice: 1 - upPrice }; - } - if (downPrice !== null && downPrice >= 0.01 && downPrice <= 0.99) { - return { upPrice: 1 - downPrice, downPrice: downPrice }; + var inferred = inferPair(upPrice, downPrice); + if (inferred) { + console.log("[Synth-Overlay] Prices from leaf walk:", inferred); + return inferred; } // Strategy 3 (FALLBACK): Parse __NEXT_DATA__ — SSR snapshot, may be stale @@ -159,17 +216,191 @@ console.log("[Synth-Overlay] __NEXT_DATA__ parse failed:", e.message); } - // Throttle this log to avoid console spam on resolved/expired markets - var now = Date.now(); - if (!scrapeLivePrices._lastWarn || now - scrapeLivePrices._lastWarn > 10000) { - scrapeLivePrices._lastWarn = now; - console.log("[Synth-Overlay] Could not scrape live prices from DOM"); + return null; + } + + // ── Kalshi price scraper (trading-panel-aware) ────────────────────── + + /** + * Extract a Yes or No price from text. + * side: "yes" or "no" — matches Yes/Up or No/Down keywords. + */ + function extractKalshiPrice(text, side) { + var sidePattern = side === "yes" ? "(yes|up)" : "(no|down)"; + // Cent format: "Yes 52¢" / "Buy Yes 52¢" + var cm = text.match(new RegExp("(?:buy\\s+)?" + sidePattern + "\\s+(\\d{1,2})\\s*[¢c%]", "i")); + if (cm) { + var cv = parseInt(cm[2], 10) / 100; + if (cv >= 0.01 && cv <= 0.99) return cv; + } + // Dollar format: "Yes $0.52" / "Buy Yes $0.52" + var dm = text.match(new RegExp("(?:buy\\s+)?" + sidePattern + "\\s+\\$?(0\\.\\d{2,4})", "i")); + if (dm) { + var dv = parseFloat(dm[2]); + if (dv >= 0.01 && dv <= 0.99) return dv; } return null; } /** - * Best-effort scraper for the user's Polymarket USD/USDC balance. + * Check if an element is inside the Kalshi trading panel (Buy/Sell/Amount). + * Walks up to 8 ancestors looking for the order form container. + */ + function isInTradingPanel(el) { + var ancestor = el.parentElement; + for (var up = 0; up < 8 && ancestor; up++) { + var aText = (ancestor.textContent || "").toLowerCase(); + if (aText.length < 500 && /\bbuy\b/.test(aText) && /\bsell\b/.test(aText) && /\bamount\b/.test(aText)) { + return true; + } + if (aText.length < 500 && /\bbuy\b/.test(aText) && /sign up/i.test(aText)) { + return true; + } + ancestor = ancestor.parentElement; + } + return false; + } + + /** + * Scrape live prices from Kalshi's DOM. + * Uses a two-pass approach: Pass 1 prioritises the trading panel (selected + * contract on multi-strike pages). Pass 2 falls back to a general scan. + */ + function scrapeKalshiPrices() { + var yesPrice = null; + var noPrice = null; + var els = document.querySelectorAll("button, a, span, div, p, [role='button'], [role='cell'], td"); + + // Pass 1: PRIORITY — only accept prices inside the trading panel + for (var i = 0; i < els.length; i++) { + var text = (els[i].textContent || "").trim(); + if (text.length > 40 || text.length < 2) continue; + if (yesPrice === null) { + var yp = extractKalshiPrice(text, "yes"); + if (yp !== null && isInTradingPanel(els[i])) yesPrice = yp; + } + if (noPrice === null) { + var np = extractKalshiPrice(text, "no"); + if (np !== null && isInTradingPanel(els[i])) noPrice = np; + } + if (yesPrice !== null && noPrice !== null) break; + } + if (yesPrice !== null && noPrice !== null && validatePricePair(yesPrice, noPrice)) { + console.log("[Synth-Overlay] Kalshi prices from trading panel:", { upPrice: yesPrice, downPrice: noPrice }); + return { upPrice: yesPrice, downPrice: noPrice }; + } + + // Pass 2: FALLBACK — scan all elements + yesPrice = null; + noPrice = null; + for (var j = 0; j < els.length; j++) { + var text2 = (els[j].textContent || "").trim(); + if (text2.length > 40 || text2.length < 2) continue; + if (yesPrice === null) { + var yv = extractKalshiPrice(text2, "yes"); + if (yv !== null) yesPrice = yv; + } + if (noPrice === null) { + var nv = extractKalshiPrice(text2, "no"); + if (nv !== null) noPrice = nv; + } + // Standalone price with parent context walk + if (yesPrice === null || noPrice === null) { + var pm = text2.match(/^(\d{1,2})\s*[¢c%]$/) || text2.match(/^\$?(0\.\d{2,4})$/); + if (pm) { + var rawVal = pm[1]; + var price = rawVal.indexOf(".") !== -1 ? parseFloat(rawVal) : parseInt(rawVal, 10) / 100; + if (price >= 0.01 && price <= 0.99) { + var parent = els[j].parentElement; + for (var dd = 0; dd < 5 && parent; dd++) { + var pText = (parent.textContent || "").toLowerCase(); + if (pText.length > 120) break; + if (/\b(yes|up)\b/.test(pText) && yesPrice === null) { yesPrice = price; break; } + if (/\b(no|down)\b/.test(pText) && noPrice === null) { noPrice = price; break; } + parent = parent.parentElement; + } + } + } + } + if (yesPrice !== null && noPrice !== null) break; + } + + var inferred = inferPair(yesPrice, noPrice); + if (inferred) { + console.log("[Synth-Overlay] Kalshi prices from DOM:", inferred); + return inferred; + } + + // Pass 3: Order book pattern — "Best Yes: $0.52" / "Best Yes: 52¢" + yesPrice = null; + noPrice = null; + for (var ob = 0; ob < els.length; ob++) { + var obText = (els[ob].textContent || "").trim(); + if (obText.length > 60 || obText.length < 5) continue; + if (yesPrice === null) { + var ym = obText.match(/best\s+yes[:\s]+\$?(0\.\d{2,4})/i) || obText.match(/best\s+yes[:\s]+(\d{1,2})\s*[¢c%]/i); + if (ym) { + var yv3 = ym[1].indexOf(".") !== -1 ? parseFloat(ym[1]) : parseInt(ym[1], 10) / 100; + if (yv3 >= 0.01 && yv3 <= 0.99) yesPrice = yv3; + } + } + if (noPrice === null) { + var nm = obText.match(/best\s+no[:\s]+\$?(0\.\d{2,4})/i) || obText.match(/best\s+no[:\s]+(\d{1,2})\s*[¢c%]/i); + if (nm) { + var nv3 = nm[1].indexOf(".") !== -1 ? parseFloat(nm[1]) : parseInt(nm[1], 10) / 100; + if (nv3 >= 0.01 && nv3 <= 0.99) noPrice = nv3; + } + } + if (yesPrice !== null && noPrice !== null) break; + } + var obInferred = inferPair(yesPrice, noPrice); + if (obInferred) { + console.log("[Synth-Overlay] Kalshi prices from order book pattern:", obInferred); + return obInferred; + } + + // Fallback: __NEXT_DATA__ + try { + var ndEl = document.getElementById("__NEXT_DATA__"); + if (ndEl) { + var fromND = findOutcomePricesInObject(JSON.parse(ndEl.textContent)); + if (fromND && validatePricePair(fromND.upPrice, fromND.downPrice)) return fromND; + } + } catch (_e) {} + + return null; + } + + // ── Unified price scraper — dispatches per platform ───────────────── + + function scrapePrices() { + if (currentPlatform === "kalshi") return scrapeKalshiPrices(); + return scrapePolymarketPrices(); + } + + // ── Kalshi browse-page market link scanner ────────────────────────── + + function scanKalshiMarketLinks() { + if (currentPlatform !== "kalshi") return []; + if (slugFromPage()) return []; + var seen = {}; + var results = []; + var anchors = document.querySelectorAll("a[href]"); + for (var i = 0; i < anchors.length && results.length < 10; i++) { + var href = anchors[i].getAttribute("href") || ""; + var m = href.match(/\/(markets|events)\/((kx[a-z0-9]+|btc|eth|btcd|ethd)[a-z0-9._-]*)/i); + if (!m) continue; + var ticker = m[2]; + if (seen[ticker]) continue; + seen[ticker] = true; + var label = (anchors[i].textContent || "").trim().substring(0, 60) || ticker; + results.push({ ticker: ticker, href: href, label: label }); + } + return results; + } + + /** + * Best-effort scraper for the user's account balance. * Returns a float balance in dollars, or null if not found. */ function scrapeBalance() { @@ -204,44 +435,57 @@ return best; } + // ── Context builder ───────────────────────────────────────────────── + function getContext() { - var livePrices = scrapeLivePrices(); + var slug = slugFromPage(); + var livePrices = scrapePrices(); var balance = scrapeBalance(); + var suggestedMarkets = (!slug && currentPlatform === "kalshi") ? scanKalshiMarketLinks() : []; return { - slug: slugFromPage(), + slug: slug, url: window.location.href, host: window.location.hostname, + platform: currentPlatform, pageUpdatedAt: Date.now(), livePrices: livePrices, balance: balance, + suggestedMarkets: suggestedMarkets, }; } - // Broadcast price update to extension + // ── Broadcasting ──────────────────────────────────────────────────── + + function safeSend(msg) { + if (!isContextValid()) return; + try { chrome.runtime.sendMessage(msg).catch(function() {}); } + catch (_e) { _contextValid = false; teardown(); } + } + function broadcastPriceUpdate(prices) { if (!prices) return; - chrome.runtime.sendMessage({ + safeSend({ type: "synth:priceUpdate", prices: prices, slug: slugFromPage(), timestamp: Date.now() - }).catch(function() {}); + }); } - // Check if prices changed and broadcast if so function checkAndBroadcastPrices() { - var prices = scrapeLivePrices(); + if (!_contextValid) return; + var prices = scrapePrices(); if (!prices) return; - if (prices.upPrice !== lastPrices.upPrice || prices.downPrice !== lastPrices.downPrice) { lastPrices = { upPrice: prices.upPrice, downPrice: prices.downPrice }; broadcastPriceUpdate(prices); } } - // Set up MutationObserver for instant price detection + // ── MutationObserver ──────────────────────────────────────────────── + var observer = new MutationObserver(function(mutations) { - // Debounce: only check every 100ms max + if (!_contextValid) return; if (observer._pending) return; observer._pending = true; setTimeout(function() { @@ -250,7 +494,6 @@ }, 100); }); - // Start observing DOM changes if (document.body) { observer.observe(document.body, { childList: true, @@ -259,26 +502,26 @@ }); } - // Detect SPA navigation (Polymarket uses Next.js client-side routing) + // ── SPA navigation detection ──────────────────────────────────────── + var lastSlug = slugFromPage(); function checkUrlChange() { + if (!isContextValid()) return; var newSlug = slugFromPage(); if (newSlug !== lastSlug) { console.log("[Synth-Overlay] URL changed:", lastSlug, "->", newSlug); lastSlug = newSlug; lastPrices = { upPrice: null, downPrice: null }; - chrome.runtime.sendMessage({ + safeSend({ type: "synth:urlChanged", slug: newSlug, url: window.location.href, timestamp: Date.now() - }).catch(function() {}); - // Immediately scrape and broadcast new prices + }); setTimeout(checkAndBroadcastPrices, 200); } } - // Intercept history.pushState and replaceState for SPA navigation var origPushState = history.pushState; var origReplaceState = history.replaceState; history.pushState = function() { @@ -291,23 +534,26 @@ }; window.addEventListener("popstate", checkUrlChange); - // Also poll every 500ms as backup for any missed mutations or navigation - setInterval(function() { + _pollInterval = setInterval(function() { + if (!isContextValid()) return; checkAndBroadcastPrices(); checkUrlChange(); }, 500); - // Initial broadcast setTimeout(checkAndBroadcastPrices, 500); - // Handle requests from sidepanel + // ── Message handler ───────────────────────────────────────────────── + chrome.runtime.onMessage.addListener(function (message, _sender, sendResponse) { + if (!isContextValid()) return; if (!message || typeof message !== "object") return; - if (message.type === "synth:getContext") { - sendResponse({ ok: true, context: getContext() }); - } - if (message.type === "synth:getPrices") { - sendResponse({ ok: true, prices: scrapeLivePrices() }); - } + try { + if (message.type === "synth:getContext") { + sendResponse({ ok: true, context: getContext() }); + } + if (message.type === "synth:getPrices") { + sendResponse({ ok: true, prices: scrapePrices() }); + } + } catch (_e) {} }); })(); diff --git a/tools/synth-overlay/extension/manifest.json b/tools/synth-overlay/extension/manifest.json index 335d726..42ee7c9 100644 --- a/tools/synth-overlay/extension/manifest.json +++ b/tools/synth-overlay/extension/manifest.json @@ -1,11 +1,13 @@ { "manifest_version": 3, "name": "Synth Panel", - "version": "1.3.0", - "description": "Chrome Side Panel for Synth forecast data on market sites", + "version": "1.4.0", + "description": "Chrome Side Panel for Synth forecast data on Polymarket and Kalshi", "permissions": ["activeTab", "tabs", "sidePanel", "notifications", "storage", "alarms"], "host_permissions": [ "https://polymarket.com/*", + "https://kalshi.com/*", + "https://*.kalshi.com/*", "http://127.0.0.1:8765/*" ], "background": { @@ -30,7 +32,9 @@ "content_scripts": [ { "matches": [ - "https://polymarket.com/*" + "https://polymarket.com/*", + "https://kalshi.com/*", + "https://*.kalshi.com/*" ], "js": ["content.js"], "run_at": "document_idle" diff --git a/tools/synth-overlay/extension/platforms.js b/tools/synth-overlay/extension/platforms.js new file mode 100644 index 0000000..9b72030 --- /dev/null +++ b/tools/synth-overlay/extension/platforms.js @@ -0,0 +1,75 @@ +/** + * Platform registry — single source of truth for supported platforms. + * + * Every JS file (content, background, sidepanel) imports this instead of + * hard-coding URL checks. Adding a third platform means adding one entry + * here; no scattered if/else across the codebase. + */ +"use strict"; + +var SynthPlatforms = (function () { + + var PLATFORMS = { + polymarket: { + name: "polymarket", + label: "Poly", + origins: ["https://polymarket.com/"], + domainHint: "polymarket.com", + marketUrlTemplate: "https://polymarket.com/event/{slug}", + tabSearchPattern: "https://polymarket.com/*", + }, + kalshi: { + name: "kalshi", + label: "Kalshi", + origins: ["https://kalshi.com/"], + domainHint: "kalshi.com", + marketUrlTemplate: "https://kalshi.com/markets/{slug}", + tabSearchPattern: "https://kalshi.com/*", + }, + }; + + /** All origins across every platform (flat array). */ + var ALL_ORIGINS = []; + for (var key in PLATFORMS) { + ALL_ORIGINS = ALL_ORIGINS.concat(PLATFORMS[key].origins); + } + + /** Check if a URL belongs to any supported platform. */ + function isSupportedUrl(url) { + if (!url) return false; + for (var key in PLATFORMS) { + if (url.indexOf(PLATFORMS[key].domainHint) !== -1) return true; + } + return false; + } + + /** Detect platform name from a URL string, or null. */ + function fromUrl(url) { + if (!url) return null; + for (var key in PLATFORMS) { + if (url.indexOf(PLATFORMS[key].domainHint) !== -1) return PLATFORMS[key]; + } + return null; + } + + /** Get platform config by name. */ + function get(name) { + return PLATFORMS[name] || null; + } + + /** Build the market page URL for a given platform + slug. */ + function marketUrl(platformName, slug) { + var p = PLATFORMS[platformName]; + if (!p) return null; + return p.marketUrlTemplate.replace("{slug}", slug); + } + + return { + PLATFORMS: PLATFORMS, + ALL_ORIGINS: ALL_ORIGINS, + isSupportedUrl: isSupportedUrl, + fromUrl: fromUrl, + get: get, + marketUrl: marketUrl, + }; +})(); diff --git a/tools/synth-overlay/extension/sidepanel.html b/tools/synth-overlay/extension/sidepanel.html index 579ed3d..38c2adf 100644 --- a/tools/synth-overlay/extension/sidepanel.html +++ b/tools/synth-overlay/extension/sidepanel.html @@ -19,7 +19,7 @@

Synth

Price Comparison
- SynthPolyΔ + SynthPolyΔ
Up @@ -136,6 +136,7 @@

Synth

+ diff --git a/tools/synth-overlay/extension/sidepanel.js b/tools/synth-overlay/extension/sidepanel.js index 842696c..3d6fd68 100644 --- a/tools/synth-overlay/extension/sidepanel.js +++ b/tools/synth-overlay/extension/sidepanel.js @@ -6,6 +6,12 @@ const API_BASE = "http://127.0.0.1:8765"; var cachedSynthData = null; var cachedMarketType = null; var currentSlug = null; +var currentPlatform = "polymarket"; + +function isSupportedUrl(url) { + if (typeof SynthPlatforms !== "undefined") return SynthPlatforms.isSupportedUrl(url); + return url && (url.indexOf("polymarket.com") !== -1 || url.indexOf("kalshi.com") !== -1); +} const els = { statusText: document.getElementById("statusText"), @@ -13,6 +19,7 @@ const els = { synthDown: document.getElementById("synthDown"), polyUp: document.getElementById("polyUp"), polyDown: document.getElementById("polyDown"), + marketLabel: document.getElementById("marketLabel"), deltaUp: document.getElementById("deltaUp"), deltaDown: document.getElementById("deltaDown"), edgeValue: document.getElementById("edgeValue"), @@ -75,7 +82,7 @@ function fmtDelta(synth, poly) { async function activeSupportedTab() { const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); const tab = tabs && tabs[0]; - if (!tab || !tab.url || !tab.url.startsWith("https://polymarket.com/")) return null; + if (!tab || !tab.url || !isSupportedUrl(tab.url)) return null; return tab; } @@ -88,15 +95,20 @@ async function getContextFromPage(tabId) { } } -async function fetchEdge(slug, livePrices) { +async function fetchEdge(slug, livePrices, platform) { var url = API_BASE + "/api/edge?slug=" + encodeURIComponent(slug); + if (platform) url += "&platform=" + encodeURIComponent(platform); // Pass live prices to server if available for real-time edge calculation if (livePrices && livePrices.upPrice != null) { url += "&live_prob_up=" + encodeURIComponent(livePrices.upPrice); } - const res = await fetch(url); - if (!res.ok) return null; - return await res.json(); + try { + const res = await fetch(url); + if (!res.ok) return null; + return await res.json(); + } catch (_e) { + return { error: "Cannot reach Synth server at " + API_BASE }; + } } function render(state) { @@ -105,6 +117,7 @@ function render(state) { els.synthDown.textContent = state.synthDown; els.polyUp.textContent = state.polyUp || "—"; els.polyDown.textContent = state.polyDown || "—"; + if (els.marketLabel) els.marketLabel.textContent = state.marketLabel || "Poly"; els.deltaUp.textContent = state.deltaUp ? state.deltaUp.text : "—"; els.deltaUp.className = "delta " + (state.deltaUp ? state.deltaUp.cls : ""); els.deltaDown.textContent = state.deltaDown ? state.deltaDown.text : "—"; @@ -383,7 +396,7 @@ async function refresh() { const tab = await activeSupportedTab(); if (!tab) { render(Object.assign({}, EMPTY, { - status: "Open a Polymarket event tab to view Synth data.", + status: "Open a Polymarket or Kalshi market tab to view Synth data.", analysis: "No active market tab found.", })); return; @@ -391,6 +404,29 @@ async function refresh() { const ctx = await getContextFromPage(tab.id); if (!ctx || !ctx.slug) { + // On Kalshi browse pages, show suggested markets if available + if (ctx && ctx.suggestedMarkets && ctx.suggestedMarkets.length > 0) { + render(Object.assign({}, EMPTY, { + status: "Kalshi browse page — pick a market.", + analysis: "Navigate to a specific market to see Synth data.", + })); + // Render suggested market buttons + var container = document.createElement("div"); + container.style.cssText = "margin-top:8px;display:flex;flex-wrap:wrap;gap:6px;"; + for (var si = 0; si < ctx.suggestedMarkets.length && si < 8; si++) { + (function(mkt) { + var btn = document.createElement("button"); + btn.textContent = mkt.label || mkt.ticker; + btn.style.cssText = "padding:3px 8px;font-size:11px;border:1px solid #555;border-radius:4px;background:#2a2a3e;color:#e0e0f0;cursor:pointer;"; + btn.addEventListener("click", function() { + chrome.tabs.update(tab.id, { url: "https://kalshi.com" + (mkt.href.startsWith("/") ? "" : "/") + mkt.href }); + }); + container.appendChild(btn); + })(ctx.suggestedMarkets[si]); + } + els.analysisText.parentElement.appendChild(container); + return; + } render(Object.assign({}, EMPTY, { status: "Could not read market context from page.", analysis: "Reload the page and try refresh again.", @@ -398,7 +434,9 @@ async function refresh() { return; } - const edge = await fetchEdge(ctx.slug, ctx.livePrices); + var ctxPlatform = ctx.platform || "polymarket"; + + const edge = await fetchEdge(ctx.slug, ctx.livePrices, ctxPlatform); if (!edge || edge.error) { render(Object.assign({}, EMPTY, { status: "Market not supported by Synth for this slug.", @@ -418,6 +456,7 @@ async function refresh() { cachedSynthData = edge; cachedMarketType = mtype; currentSlug = ctx.slug; + currentPlatform = ctxPlatform; if (typeof updateWatchBtnState === "function") updateWatchBtnState(); // Log live price status for debugging @@ -451,8 +490,11 @@ async function refresh() { } var liveStatus = ctx.livePrices ? " (Live)" : ""; + var platformLabel = (typeof SynthPlatforms !== "undefined" && SynthPlatforms.get(ctxPlatform)) + ? SynthPlatforms.get(ctxPlatform).label : (ctxPlatform === "kalshi" ? "Kalshi" : "Poly"); render({ status: "Synced — " + asset + " " + horizon + " forecast." + liveStatus, + marketLabel: platformLabel, synthUp: fmtCentsFromProb(synthProbUp), synthDown: synthProbUp == null ? "—" : fmtCentsFromProb(1 - synthProbUp), polyUp: fmtCentsFromProb(polyProbUp), @@ -534,7 +576,7 @@ chrome.runtime.onMessage.addListener(function(message, sender, sendResponse) { // Also detect tab URL changes (full navigations) chrome.tabs.onUpdated.addListener(function(tabId, changeInfo, tab) { - if (changeInfo.url && tab.active && tab.url && tab.url.startsWith("https://polymarket.com/")) { + if (changeInfo.url && tab.active && tab.url && isSupportedUrl(tab.url)) { console.log("[Synth-Overlay] Tab URL updated:", changeInfo.url); cachedSynthData = null; cachedMarketType = null; @@ -679,8 +721,8 @@ alertEls.watchBtn.addEventListener("click", function () { if (!currentSlug) return; var asset = cachedSynthData ? (cachedSynthData.asset || "BTC") : "BTC"; var mtype = cachedMarketType || "daily"; - var label = SynthAlerts.formatMarketLabel(asset, mtype); - SynthAlerts.addToWatchlist(currentSlug, asset, label, renderWatchlist); + var label = SynthAlerts.formatMarketLabel(asset, mtype, currentPlatform); + SynthAlerts.addToWatchlist(currentSlug, asset, label, currentPlatform, renderWatchlist); }); alertEls.autoDismiss.addEventListener("change", function () { diff --git a/tools/synth-overlay/matcher.py b/tools/synth-overlay/matcher.py index 7f5c596..603738a 100644 --- a/tools/synth-overlay/matcher.py +++ b/tools/synth-overlay/matcher.py @@ -1,7 +1,38 @@ -"""Map Polymarket URL/slug to Synth market type and supported asset.""" +"""Grammar-based market matcher with declarative platform configuration. + +Architecture +============ +This module takes a fundamentally different approach from pattern-matching +implementations that maintain explicit ticker→asset maps and series→type +frozensets for each platform: + +1. **Grammar-based Kalshi parsing** — Tickers like ``KXBTCD-26MAR1317`` + are *structurally decomposed*: ``KX`` prefix + asset code (``BTC``) + + market suffix (``D`` → daily). Asset and market type are **derived** + from the grammar, not looked up. New Kalshi assets work automatically + if the asset code is registered — no series sets to maintain. + +2. **Rule-chain market type inference** — Ordered ``(matcher, type)`` + tuples replace scattered if/else chains. Rules are composable, + independently testable, and declarative. + +3. **Config-driven platform definitions** — Each platform is a data dict + with domain, URL patterns, detection heuristics, and resolution + strategy. A generic engine processes any config. Adding a platform + = adding a config entry + an asset/type resolver function. + +4. **Structured ResolveResult** — Typed error codes (``invalid_input``, + ``unknown_platform``, ``unsupported_market``) so the server returns + actionable 400/404 messages instead of bare ``None``. +""" + +from __future__ import annotations import re -from typing import Literal +from dataclasses import dataclass +from typing import Callable, Literal + +# ── Market-type & platform constants ───────────────────────────────── MARKET_DAILY = "daily" MARKET_HOURLY = "hourly" @@ -9,63 +40,493 @@ MARKET_5MIN = "5min" MARKET_RANGE = "range" -_HOURLY_TIME_PATTERN = re.compile(r"\d{1,2}(am|pm)") -_15MIN_PATTERN = re.compile(r"(updown|up-down)-15m-|(? dict: + """Dict suitable for JSON serialisation (omits None values).""" + return {k: v for k, v in { + "ok": self.ok, + "slug": self.slug, + "asset": self.asset, + "market_type": self.market_type, + "platform": self.platform, + "error_code": self.error_code, + "error": self.error, + }.items() if v is not None} + + +ERR_INVALID_INPUT = "invalid_input" +ERR_UNKNOWN_PLATFORM = "unknown_platform" +ERR_UNSUPPORTED_MARKET = "unsupported_market" +ERR_UNKNOWN_ASSET = "unknown_asset" +ERR_NORMALIZE_FAILED = "normalize_failed" + + +# ═══════════════════════════════════════════════════════════════════════ +# KALSHI — Grammar-based ticker parser +# +# Instead of a 25-entry series→asset map and 3 series→type frozensets +# (the PR #40 / lookup-table approach), we parse Kalshi tickers using +# their *structural grammar*: +# +# KX{asset_code}{market_suffix}-{date}-{strike_or_bracket} +# +# Asset and market type are DERIVED from the structure, not looked up. +# Adding a new Kalshi asset (e.g. LINK, DOT) = one entry in _KX_CODES. +# No series sets to maintain. +# ═══════════════════════════════════════════════════════════════════════ + +# Minimal asset-code table (12 entries vs 25+ in lookup approach) +_KX_CODES: dict[str, str] = { + "btc": "BTC", "eth": "ETH", "sol": "SOL", "xrp": "XRP", "doge": "DOGE", + "spx": "SPY", "spy": "SPY", "nvda": "NVDA", "tsla": "TSLA", + "aapl": "AAPL", "googl": "GOOGL", "xau": "XAU", + # Future Kalshi assets auto-work if code is added here: + "ada": "ADA", "bnb": "BNB", "bch": "BCH", } +# Market-type suffixes — checked longest-first so "15m" beats "m" +_KX_SUFFIXES: list[tuple[str, str]] = [ + ("15m", MARKET_15MIN), + ("d", MARKET_DAILY), + # No suffix → MARKET_RANGE (handled by fallback) +] -def asset_from_slug(slug: str) -> str | None: - """Extract the asset ticker (BTC, ETH, …) from a Polymarket slug prefix.""" - if not slug: - return None - slug_lower = slug.lower() - for prefix, ticker in _ASSET_PREFIXES.items(): - if slug_lower.startswith(prefix + "-"): +# Legacy tickers (no KX prefix) that need collision avoidance +_LEGACY_CODES: dict[str, str] = {"btc": "BTC", "eth": "ETH"} +_LEGACY_VARIANTS: dict[str, tuple[str, str]] = { + "btcd-b": ("BTC", MARKET_DAILY), +} + +# Date-suffix pattern for collision avoidance with Polymarket slugs +_DATE_RE = re.compile(r"^\d{2}[A-Za-z]{3}\d*") +# Contract-level suffixes that override grammar-inferred type +_STRIKE_RE = re.compile(r"-t[\d.]+$", re.I) +_BRACKET_RE = re.compile(r"-b\d+$", re.I) + + +def _parse_kx_base(base: str) -> tuple[str | None, str | None]: + """Derive (asset, market_type) from a KX-prefixed series base. + + Grammar: ``kx`` + asset_code + optional_suffix + Suffixes: ``d`` → daily, ``15m`` → 15min, (none) → range. + """ + if not base.startswith("kx") or len(base) < 4: + return None, None + body = base[2:] + for suffix, mtype in _KX_SUFFIXES: + if body.endswith(suffix): + code = body[: -len(suffix)] + if code in _KX_CODES: + return _KX_CODES[code], mtype + # No suffix matched → range + if body in _KX_CODES: + return _KX_CODES[body], MARKET_RANGE + return None, None + + +def _parse_legacy_base(base: str) -> tuple[str | None, str | None]: + """Derive (asset, market_type) from a legacy (non-KX) series base.""" + for suffix, mtype in _KX_SUFFIXES: + if base.endswith(suffix): + code = base[: -len(suffix)] + if code in _LEGACY_CODES: + return _LEGACY_CODES[code], mtype + if base in _LEGACY_CODES: + return _LEGACY_CODES[base], MARKET_RANGE + return None, None + + +def _kalshi_parse(ticker: str) -> tuple[str | None, str | None, str | None]: + """Grammar-based Kalshi ticker parser. + + Returns ``(series_base, asset, market_type)`` or ``(None, None, None)``. + + Unlike lookup-table approaches, this **derives** asset and market type + from the ticker's structural grammar rather than maintaining separate + series→asset and series→type maps. + """ + if not ticker: + return None, None, None + t = ticker.strip().lower() + parts = t.split("-") + + # ── Explicit legacy variants (btcd-b) ──────────────────────────── + if len(parts) >= 2: + two_seg = parts[0] + "-" + parts[1] + if two_seg in _LEGACY_VARIANTS: + asset, mtype = _LEGACY_VARIANTS[two_seg] + return two_seg, asset, mtype + + base = parts[0] + + # ── KX-prefixed: grammar parse ─────────────────────────────────── + if base.startswith("kx"): + asset, mtype = _parse_kx_base(base) + if asset: + # Contract suffix can override grammar type + if _BRACKET_RE.search(t): + mtype = MARKET_RANGE + elif _STRIKE_RE.search(t): + mtype = MARKET_DAILY + return base, asset, mtype + # Unknown KX ticker with at least 4 chars — might be future asset + if len(base) >= 4: + return base, None, None + + # ── Legacy (no KX prefix) with collision avoidance ─────────────── + asset, mtype = _parse_legacy_base(base) + if asset: + # Bare legacy ticker (e.g. "btcd", "btc") → accept + if len(parts) == 1: + return base, asset, mtype + # Legacy + date suffix (e.g. "btcd-26MAR1317") → accept + remainder = t[len(base) + 1 :] + if _DATE_RE.match(remainder): + return base, asset, mtype + # Otherwise reject — likely a Polymarket slug (btc-updown-5m-...) + return None, None, None + + return None, None, None + + +# ═══════════════════════════════════════════════════════════════════════ +# POLYMARKET — Rule-chain market type inference +# +# Instead of an if/else chain, market type is inferred by walking an +# ordered list of (matcher, type) rules. First match wins. +# Rules are data, not control flow — composable and independently testable. +# ═══════════════════════════════════════════════════════════════════════ + +_POLY_5MIN_RE = re.compile(r"(updown|up-down)-5m-|(? str | None: + s = slug.lower() + for prefix, ticker in _POLY_ASSET_PREFIXES.items(): + if s.startswith(prefix + "-"): return ticker return None -def normalize_slug(url_or_slug: str) -> str | None: - """Extract market slug from Polymarket URL or return slug as-is if already a slug.""" - if not url_or_slug or not isinstance(url_or_slug, str): - return None - s = url_or_slug.strip() - m = re.search(r"polymarket\.com/(?:event/|market/)?([a-zA-Z0-9-]+)", s) - if m: - return m.group(1) - if re.match(r"^[a-zA-Z0-9-]+$", s): +def _poly_resolve_market_type(slug: str) -> str | None: + s = slug.lower() + for matcher, mtype in _POLY_MARKET_RULES: + if matcher(s): + return mtype + return None + + +# ═══════════════════════════════════════════════════════════════════════ +# Config-driven platform definitions +# +# Each platform is a DATA DICT — not a class. The generic resolution +# engine below processes any config. Adding a new platform is adding +# a new dict entry, not writing a new class. +# ═══════════════════════════════════════════════════════════════════════ + +_PlatformConfig = dict # type alias for readability + +_CONFIGS: dict[str, _PlatformConfig] = { + PLATFORM_POLYMARKET: { + "domain": "polymarket.com", + "label": "Poly", + "priority": 0, + "supported_assets": frozenset({"BTC", "ETH", "SOL", "XRP"}), + "supported_market_types": frozenset({ + MARKET_DAILY, MARKET_HOURLY, MARKET_15MIN, MARKET_5MIN, MARKET_RANGE, + }), + "url_re": re.compile( + r"polymarket\.com/(?:event/|market/)?([a-zA-Z0-9-]+)", re.I, + ), + "slug_re": re.compile(r"^[a-zA-Z0-9-]+$"), + "detect": lambda s: bool(_POLY_DETECT_RE.search(s)), + "resolve_asset": _poly_resolve_asset, + "resolve_market_type": _poly_resolve_market_type, + }, + PLATFORM_KALSHI: { + "domain": "kalshi.com", + "label": "Kalshi", + "priority": 10, + "supported_assets": frozenset({ + "BTC", "ETH", "SOL", "XRP", "DOGE", + "SPY", "NVDA", "TSLA", "AAPL", "GOOGL", "XAU", + }), + "supported_market_types": frozenset({MARKET_DAILY, MARKET_15MIN, MARKET_RANGE}), + "url_re": re.compile( + r"kalshi\.com/(?:markets|events)(?:/[^/]+)*/([a-zA-Z0-9._-]+)", re.I, + ), + "slug_re": re.compile( + r"^(?:kx[a-z0-9]+|btcd?(?:-b)?|ethd?)(?:-[a-zA-Z0-9._-]*)?$", re.I, + ), + "detect": lambda s: _kalshi_parse(s)[1] is not None, + "resolve_asset": lambda slug: _kalshi_parse(slug)[1], + "resolve_market_type": lambda slug: _kalshi_parse(slug)[2], + }, +} + +# Priority-sorted list for ordered dispatch +_PLATFORM_ORDER: list[str] = sorted(_CONFIGS, key=lambda n: _CONFIGS[n]["priority"]) + + +# ═══════════════════════════════════════════════════════════════════════ +# Generic resolution engine +# ═══════════════════════════════════════════════════════════════════════ + +def _normalize_with_configs(raw: str) -> str | None: + """Extract canonical slug from URL or bare input using platform configs.""" + s = raw.strip() + for name in _PLATFORM_ORDER: + cfg = _CONFIGS[name] + m = cfg["url_re"].search(s) + if m: + return m.group(1) + for name in _PLATFORM_ORDER: + cfg = _CONFIGS[name] + if cfg["slug_re"].match(s): + return s + if re.match(r"^[a-zA-Z0-9._-]+$", s): return s return None -def get_market_type(slug: str) -> Literal["daily", "hourly", "15min", "5min", "range"] | None: - """Infer Synth market type from slug. Returns None if not recognizable.""" +def _detect_with_configs(raw: str) -> str | None: + """Detect platform from URL or slug using configs.""" + s = raw.strip().lower() + # Fast path: domain present + for name in _PLATFORM_ORDER: + if _CONFIGS[name]["domain"] in s: + return name + # Heuristic: ask each platform's detect function + for name in _PLATFORM_ORDER: + if _CONFIGS[name]["detect"](s): + return name + return None + + +def _resolve_on_config(slug: str, cfg: _PlatformConfig) -> tuple[str | None, str | None]: + """Resolve (asset, market_type) using a platform config.""" + mtype = cfg["resolve_market_type"](slug) + if not mtype: + return None, None + asset = cfg["resolve_asset"](slug) + return asset, mtype + + +# ═══════════════════════════════════════════════════════════════════════ +# PlatformRegistry — thin introspection wrapper around configs +# ═══════════════════════════════════════════════════════════════════════ + +class PlatformRegistry: + """Read-only registry for platform introspection and structured resolve.""" + + def __init__(self, configs: dict[str, _PlatformConfig]) -> None: + self._configs = configs + self._order = sorted(configs, key=lambda n: configs[n]["priority"]) + + def get(self, name: str) -> _PlatformConfig | None: + return self._configs.get(name) + + @property + def platforms(self) -> list[_PlatformConfig]: + return [self._configs[n] for n in self._order] + + @property + def platform_names(self) -> list[str]: + return list(self._order) + + def _normalize(self, raw: str) -> str | None: + s = raw.strip() + for name in self._order: + cfg = self._configs[name] + m = cfg["url_re"].search(s) + if m: + return m.group(1) + for name in self._order: + cfg = self._configs[name] + if cfg["slug_re"].match(s): + return s + if re.match(r"^[a-zA-Z0-9._-]+$", s): + return s + return None + + def _detect(self, raw: str) -> str | None: + s = raw.strip().lower() + for name in self._order: + if self._configs[name]["domain"] in s: + return name + for name in self._order: + if self._configs[name]["detect"](s): + return name + return None + + def capabilities(self) -> list[dict]: + result = [] + for name in self._order: + cfg = self._configs[name] + result.append({ + "name": name, + "label": cfg.get("label", name.title()), + "domain": cfg["domain"], + "supported_assets": sorted(cfg["supported_assets"]), + "supported_market_types": sorted(cfg["supported_market_types"]), + }) + return result + + def all_supported_assets(self) -> frozenset[str]: + result: set[str] = set() + for cfg in self._configs.values(): + result |= cfg["supported_assets"] + return frozenset(result) + + def resolve( + self, url_or_slug: str, platform_hint: str | None = None, + ) -> ResolveResult: + if not url_or_slug or not isinstance(url_or_slug, str): + return ResolveResult( + ok=False, error_code=ERR_INVALID_INPUT, + error="Missing or empty slug/url", + ) + slug = self._normalize(url_or_slug) + if not slug: + return ResolveResult( + ok=False, error_code=ERR_NORMALIZE_FAILED, + error=f"Could not extract slug from: {url_or_slug!r}", + ) + pname = platform_hint or self._detect(url_or_slug) or self._order[0] if self._order else None + if not pname: + pname = platform_hint + cfg = self._configs.get(pname) if pname else None + if not cfg: + return ResolveResult( + ok=False, slug=slug, platform=pname, + error_code=ERR_UNKNOWN_PLATFORM, + error=f"Unknown platform: {pname!r}", + ) + asset, mtype = _resolve_on_config(slug, cfg) + if not mtype: + return ResolveResult( + ok=False, slug=slug, platform=pname, + error_code=ERR_UNSUPPORTED_MARKET, + error=f"Slug {slug!r} is not a recognised {pname} market type", + ) + return ResolveResult( + ok=True, slug=slug, asset=asset or "BTC", + market_type=mtype, platform=pname, + ) + + +# ── Default registry singleton ─────────────────────────────────────── + +registry = PlatformRegistry(_CONFIGS) + + +# ═══════════════════════════════════════════════════════════════════════ +# Backward-compatible public API +# +# Same function signatures as the original matcher.py — server.py, +# tests, and extension code work without changes. +# ═══════════════════════════════════════════════════════════════════════ + +def detect_platform(url_or_slug: str) -> str | None: + return _detect_with_configs(url_or_slug) if url_or_slug and isinstance(url_or_slug, str) else None + +def asset_from_slug(slug: str) -> str | None: + """Extract asset from a Polymarket slug.""" + return _poly_resolve_asset(slug) if slug else None + +def asset_from_kalshi_ticker(ticker: str) -> str | None: + """Extract asset from a Kalshi ticker (grammar-based).""" + if not ticker: + return None + _, asset, _ = _kalshi_parse(ticker) + return asset + +def normalize_slug(url_or_slug: str) -> str | None: + if not url_or_slug or not isinstance(url_or_slug, str): + return None + return _normalize_with_configs(url_or_slug) + +def get_market_type(slug: str) -> MarketType | None: + """Infer market type — Polymarket rules checked first for disambiguation.""" if not slug: return None - slug_lower = slug.lower() - if _5MIN_PATTERN.search(slug_lower): - return MARKET_5MIN - if _15MIN_PATTERN.search(slug_lower): - return MARKET_15MIN - if "up-or-down" in slug_lower and _HOURLY_TIME_PATTERN.search(slug_lower): - return MARKET_HOURLY - if "up-or-down" in slug_lower and "on-" in slug_lower: - return MARKET_DAILY - if "price-on" in slug_lower: - return MARKET_RANGE + for name in _PLATFORM_ORDER: + cfg = _CONFIGS[name] + mtype = cfg["resolve_market_type"](slug) + if mtype: + return mtype return None +def get_kalshi_market_type(ticker: str) -> MarketType | None: + """Convenience: Kalshi-specific market type (grammar-based).""" + if not ticker: + return None + _, _, mtype = _kalshi_parse(ticker) + return mtype + +def resolve(url_or_slug: str, platform_hint: str | None = None) -> dict | None: + """Legacy dict-returning resolver (backward compat). + + New code should use ``registry.resolve()`` for structured diagnostics. + """ + r = registry.resolve(url_or_slug, platform_hint) + if not r.ok: + return None + return {"slug": r.slug, "asset": r.asset, "market_type": r.market_type, "platform": r.platform} def is_supported(slug: str) -> bool: - """True if slug maps to a Synth-supported market (daily, hourly, or range).""" return get_market_type(slug) is not None + + +# ═══════════════════════════════════════════════════════════════════════ +# Platform class — kept for test compatibility +# ═══════════════════════════════════════════════════════════════════════ + +class Platform: + """Minimal base for test compatibility (custom platform registration).""" + name: str = "" + domain: str = "" + label: str = "" + priority: int = 0 + supported_assets: frozenset[str] = frozenset() + supported_market_types: frozenset[str] = frozenset() diff --git a/tools/synth-overlay/server.py b/tools/synth-overlay/server.py index cca769f..fdf72c5 100644 --- a/tools/synth-overlay/server.py +++ b/tools/synth-overlay/server.py @@ -1,6 +1,6 @@ """ Local API server for the Synth Overlay extension. -Serves edge data from SynthClient; extension calls this from Polymarket pages. +Serves edge data from SynthClient; extension calls this from Polymarket and Kalshi pages. """ import os @@ -17,7 +17,21 @@ from analyzer import EdgeAnalyzer from edge import edge_from_range_bracket, signal_from_edge -from matcher import asset_from_slug, get_market_type, normalize_slug +from matcher import ( + asset_from_kalshi_ticker, + asset_from_slug, + detect_platform, + get_market_type, + normalize_slug, + resolve, + registry, + ERR_INVALID_INPUT, + ERR_NORMALIZE_FAILED, + ERR_UNKNOWN_PLATFORM, + ERR_UNSUPPORTED_MARKET, + PLATFORM_KALSHI, + PLATFORM_POLYMARKET, +) app = Flask(__name__) _client: SynthClient | None = None @@ -94,13 +108,15 @@ def _fetch_updown_pair(client: SynthClient, asset: str, market_type: str) -> tup def _handle_updown_market( - client: SynthClient, slug: str, asset: str, market_type: str, live_prob_up: float | None = None + client: SynthClient, slug: str, asset: str, market_type: str, + live_prob_up: float | None = None, platform: str = "polymarket", ): """Handle up/down markets for any supported asset and horizon. - + Args: - live_prob_up: Real-time Polymarket price scraped from DOM. If provided, + live_prob_up: Real-time market price scraped from DOM. If provided, overrides the API's polymarket_probability_up for edge calculation. + platform: Source platform ("polymarket" or "kalshi"). """ primary_data, reference_data = _fetch_updown_pair(client, asset, market_type) @@ -132,6 +148,7 @@ def _handle_updown_market( "asset": asset, "horizon": primary_horizon, "market_type": market_type, + "platform": platform, "edge_pct": result.primary.edge_pct, "signal": result.primary.signal, "strength": result.strength, @@ -159,6 +176,7 @@ def _handle_updown_market( "asset": asset, "horizon": primary_horizon, "market_type": market_type, + "platform": platform, "edge_pct": result.primary.edge_pct, "signal": result.primary.signal, "strength": result.strength, @@ -182,19 +200,38 @@ def _handle_updown_market( return jsonify(resp) +@app.route("/api/platforms", methods=["GET", "OPTIONS"]) +def platforms(): + """Introspection: list registered platforms, supported assets, market types.""" + if request.method == "OPTIONS": + return "", 204 + return jsonify({ + "platforms": registry.capabilities(), + "all_supported_assets": sorted(registry.all_supported_assets()), + }) + + @app.route("/api/edge", methods=["GET", "OPTIONS"]) def edge(): if request.method == "OPTIONS": return "", 204 raw = request.args.get("slug") or request.args.get("url") or "" - slug = normalize_slug(raw) - if not slug: - return jsonify({"error": "Missing or invalid slug/url"}), 400 - market_type = get_market_type(slug) - if not market_type: - return jsonify({"error": "Unsupported market", "slug": slug}), 404 - asset = asset_from_slug(slug) or "BTC" - # Live Polymarket price scraped from DOM (real-time, avoids API latency) + platform_hint = request.args.get("platform") + # Structured resolve: slug + asset + market_type + platform + diagnostics + result = registry.resolve(raw, platform_hint) + if not result.ok: + code = result.error_code + if code == ERR_INVALID_INPUT or code == ERR_NORMALIZE_FAILED: + return jsonify({"error": result.error, "error_code": code}), 400 + if code == ERR_UNKNOWN_PLATFORM: + return jsonify({"error": result.error, "error_code": code, "slug": result.slug}), 400 + # ERR_UNSUPPORTED_MARKET or anything else → 404 + return jsonify({"error": result.error or "Unsupported market", "error_code": code, "slug": result.slug}), 404 + slug = result.slug + asset = result.asset + market_type = result.market_type + platform = result.platform + # Live market price scraped from DOM (real-time, avoids API latency) live_prob_up = request.args.get("live_prob_up") if live_prob_up: try: @@ -204,7 +241,7 @@ def edge(): try: client = get_client() if market_type in ("daily", "hourly", "15min", "5min"): - return _handle_updown_market(client, slug, asset, market_type, live_prob_up) + return _handle_updown_market(client, slug, asset, market_type, live_prob_up, platform) # range data = client.get_polymarket_range() if not isinstance(data, list): @@ -246,6 +283,7 @@ def edge(): return jsonify({ "slug": selected.get("slug"), "horizon": "24h", + "platform": platform, "bracket_title": selected.get("title"), "edge_pct": result.primary.edge_pct, "signal": result.primary.signal, @@ -268,7 +306,8 @@ def edge(): def main(): import warnings warnings.filterwarnings("ignore", message="No SYNTH_API_KEY") - app.run(host="127.0.0.1", port=8765, debug=False, use_reloader=False) + host = os.environ.get("SERVER_HOST", "127.0.0.1") + app.run(host=host, port=8765, debug=False, use_reloader=False) if __name__ == "__main__": diff --git a/tools/synth-overlay/tests/test_matcher.py b/tools/synth-overlay/tests/test_matcher.py index a8f74dc..d3491f2 100644 --- a/tools/synth-overlay/tests/test_matcher.py +++ b/tools/synth-overlay/tests/test_matcher.py @@ -1,13 +1,37 @@ -"""Tests for market slug / URL matcher.""" +"""Tests for market slug / URL matcher — Polymarket + Kalshi platform registry.""" +import re import sys import os sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) -from matcher import asset_from_slug, normalize_slug, get_market_type, is_supported +from matcher import ( + asset_from_kalshi_ticker, + asset_from_slug, + detect_platform, + get_kalshi_market_type, + get_market_type, + is_supported, + normalize_slug, + resolve, + registry, + ERR_INVALID_INPUT, + ERR_NORMALIZE_FAILED, + ERR_UNKNOWN_PLATFORM, + ERR_UNSUPPORTED_MARKET, + PLATFORM_KALSHI, + PLATFORM_POLYMARKET, + Platform, + PlatformRegistry, + ResolveResult, +) +# ═══════════════════════════════════════════════════════════════════════ +# Polymarket — slug normalization +# ═══════════════════════════════════════════════════════════════════════ + def test_normalize_slug_from_url(): assert normalize_slug("https://polymarket.com/event/bitcoin-up-or-down-on-february-26") == "bitcoin-up-or-down-on-february-26" assert normalize_slug("https://polymarket.com/market/bitcoin-price-on-february-26") == "bitcoin-price-on-february-26" @@ -22,6 +46,10 @@ def test_normalize_slug_invalid(): assert normalize_slug(None) is None +# ═══════════════════════════════════════════════════════════════════════ +# Polymarket — market type +# ═══════════════════════════════════════════════════════════════════════ + def test_get_market_type_daily(): assert get_market_type("bitcoin-up-or-down-on-february-26") == "daily" assert get_market_type("btc-up-or-down-on-march-1") == "daily" @@ -63,6 +91,10 @@ def test_is_supported(): assert is_supported("unknown-market") is False +# ═══════════════════════════════════════════════════════════════════════ +# Polymarket — asset extraction +# ═══════════════════════════════════════════════════════════════════════ + def test_asset_from_slug(): assert asset_from_slug("bitcoin-up-or-down-on-february-26") == "BTC" assert asset_from_slug("ethereum-up-or-down-on-february-28") == "ETH" @@ -80,3 +112,493 @@ def test_asset_from_slug_unknown(): assert asset_from_slug("random-slug") is None assert asset_from_slug("") is None assert asset_from_slug(None) is None + + +# ═══════════════════════════════════════════════════════════════════════ +# Platform detection +# ═══════════════════════════════════════════════════════════════════════ + +def test_detect_platform_polymarket(): + assert detect_platform("https://polymarket.com/event/bitcoin-up-or-down-on-february-26") == PLATFORM_POLYMARKET + assert detect_platform("bitcoin-up-or-down-on-february-26") == PLATFORM_POLYMARKET + + +def test_detect_platform_kalshi(): + assert detect_platform("https://kalshi.com/markets/kxbtcd") == PLATFORM_KALSHI + assert detect_platform("https://kalshi.com/events/KXBTCD-26MAR1317") == PLATFORM_KALSHI + assert detect_platform("https://www.kalshi.com/markets/kxbtcd") == PLATFORM_KALSHI + assert detect_platform("kxbtcd-26mar1317") == PLATFORM_KALSHI + assert detect_platform("KXBTCD-26MAR1317-T70499.99") == PLATFORM_KALSHI + + +def test_detect_platform_none(): + assert detect_platform("") is None + assert detect_platform(None) is None + + +def test_detect_platform_polymarket_not_kalshi(): + """Polymarket slugs with btc/eth prefix should NOT be detected as Kalshi.""" + assert detect_platform("btc-updown-5m-1772205000") == PLATFORM_POLYMARKET + assert detect_platform("btc-up-or-down-on-march-1") == PLATFORM_POLYMARKET + assert detect_platform("eth-updown-15m-1772204400") == PLATFORM_POLYMARKET + assert detect_platform("bitcoin-up-or-down-on-february-26") == PLATFORM_POLYMARKET + + +# ═══════════════════════════════════════════════════════════════════════ +# Kalshi — slug normalization +# ═══════════════════════════════════════════════════════════════════════ + +def test_normalize_slug_kalshi_markets(): + assert normalize_slug("https://kalshi.com/markets/kxbtcd") == "kxbtcd" + assert normalize_slug("https://kalshi.com/markets/KXBTCD-26MAR1317-T70499.99") == "KXBTCD-26MAR1317-T70499.99" + assert normalize_slug("https://www.kalshi.com/markets/kxbtcd") == "kxbtcd" + + +def test_normalize_slug_kalshi_events(): + assert normalize_slug("https://kalshi.com/events/kxbtcd/KXBTCD-26MAR1317") == "KXBTCD-26MAR1317" + assert normalize_slug("https://kalshi.com/events/KXBTCD-26MAR1317") == "KXBTCD-26MAR1317" + + +def test_normalize_slug_kalshi_multi_segment(): + assert normalize_slug("https://kalshi.com/markets/kxsol15m/solana-15-minutes/kxsol15m-26mar121945") == "kxsol15m-26mar121945" + assert normalize_slug("https://kalshi.com/markets/kxbtcd/bitcoin-daily/KXBTCD-26MAR1317") == "KXBTCD-26MAR1317" + + +def test_normalize_slug_kalshi_ticker_passthrough(): + assert normalize_slug("kxbtcd") == "kxbtcd" + assert normalize_slug("KXBTCD-26MAR1317") == "KXBTCD-26MAR1317" + assert normalize_slug("KXBTCD-26MAR1317-T70499.99") == "KXBTCD-26MAR1317-T70499.99" + + +# ═══════════════════════════════════════════════════════════════════════ +# Kalshi — asset extraction +# ═══════════════════════════════════════════════════════════════════════ + +def test_asset_from_kalshi_ticker(): + assert asset_from_kalshi_ticker("kxbtcd") == "BTC" + assert asset_from_kalshi_ticker("kxethd") == "ETH" + assert asset_from_kalshi_ticker("kxsold") == "SOL" + assert asset_from_kalshi_ticker("KXBTCD") == "BTC" + + +def test_asset_from_kalshi_event_ticker(): + assert asset_from_kalshi_ticker("KXBTCD-26MAR1317") == "BTC" + assert asset_from_kalshi_ticker("KXETHD-26MAR1317") == "ETH" + + +def test_asset_from_kalshi_contract_ticker(): + assert asset_from_kalshi_ticker("KXBTCD-26MAR1317-T70499.99") == "BTC" + assert asset_from_kalshi_ticker("KXBTC-26MAR1317-B76750") == "BTC" + + +def test_asset_from_kalshi_15min(): + assert asset_from_kalshi_ticker("KXBTC15M-26MAR121930-30") == "BTC" + assert asset_from_kalshi_ticker("KXETH15M-26MAR121930-30") == "ETH" + + +def test_asset_from_kalshi_legacy(): + assert asset_from_kalshi_ticker("btcd") == "BTC" + assert asset_from_kalshi_ticker("ETH") == "ETH" + + +def test_asset_from_kalshi_other(): + assert asset_from_kalshi_ticker("kxspx-26mar12") == "SPY" + assert asset_from_kalshi_ticker("kxnvda-26mar12") == "NVDA" + assert asset_from_kalshi_ticker("kxxau-26mar12") == "XAU" + + +def test_asset_from_kalshi_unknown(): + assert asset_from_kalshi_ticker("unknown-ticker") is None + assert asset_from_kalshi_ticker("") is None + assert asset_from_kalshi_ticker(None) is None + + +# ═══════════════════════════════════════════════════════════════════════ +# Kalshi — market type +# ═══════════════════════════════════════════════════════════════════════ + +def test_get_kalshi_market_type_daily(): + assert get_kalshi_market_type("kxbtcd") == "daily" + assert get_kalshi_market_type("KXBTCD-26MAR1317") == "daily" + assert get_kalshi_market_type("KXBTCD-26MAR1317-T70499.99") == "daily" + assert get_kalshi_market_type("kxethd-26mar1317") == "daily" + assert get_kalshi_market_type("btcd") == "daily" + + +def test_get_kalshi_market_type_range(): + assert get_kalshi_market_type("kxbtc") == "range" + assert get_kalshi_market_type("KXBTC-26MAR1317") == "range" + assert get_kalshi_market_type("KXBTC-26MAR1317-B76750") == "range" + assert get_kalshi_market_type("kxeth") == "range" + + +def test_get_kalshi_market_type_15min(): + assert get_kalshi_market_type("kxbtc15m") == "15min" + assert get_kalshi_market_type("KXBTC15M-26MAR121930-30") == "15min" + assert get_kalshi_market_type("kxeth15m") == "15min" + assert get_kalshi_market_type("kxsol15m") == "15min" + + +def test_get_market_type_kalshi_via_unified(): + """get_market_type should route Kalshi tickers correctly.""" + assert get_market_type("kxbtcd") == "daily" + assert get_market_type("KXBTCD-26MAR1317-T70499.99") == "daily" + assert get_market_type("kxbtc") == "range" + assert get_market_type("KXBTC-26MAR1317-B76750") == "range" + assert get_market_type("kxbtc15m") == "15min" + assert get_market_type("kxethd") == "daily" + + +def test_is_supported_kalshi(): + assert is_supported("kxbtcd") is True + assert is_supported("kxethd") is True + assert is_supported("kxbtc") is True + assert is_supported("kxbtc15m") is True + assert is_supported("KXBTCD-26MAR1317-T70499.99") is True + + +# ═══════════════════════════════════════════════════════════════════════ +# Short ticker disambiguation (Polymarket vs Kalshi) +# ═══════════════════════════════════════════════════════════════════════ + +def test_short_ticker_does_not_collide_with_polymarket(): + """Polymarket slugs with btc/eth prefix must not be routed to Kalshi.""" + assert get_market_type("btc-updown-5m-1772205000") == "5min" + assert get_market_type("btc-updown-15m-1772204400") == "15min" + assert get_market_type("btc-up-or-down-on-march-1") == "daily" + assert get_market_type("eth-updown-15m-1772204400") == "15min" + assert get_market_type("eth-updown-5m-1772205000") == "5min" + + +def test_legacy_kalshi_tickers_still_work(): + assert get_kalshi_market_type("btc") == "range" + assert get_kalshi_market_type("btcd") == "daily" + assert get_kalshi_market_type("eth") == "range" + assert get_kalshi_market_type("ethd") == "daily" + + +# ═══════════════════════════════════════════════════════════════════════ +# resolve() — one-shot resolver +# ═══════════════════════════════════════════════════════════════════════ + +def test_resolve_polymarket(): + r = resolve("bitcoin-up-or-down-on-february-26") + assert r is not None + assert r["platform"] == "polymarket" + assert r["asset"] == "BTC" + assert r["market_type"] == "daily" + assert r["slug"] == "bitcoin-up-or-down-on-february-26" + + +def test_resolve_polymarket_url(): + r = resolve("https://polymarket.com/event/bitcoin-up-or-down-on-february-26") + assert r is not None + assert r["platform"] == "polymarket" + assert r["asset"] == "BTC" + + +def test_resolve_kalshi_ticker(): + r = resolve("KXBTCD-26MAR1317", "kalshi") + assert r is not None + assert r["platform"] == "kalshi" + assert r["asset"] == "BTC" + assert r["market_type"] == "daily" + + +def test_resolve_kalshi_url(): + r = resolve("https://kalshi.com/markets/KXBTCD-26MAR1317") + assert r is not None + assert r["platform"] == "kalshi" + assert r["asset"] == "BTC" + + +def test_resolve_kalshi_15min(): + r = resolve("kxbtc15m", "kalshi") + assert r is not None + assert r["market_type"] == "15min" + assert r["asset"] == "BTC" + + +def test_resolve_kalshi_range(): + r = resolve("kxbtc", "kalshi") + assert r is not None + assert r["market_type"] == "range" + + +def test_resolve_unsupported(): + assert resolve("random-unknown-slug") is None + assert resolve("") is None + assert resolve(None) is None + + +def test_resolve_kalshi_auto_detect(): + """Platform auto-detected from kx prefix.""" + r = resolve("kxethd") + assert r is not None + assert r["platform"] == "kalshi" + assert r["asset"] == "ETH" + + +# ═══════════════════════════════════════════════════════════════════════ +# Extended Kalshi ticker coverage (XRP, DOGE, kxspy, btcd-b) +# ═══════════════════════════════════════════════════════════════════════ + +def test_asset_from_kalshi_xrp(): + assert asset_from_kalshi_ticker("kxxrpd") == "XRP" + assert asset_from_kalshi_ticker("kxxrp") == "XRP" + assert asset_from_kalshi_ticker("kxxrp15m") == "XRP" + assert asset_from_kalshi_ticker("KXXRPD-26MAR1317") == "XRP" + + +def test_asset_from_kalshi_doge(): + assert asset_from_kalshi_ticker("kxdoged") == "DOGE" + assert asset_from_kalshi_ticker("kxdoge") == "DOGE" + assert asset_from_kalshi_ticker("kxdoge15m") == "DOGE" + + +def test_asset_from_kalshi_spy_variant(): + assert asset_from_kalshi_ticker("kxspy") == "SPY" + assert asset_from_kalshi_ticker("kxspx") == "SPY" + + +def test_asset_from_kalshi_btcd_b(): + assert asset_from_kalshi_ticker("btcd-b") == "BTC" + assert asset_from_kalshi_ticker("BTCD-B") == "BTC" + + +def test_get_kalshi_market_type_btcd_b(): + assert get_kalshi_market_type("btcd-b") == "daily" + + +def test_get_kalshi_market_type_xrp(): + assert get_kalshi_market_type("kxxrpd") == "daily" + assert get_kalshi_market_type("kxxrp") == "range" + assert get_kalshi_market_type("kxxrp15m") == "15min" + + +def test_get_kalshi_market_type_doge(): + assert get_kalshi_market_type("kxdoged") == "daily" + assert get_kalshi_market_type("kxdoge") == "range" + assert get_kalshi_market_type("kxdoge15m") == "15min" + + +def test_detect_platform_kalshi_btcd_b(): + assert detect_platform("BTCD-B") == PLATFORM_KALSHI + + +def test_is_supported_extended_kalshi(): + assert is_supported("kxxrpd") is True + assert is_supported("kxdoged") is True + assert is_supported("kxspy") is True + assert is_supported("btcd-b") is True + assert is_supported("kxunknown-26feb25") is False + + +# ═══════════════════════════════════════════════════════════════════════ +# Contract ticker with -T (strike) and -B (bracket) suffixes +# ═══════════════════════════════════════════════════════════════════════ + +def test_get_market_type_contract_with_threshold(): + assert get_market_type("KXBTCD-26MAR1317-T71500") == "daily" + assert get_market_type("KXETHD-26MAR1317-T3500.5") == "daily" + + +def test_asset_from_kalshi_contract_with_threshold(): + assert asset_from_kalshi_ticker("KXBTCD-26MAR1317-T71500") == "BTC" + assert asset_from_kalshi_ticker("KXETHD-26MAR1317-T3500.5") == "ETH" + assert asset_from_kalshi_ticker("KXSOLD-26MAR1317-T150") == "SOL" + + +def test_detect_platform_kalshi_contract_ticker(): + assert detect_platform("KXBTCD-26MAR1317-T71500") == PLATFORM_KALSHI + assert detect_platform("KXETHD-26MAR1317-T3500.5") == PLATFORM_KALSHI + + +def test_legacy_kalshi_with_date_suffix(): + assert get_kalshi_market_type("btcd-26MAR1317") == "daily" + assert get_kalshi_market_type("ethd-26MAR1317") == "daily" + + +# ═══════════════════════════════════════════════════════════════════════ +# Multi-segment Kalshi URL normalization — additional cases +# ═══════════════════════════════════════════════════════════════════════ + +def test_normalize_slug_kalshi_multi_segment_eth(): + assert normalize_slug("https://kalshi.com/markets/kxeth15m/ethereum-15-minutes/kxeth15m-26mar121945") == "kxeth15m-26mar121945" + + +def test_normalize_slug_kalshi_contract_with_threshold(): + assert normalize_slug("https://kalshi.com/markets/kxbtcd/bitcoin-daily/KXBTCD-26MAR1317-T71500") == "KXBTCD-26MAR1317-T71500" + assert normalize_slug("KXBTCD-26MAR1317-T71500") == "KXBTCD-26MAR1317-T71500" + + +# ═══════════════════════════════════════════════════════════════════════ +# ResolveResult — structured diagnostics +# ═══════════════════════════════════════════════════════════════════════ + +def test_resolve_result_success(): + r = registry.resolve("bitcoin-up-or-down-on-february-26") + assert r.ok is True + assert r.slug == "bitcoin-up-or-down-on-february-26" + assert r.asset == "BTC" + assert r.market_type == "daily" + assert r.platform == "polymarket" + assert r.error_code is None + assert r.error is None + + +def test_resolve_result_invalid_input(): + r = registry.resolve("") + assert r.ok is False + assert r.error_code == ERR_INVALID_INPUT + assert "Missing" in r.error + + r2 = registry.resolve(None) + assert r2.ok is False + assert r2.error_code == ERR_INVALID_INPUT + + +def test_resolve_result_unsupported_market(): + r = registry.resolve("random-unknown-slug") + assert r.ok is False + assert r.error_code == ERR_UNSUPPORTED_MARKET + assert r.slug == "random-unknown-slug" + assert r.platform is not None + + +def test_resolve_result_unknown_platform(): + r = registry.resolve("some-slug", platform_hint="robinhood") + assert r.ok is False + assert r.error_code == ERR_UNKNOWN_PLATFORM + assert "robinhood" in r.error + + +def test_resolve_result_to_dict_omits_none(): + r = registry.resolve("kxbtcd", "kalshi") + d = r.to_dict() + assert d["ok"] is True + assert "error_code" not in d + assert "error" not in d + assert d["platform"] == "kalshi" + assert d["asset"] == "BTC" + + +def test_resolve_result_error_to_dict(): + r = registry.resolve("") + d = r.to_dict() + assert d["ok"] is False + assert "error_code" in d + assert "error" in d + assert "slug" not in d # None values omitted + + +def test_resolve_result_kalshi_structured(): + r = registry.resolve("KXBTCD-26MAR1317", "kalshi") + assert r.ok is True + assert r.platform == "kalshi" + assert r.asset == "BTC" + assert r.market_type == "daily" + + +# ═══════════════════════════════════════════════════════════════════════ +# PlatformRegistry — introspection & capabilities +# ═══════════════════════════════════════════════════════════════════════ + +def test_registry_has_two_platforms(): + assert len(registry.platform_names) == 2 + assert "polymarket" in registry.platform_names + assert "kalshi" in registry.platform_names + + +def test_registry_priority_order(): + """Polymarket (priority=0) comes before Kalshi (priority=10).""" + assert registry.platform_names[0] == "polymarket" + assert registry.platform_names[1] == "kalshi" + + +def test_registry_get_platform(): + poly = registry.get("polymarket") + assert poly is not None + assert poly["domain"] == "polymarket.com" + + kalshi = registry.get("kalshi") + assert kalshi is not None + assert kalshi["domain"] == "kalshi.com" + + assert registry.get("nonexistent") is None + + +def test_registry_capabilities(): + caps = registry.capabilities() + assert len(caps) == 2 + poly_cap = caps[0] + assert poly_cap["name"] == "polymarket" + assert "BTC" in poly_cap["supported_assets"] + assert "daily" in poly_cap["supported_market_types"] + assert poly_cap["label"] == "Poly" + + kalshi_cap = caps[1] + assert kalshi_cap["name"] == "kalshi" + assert "NVDA" in kalshi_cap["supported_assets"] + assert "15min" in kalshi_cap["supported_market_types"] + assert kalshi_cap["label"] == "Kalshi" + + +def test_registry_all_supported_assets(): + assets = registry.all_supported_assets() + assert "BTC" in assets + assert "ETH" in assets + assert "NVDA" in assets # Kalshi-only + assert "XRP" in assets + + +def test_platform_capabilities_includes_domain(): + caps = registry.capabilities() + kalshi_cap = [c for c in caps if c["name"] == "kalshi"][0] + assert kalshi_cap["domain"] == "kalshi.com" + assert isinstance(kalshi_cap["supported_assets"], list) + assert isinstance(kalshi_cap["supported_market_types"], list) + + +# ═══════════════════════════════════════════════════════════════════════ +# Custom platform registration (extensibility proof) +# ═══════════════════════════════════════════════════════════════════════ + +_MOCK_CONFIG = { + "mockex": { + "domain": "mockex.com", + "label": "Mock", + "priority": 20, + "supported_assets": frozenset({"BTC"}), + "supported_market_types": frozenset({"daily"}), + "url_re": re.compile(r"mockex\.com/markets/([a-zA-Z0-9._-]+)", re.I), + "slug_re": re.compile(r"^MX-", re.I), + "detect": lambda s: "mockex.com" in s or s.startswith("mx-"), + "resolve_asset": lambda slug: "BTC" if slug.lower().startswith("mx-") else None, + "resolve_market_type": lambda slug: "daily" if slug.lower().startswith("mx-") else None, + }, +} + + +def test_custom_platform_registration(): + """New platforms can be registered as config dicts without touching existing code.""" + custom_reg = PlatformRegistry(_MOCK_CONFIG) + + r = custom_reg.resolve("https://mockex.com/markets/MX-BTC-DAILY") + assert r.ok is True + assert r.platform == "mockex" + assert r.asset == "BTC" + assert r.market_type == "daily" + + +def test_custom_platform_does_not_affect_default_registry(): + """Config-based registry instances are isolated.""" + assert registry.get("mockex") is None + + +def test_custom_platform_capabilities(): + custom_reg = PlatformRegistry(_MOCK_CONFIG) + caps = custom_reg.capabilities() + assert len(caps) == 1 + assert caps[0]["name"] == "mockex" + assert caps[0]["supported_assets"] == ["BTC"] diff --git a/tools/synth-overlay/tests/test_server.py b/tools/synth-overlay/tests/test_server.py index 710926c..4592fb1 100644 --- a/tools/synth-overlay/tests/test_server.py +++ b/tools/synth-overlay/tests/test_server.py @@ -199,3 +199,207 @@ def test_edge_live_price_invalid_ignored(client): assert resp.status_code == 200 data = resp.get_json() assert data.get("live_price_used") is False + + +# ---- Kalshi support ---- + +def test_edge_kalshi_btc_daily(client): + """Kalshi BTC daily market returns edge data with kalshi platform.""" + resp = client.get("/api/edge?slug=KXBTCD-26MAR1317&platform=kalshi") + assert resp.status_code == 200 + data = resp.get_json() + assert data["asset"] == "BTC" + assert data["market_type"] == "daily" + assert data["platform"] == "kalshi" + assert "edge_pct" in data + assert "confidence_score" in data + assert "explanation" in data + + +def test_edge_kalshi_eth_daily(client): + """Kalshi ETH daily market is supported.""" + resp = client.get("/api/edge?slug=KXETHD-26MAR1317&platform=kalshi") + assert resp.status_code == 200 + data = resp.get_json() + assert data["asset"] == "ETH" + assert data["market_type"] == "daily" + assert data["platform"] == "kalshi" + + +def test_edge_kalshi_sol_daily(client): + """Kalshi SOL daily market is supported.""" + resp = client.get("/api/edge?slug=KXSOLD-26MAR1317&platform=kalshi") + assert resp.status_code == 200 + data = resp.get_json() + assert data["asset"] == "SOL" + assert data["market_type"] == "daily" + + +def test_edge_kalshi_with_live_price(client): + """Kalshi markets support live price override.""" + resp = client.get("/api/edge?slug=KXBTCD-26MAR1317&platform=kalshi&live_prob_up=0.60") + assert resp.status_code == 200 + data = resp.get_json() + assert data.get("live_price_used") is True + assert data["polymarket_probability_up"] == 0.60 + + +def test_edge_kalshi_auto_detect_platform(client): + """Platform auto-detected from Kalshi URL without explicit platform param.""" + resp = client.get("/api/edge?url=https://kalshi.com/markets/KXBTCD-26MAR1317") + assert resp.status_code == 200 + data = resp.get_json() + assert data["platform"] == "kalshi" + assert data["asset"] == "BTC" + + +def test_edge_polymarket_includes_platform(client): + """Polymarket responses include platform field.""" + resp = client.get("/api/edge?slug=bitcoin-up-or-down-on-february-26") + assert resp.status_code == 200 + data = resp.get_json() + assert data["platform"] == "polymarket" + + +def test_edge_kalshi_unsupported_ticker(client): + """Unknown Kalshi ticker returns 404.""" + resp = client.get("/api/edge?slug=kxunknown-26feb25&platform=kalshi") + assert resp.status_code == 404 + + +def test_edge_kalshi_15min(client): + """Kalshi 15min market (KXBTC15M series) returns edge data.""" + resp = client.get("/api/edge?slug=kxbtc15m&platform=kalshi") + assert resp.status_code == 200 + data = resp.get_json() + assert data["asset"] == "BTC" + assert data["market_type"] == "15min" + assert data["platform"] == "kalshi" + assert data["horizon"] == "15min" + assert "edge_pct" in data + + +def test_edge_kalshi_range(client): + """Kalshi range market (KXBTC series) returns range data.""" + resp = client.get("/api/edge?slug=kxbtc&platform=kalshi") + # Range markets need range bracket data which may or may not be mocked for "kxbtc". + assert resp.status_code in (200, 404, 500) + if resp.status_code == 200: + data = resp.get_json() + assert data["platform"] == "kalshi" + + +def test_edge_kalshi_www_url(client): + """Platform auto-detected from www.kalshi.com URL.""" + resp = client.get("/api/edge?url=https://www.kalshi.com/markets/KXBTCD-26MAR1317") + assert resp.status_code == 200 + data = resp.get_json() + assert data["platform"] == "kalshi" + assert data["asset"] == "BTC" + + +def test_edge_kalshi_contract_with_threshold(client): + """Kalshi contract ticker with -T (strike) suffix resolves correctly.""" + resp = client.get("/api/edge?slug=KXBTCD-26MAR1317-T71500&platform=kalshi") + assert resp.status_code == 200 + data = resp.get_json() + assert data["asset"] == "BTC" + assert data["market_type"] == "daily" + assert data["platform"] == "kalshi" + + +def test_edge_kalshi_multi_segment_url(client): + """Multi-segment Kalshi URL extracts the last segment as ticker.""" + resp = client.get("/api/edge?url=https://kalshi.com/markets/kxbtcd/bitcoin-daily/KXBTCD-26MAR1317") + assert resp.status_code == 200 + data = resp.get_json() + assert data["platform"] == "kalshi" + assert data["asset"] == "BTC" + assert data["market_type"] == "daily" + + +def test_edge_kalshi_live_price_recalculates_edge(client): + """Live price override on Kalshi recalculates edge without refetch.""" + resp = client.get("/api/edge?slug=KXBTCD-26MAR1317&platform=kalshi&live_prob_up=0.80") + assert resp.status_code == 200 + data = resp.get_json() + assert data.get("live_price_used") is True + assert data["polymarket_probability_up"] == 0.80 + assert data["platform"] == "kalshi" + + +# ---- /api/platforms introspection ---- + +def test_platforms_endpoint(client): + """GET /api/platforms returns registered platform capabilities.""" + resp = client.get("/api/platforms") + assert resp.status_code == 200 + data = resp.get_json() + assert "platforms" in data + assert "all_supported_assets" in data + assert len(data["platforms"]) == 2 + names = [p["name"] for p in data["platforms"]] + assert "polymarket" in names + assert "kalshi" in names + + +def test_platforms_includes_assets_and_types(client): + """Each platform entry has supported_assets and supported_market_types.""" + resp = client.get("/api/platforms") + data = resp.get_json() + for plat in data["platforms"]: + assert "supported_assets" in plat + assert "supported_market_types" in plat + assert "domain" in plat + assert "label" in plat + assert len(plat["supported_assets"]) > 0 + assert len(plat["supported_market_types"]) > 0 + + +def test_platforms_all_supported_assets_union(client): + """all_supported_assets is the union across all platforms.""" + resp = client.get("/api/platforms") + data = resp.get_json() + assets = data["all_supported_assets"] + assert "BTC" in assets + assert "NVDA" in assets # Kalshi-only + assert "ETH" in assets + + +def test_platforms_kalshi_has_extended_assets(client): + """Kalshi platform reports all its supported assets.""" + resp = client.get("/api/platforms") + data = resp.get_json() + kalshi = [p for p in data["platforms"] if p["name"] == "kalshi"][0] + assert "DOGE" in kalshi["supported_assets"] + assert "XRP" in kalshi["supported_assets"] + assert "SPY" in kalshi["supported_assets"] + + +# ---- Structured error codes ---- + +def test_edge_missing_slug_has_error_code(client): + """Empty slug returns 400 with error_code.""" + resp = client.get("/api/edge") + assert resp.status_code == 400 + data = resp.get_json() + assert "error_code" in data + assert data["error_code"] == "invalid_input" + + +def test_edge_unsupported_slug_has_error_code(client): + """Unknown slug returns 404 with error_code.""" + resp = client.get("/api/edge?slug=unsupported-random-market") + assert resp.status_code == 404 + data = resp.get_json() + assert "error_code" in data + assert data["error_code"] == "unsupported_market" + + +def test_edge_unknown_platform_returns_400(client): + """Explicit unknown platform hint returns 400.""" + resp = client.get("/api/edge?slug=some-slug&platform=robinhood") + assert resp.status_code == 400 + data = resp.get_json() + assert data["error_code"] == "unknown_platform" + assert "robinhood" in data["error"] From 3d6e155955bf86e49ca8fdb7dace5387ba7ccbc3 Mon Sep 17 00:00:00 2001 From: mkdev11 Date: Fri, 13 Mar 2026 09:21:59 +0100 Subject: [PATCH 2/2] fix: route Kalshi range contracts through up/down handler + revert test IP --- tools/synth-overlay/server.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tools/synth-overlay/server.py b/tools/synth-overlay/server.py index fdf72c5..6f643bc 100644 --- a/tools/synth-overlay/server.py +++ b/tools/synth-overlay/server.py @@ -242,7 +242,10 @@ def edge(): client = get_client() if market_type in ("daily", "hourly", "15min", "5min"): return _handle_updown_market(client, slug, asset, market_type, live_prob_up, platform) - # range + # Kalshi "range" contracts are binary Yes/No — route through up/down handler + if market_type == "range" and platform == PLATFORM_KALSHI: + return _handle_updown_market(client, slug, asset, "daily", live_prob_up, platform) + # Polymarket range (multi-bracket) data = client.get_polymarket_range() if not isinstance(data, list): return jsonify({"error": "Invalid range data"}), 500