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 @@