From 8d2f501dcb5c3219d2f5d4492fa35fd8e82a5822 Mon Sep 17 00:00:00 2001 From: bitloi Date: Tue, 3 Mar 2026 06:03:18 +0100 Subject: [PATCH 1/8] feat(synth-overlay): expand to 5min/15min markets and ETH/SOL assets - Add get_polymarket_5min() and get_polymarket_15min() methods to SynthClient - Add asset parameter to get_polymarket_daily() and get_polymarket_hourly() - Update matcher.py with MARKET_5MIN and MARKET_15MIN type detection - Add short asset prefixes (btc-, eth-, sol-) to asset detection - Preserve dual-horizon analysis (1h vs 24h) for daily/hourly markets - Add analyze_single_horizon() with cross-horizon reference for 5min/15min - Update Chrome extension UI for dynamic horizon labels and asset display - Add mock data for 5min/15min markets and ETH/SOL daily/hourly - 80 tests passing including 6 new analyzer + 8 new server tests Closes #11 --- mock_data/polymarket/up_down_15min_BTC.json | 20 +++ mock_data/polymarket/up_down_15min_ETH.json | 20 +++ mock_data/polymarket/up_down_15min_SOL.json | 20 +++ mock_data/polymarket/up_down_5min_BTC.json | 20 +++ mock_data/polymarket/up_down_5min_ETH.json | 20 +++ mock_data/polymarket/up_down_5min_SOL.json | 20 +++ ...down_daily.json => up_down_daily_BTC.json} | 0 mock_data/polymarket/up_down_daily_ETH.json | 21 +++ mock_data/polymarket/up_down_daily_SOL.json | 21 +++ ...wn_hourly.json => up_down_hourly_BTC.json} | 0 mock_data/polymarket/up_down_hourly_ETH.json | 21 +++ mock_data/polymarket/up_down_hourly_SOL.json | 21 +++ synth_client/client.py | 52 ++++++- tools/synth-overlay/README.md | 13 +- tools/synth-overlay/analyzer.py | 85 +++++++++++ tools/synth-overlay/extension/sidepanel.html | 9 +- tools/synth-overlay/extension/sidepanel.js | 60 ++++++-- tools/synth-overlay/matcher.py | 13 +- tools/synth-overlay/server.py | 135 ++++++++++++------ tools/synth-overlay/tests/test_analyzer.py | 43 ++++++ tools/synth-overlay/tests/test_matcher.py | 23 ++- tools/synth-overlay/tests/test_server.py | 72 ++++++++-- 22 files changed, 631 insertions(+), 78 deletions(-) create mode 100644 mock_data/polymarket/up_down_15min_BTC.json create mode 100644 mock_data/polymarket/up_down_15min_ETH.json create mode 100644 mock_data/polymarket/up_down_15min_SOL.json create mode 100644 mock_data/polymarket/up_down_5min_BTC.json create mode 100644 mock_data/polymarket/up_down_5min_ETH.json create mode 100644 mock_data/polymarket/up_down_5min_SOL.json rename mock_data/polymarket/{up_down_daily.json => up_down_daily_BTC.json} (100%) create mode 100644 mock_data/polymarket/up_down_daily_ETH.json create mode 100644 mock_data/polymarket/up_down_daily_SOL.json rename mock_data/polymarket/{up_down_hourly.json => up_down_hourly_BTC.json} (100%) create mode 100644 mock_data/polymarket/up_down_hourly_ETH.json create mode 100644 mock_data/polymarket/up_down_hourly_SOL.json diff --git a/mock_data/polymarket/up_down_15min_BTC.json b/mock_data/polymarket/up_down_15min_BTC.json new file mode 100644 index 0000000..c1ffc9c --- /dev/null +++ b/mock_data/polymarket/up_down_15min_BTC.json @@ -0,0 +1,20 @@ +{ + "slug": "btc-updown-15m-1772204400", + "start_price": 68176.05, + "current_time": "2026-02-27T15:04:21+00:00", + "current_price": 67936.60, + "current_outcome": "Down", + "synth_probability_up": 0.0969, + "synth_outcome": "Down", + "polymarket_probability_up": 0.475, + "polymarket_outcome": "Down", + "event_start_time": "2026-02-27T15:00:00+00:00", + "event_end_time": "2026-02-27T15:15:00+00:00", + "best_bid_price": 0.47, + "best_ask_price": 0.48, + "best_bid_size": 218.0, + "best_ask_size": 445.47, + "polymarket_last_trade_time": "2026-02-27T14:59:57+00:00", + "polymarket_last_trade_price": 0.4582, + "polymarket_last_trade_outcome": "Down" +} diff --git a/mock_data/polymarket/up_down_15min_ETH.json b/mock_data/polymarket/up_down_15min_ETH.json new file mode 100644 index 0000000..e7aefca --- /dev/null +++ b/mock_data/polymarket/up_down_15min_ETH.json @@ -0,0 +1,20 @@ +{ + "slug": "eth-updown-15m-1772204400", + "start_price": 3245.12, + "current_time": "2026-02-27T15:04:21+00:00", + "current_price": 3238.45, + "current_outcome": "Down", + "synth_probability_up": 0.3521, + "synth_outcome": "Down", + "polymarket_probability_up": 0.42, + "polymarket_outcome": "Down", + "event_start_time": "2026-02-27T15:00:00+00:00", + "event_end_time": "2026-02-27T15:15:00+00:00", + "best_bid_price": 0.41, + "best_ask_price": 0.43, + "best_bid_size": 156.0, + "best_ask_size": 312.5, + "polymarket_last_trade_time": "2026-02-27T14:58:32+00:00", + "polymarket_last_trade_price": 0.42, + "polymarket_last_trade_outcome": "Down" +} diff --git a/mock_data/polymarket/up_down_15min_SOL.json b/mock_data/polymarket/up_down_15min_SOL.json new file mode 100644 index 0000000..1b6eda6 --- /dev/null +++ b/mock_data/polymarket/up_down_15min_SOL.json @@ -0,0 +1,20 @@ +{ + "slug": "sol-updown-15m-1772204400", + "start_price": 142.85, + "current_time": "2026-02-27T15:04:21+00:00", + "current_price": 141.92, + "current_outcome": "Down", + "synth_probability_up": 0.2845, + "synth_outcome": "Down", + "polymarket_probability_up": 0.38, + "polymarket_outcome": "Down", + "event_start_time": "2026-02-27T15:00:00+00:00", + "event_end_time": "2026-02-27T15:15:00+00:00", + "best_bid_price": 0.37, + "best_ask_price": 0.39, + "best_bid_size": 89.0, + "best_ask_size": 201.3, + "polymarket_last_trade_time": "2026-02-27T14:57:18+00:00", + "polymarket_last_trade_price": 0.38, + "polymarket_last_trade_outcome": "Down" +} diff --git a/mock_data/polymarket/up_down_5min_BTC.json b/mock_data/polymarket/up_down_5min_BTC.json new file mode 100644 index 0000000..abc6dec --- /dev/null +++ b/mock_data/polymarket/up_down_5min_BTC.json @@ -0,0 +1,20 @@ +{ + "slug": "btc-updown-5m-1772205000", + "start_price": 67980.25, + "current_time": "2026-02-27T15:16:21+00:00", + "current_price": 67945.80, + "current_outcome": "Down", + "synth_probability_up": 0.4215, + "synth_outcome": "Down", + "polymarket_probability_up": 0.485, + "polymarket_outcome": "Down", + "event_start_time": "2026-02-27T15:15:00+00:00", + "event_end_time": "2026-02-27T15:20:00+00:00", + "best_bid_price": 0.48, + "best_ask_price": 0.49, + "best_bid_size": 125.0, + "best_ask_size": 267.3, + "polymarket_last_trade_time": "2026-02-27T15:15:42+00:00", + "polymarket_last_trade_price": 0.485, + "polymarket_last_trade_outcome": "Down" +} diff --git a/mock_data/polymarket/up_down_5min_ETH.json b/mock_data/polymarket/up_down_5min_ETH.json new file mode 100644 index 0000000..78a2d0c --- /dev/null +++ b/mock_data/polymarket/up_down_5min_ETH.json @@ -0,0 +1,20 @@ +{ + "slug": "eth-updown-5m-1772205000", + "start_price": 3240.50, + "current_time": "2026-02-27T15:16:21+00:00", + "current_price": 3242.15, + "current_outcome": "Up", + "synth_probability_up": 0.5832, + "synth_outcome": "Up", + "polymarket_probability_up": 0.52, + "polymarket_outcome": "Up", + "event_start_time": "2026-02-27T15:15:00+00:00", + "event_end_time": "2026-02-27T15:20:00+00:00", + "best_bid_price": 0.51, + "best_ask_price": 0.53, + "best_bid_size": 98.0, + "best_ask_size": 184.2, + "polymarket_last_trade_time": "2026-02-27T15:15:38+00:00", + "polymarket_last_trade_price": 0.52, + "polymarket_last_trade_outcome": "Up" +} diff --git a/mock_data/polymarket/up_down_5min_SOL.json b/mock_data/polymarket/up_down_5min_SOL.json new file mode 100644 index 0000000..99493f5 --- /dev/null +++ b/mock_data/polymarket/up_down_5min_SOL.json @@ -0,0 +1,20 @@ +{ + "slug": "sol-updown-5m-1772205000", + "start_price": 141.95, + "current_time": "2026-02-27T15:16:21+00:00", + "current_price": 142.08, + "current_outcome": "Up", + "synth_probability_up": 0.5124, + "synth_outcome": "Up", + "polymarket_probability_up": 0.48, + "polymarket_outcome": "Down", + "event_start_time": "2026-02-27T15:15:00+00:00", + "event_end_time": "2026-02-27T15:20:00+00:00", + "best_bid_price": 0.47, + "best_ask_price": 0.49, + "best_bid_size": 72.0, + "best_ask_size": 145.8, + "polymarket_last_trade_time": "2026-02-27T15:15:29+00:00", + "polymarket_last_trade_price": 0.48, + "polymarket_last_trade_outcome": "Down" +} diff --git a/mock_data/polymarket/up_down_daily.json b/mock_data/polymarket/up_down_daily_BTC.json similarity index 100% rename from mock_data/polymarket/up_down_daily.json rename to mock_data/polymarket/up_down_daily_BTC.json diff --git a/mock_data/polymarket/up_down_daily_ETH.json b/mock_data/polymarket/up_down_daily_ETH.json new file mode 100644 index 0000000..40ee07d --- /dev/null +++ b/mock_data/polymarket/up_down_daily_ETH.json @@ -0,0 +1,21 @@ +{ + "slug": "ethereum-up-or-down-on-february-26", + "start_price": 3280.45, + "current_time": "2026-02-25T23:45:17.526062+00:00", + "current_price": 3245.82, + "current_outcome": "Down", + "synth_probability_up": 0.3825, + "synth_outcome": "Down", + "polymarket_probability_up": 0.415, + "polymarket_outcome": "Down", + "event_start_time": "2026-02-25T17:00:00+00:00", + "event_end_time": "2026-02-26T17:00:00+00:00", + "event_creation_time": "2026-02-24T17:02:40.731726+00:00", + "best_bid_price": 0.41, + "best_ask_price": 0.42, + "best_bid_size": 890.0, + "best_ask_size": 1250.0, + "polymarket_last_trade_time": "2026-02-25T23:42:15+00:00", + "polymarket_last_trade_price": 0.415, + "polymarket_last_trade_outcome": "Down" +} diff --git a/mock_data/polymarket/up_down_daily_SOL.json b/mock_data/polymarket/up_down_daily_SOL.json new file mode 100644 index 0000000..851bc2f --- /dev/null +++ b/mock_data/polymarket/up_down_daily_SOL.json @@ -0,0 +1,21 @@ +{ + "slug": "solana-up-or-down-on-february-26", + "start_price": 145.62, + "current_time": "2026-02-25T23:45:17.526062+00:00", + "current_price": 142.18, + "current_outcome": "Down", + "synth_probability_up": 0.3142, + "synth_outcome": "Down", + "polymarket_probability_up": 0.38, + "polymarket_outcome": "Down", + "event_start_time": "2026-02-25T17:00:00+00:00", + "event_end_time": "2026-02-26T17:00:00+00:00", + "event_creation_time": "2026-02-24T17:02:40.731726+00:00", + "best_bid_price": 0.37, + "best_ask_price": 0.39, + "best_bid_size": 520.0, + "best_ask_size": 780.0, + "polymarket_last_trade_time": "2026-02-25T23:40:32+00:00", + "polymarket_last_trade_price": 0.38, + "polymarket_last_trade_outcome": "Down" +} diff --git a/mock_data/polymarket/up_down_hourly.json b/mock_data/polymarket/up_down_hourly_BTC.json similarity index 100% rename from mock_data/polymarket/up_down_hourly.json rename to mock_data/polymarket/up_down_hourly_BTC.json diff --git a/mock_data/polymarket/up_down_hourly_ETH.json b/mock_data/polymarket/up_down_hourly_ETH.json new file mode 100644 index 0000000..dc7f442 --- /dev/null +++ b/mock_data/polymarket/up_down_hourly_ETH.json @@ -0,0 +1,21 @@ +{ + "slug": "ethereum-up-or-down-february-25-6pm-et", + "start_price": 3252.80, + "current_time": "2026-02-25T23:45:23.586996+00:00", + "current_price": 3245.82, + "current_outcome": "Down", + "synth_probability_up": 0.2845, + "synth_outcome": "Down", + "polymarket_probability_up": 0.32, + "polymarket_outcome": "Down", + "event_start_time": "2026-02-25T23:00:00+00:00", + "event_end_time": "2026-02-26T00:00:00+00:00", + "event_creation_time": "2026-02-23T23:02:47.889891+00:00", + "best_bid_price": 0.31, + "best_ask_price": 0.33, + "best_bid_size": 145.0, + "best_ask_size": 210.5, + "polymarket_last_trade_time": "2026-02-25T23:43:18+00:00", + "polymarket_last_trade_price": 0.32, + "polymarket_last_trade_outcome": "Down" +} diff --git a/mock_data/polymarket/up_down_hourly_SOL.json b/mock_data/polymarket/up_down_hourly_SOL.json new file mode 100644 index 0000000..d659e35 --- /dev/null +++ b/mock_data/polymarket/up_down_hourly_SOL.json @@ -0,0 +1,21 @@ +{ + "slug": "solana-up-or-down-february-25-6pm-et", + "start_price": 143.25, + "current_time": "2026-02-25T23:45:23.586996+00:00", + "current_price": 142.18, + "current_outcome": "Down", + "synth_probability_up": 0.3521, + "synth_outcome": "Down", + "polymarket_probability_up": 0.40, + "polymarket_outcome": "Down", + "event_start_time": "2026-02-25T23:00:00+00:00", + "event_end_time": "2026-02-26T00:00:00+00:00", + "event_creation_time": "2026-02-23T23:02:47.889891+00:00", + "best_bid_price": 0.39, + "best_ask_price": 0.41, + "best_bid_size": 98.0, + "best_ask_size": 156.2, + "polymarket_last_trade_time": "2026-02-25T23:41:45+00:00", + "polymarket_last_trade_price": 0.40, + "polymarket_last_trade_outcome": "Down" +} diff --git a/synth_client/client.py b/synth_client/client.py index ab68980..7954178 100644 --- a/synth_client/client.py +++ b/synth_client/client.py @@ -219,28 +219,68 @@ def get_lp_probabilities(self, asset: str) -> dict: # ─── Polymarket ────────────────────────────────────────────────── - def get_polymarket_daily(self) -> dict: + def get_polymarket_daily(self, asset: str = "BTC") -> dict: """ Get daily up/down comparison between Synth forecasts and Polymarket prices. + Args: + asset: Asset symbol (BTC, ETH, SOL). Default: BTC + Returns: - Dict with Synth vs Polymarket probability comparison (BTC) + Dict with Synth vs Polymarket probability comparison """ return self._get( "/insights/polymarket/up-down/daily", - ["polymarket", "up_down_daily.json"], + ["polymarket", f"up_down_daily_{asset}.json"], + params={"asset": asset}, ) - def get_polymarket_hourly(self) -> dict: + def get_polymarket_hourly(self, asset: str = "BTC") -> dict: """ Get hourly up/down comparison between Synth forecasts and Polymarket prices. + Args: + asset: Asset symbol (BTC, ETH, SOL). Default: BTC + Returns: - Dict with Synth vs Polymarket probability comparison (BTC) + Dict with Synth vs Polymarket probability comparison """ return self._get( "/insights/polymarket/up-down/hourly", - ["polymarket", "up_down_hourly.json"], + ["polymarket", f"up_down_hourly_{asset}.json"], + params={"asset": asset}, + ) + + def get_polymarket_15min(self, asset: str = "BTC") -> dict: + """ + Get 15-minute up/down comparison between Synth forecasts and Polymarket prices. + + Args: + asset: Asset symbol (BTC, ETH, SOL). Default: BTC + + Returns: + Dict with Synth vs Polymarket probability comparison + """ + return self._get( + "/insights/polymarket/up-down/15min", + ["polymarket", f"up_down_15min_{asset}.json"], + params={"asset": asset}, + ) + + def get_polymarket_5min(self, asset: str = "BTC") -> dict: + """ + Get 5-minute up/down comparison between Synth forecasts and Polymarket prices. + + Args: + asset: Asset symbol (BTC, ETH, SOL). Default: BTC + + Returns: + Dict with Synth vs Polymarket probability comparison + """ + return self._get( + "/insights/polymarket/up-down/5min", + ["polymarket", f"up_down_5min_{asset}.json"], + params={"asset": asset}, ) def get_polymarket_range(self) -> list: diff --git a/tools/synth-overlay/README.md b/tools/synth-overlay/README.md index 00f71ff..f1e015e 100644 --- a/tools/synth-overlay/README.md +++ b/tools/synth-overlay/README.md @@ -20,10 +20,12 @@ Chrome extension that uses Chrome's **native Side Panel** to show Synth market c ## Synth API usage -- `get_polymarket_daily()` — daily up/down (24h) Synth vs Polymarket. -- `get_polymarket_hourly()` — hourly up/down (1h). +- `get_polymarket_daily(asset)` — daily up/down (24h) Synth vs Polymarket. +- `get_polymarket_hourly(asset)` — hourly up/down (1h). +- `get_polymarket_15min(asset)` — 15-minute up/down (15m). +- `get_polymarket_5min(asset)` — 5-minute up/down (5m). - `get_polymarket_range()` — range brackets with synth vs polymarket probability per bracket. -- `get_prediction_percentiles(asset, horizon)` — used for confidence scoring (forecast spread) and optional bias in explanations; wired for both up/down and range. +- `get_prediction_percentiles(asset, horizon)` — used for confidence scoring (forecast spread) and optional bias in explanations. ## Run locally @@ -41,8 +43,9 @@ Chrome extension that uses Chrome's **native Side Panel** to show Synth market c 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 (mock): `https://polymarket.com/event/bitcoin-up-or-down-on-february-26` - - Hourly (mock): `https://polymarket.com/event/bitcoin-up-or-down-february-25-6pm-et` + - 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` - 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/analyzer.py b/tools/synth-overlay/analyzer.py index d4134e6..2e897b4 100644 --- a/tools/synth-overlay/analyzer.py +++ b/tools/synth-overlay/analyzer.py @@ -247,6 +247,91 @@ def _build_range_invalidation(self, bracket: dict, signal: str) -> str: ) return f"No meaningful edge on {title} — bracket is fairly priced." + def analyze_single_horizon(self, data: dict, horizon: str = "24h") -> AnalysisResult: + """Analyze a single up/down market with optional reference-horizon context.""" + edge = self._extract_edge(data, horizon) + strength = strength_from_edge(edge.edge_pct) + + ref_edge = None + if self._hourly: + try: + ref_edge = self._extract_edge(self._hourly, "ref") + except (KeyError, ValueError): + pass + + if ref_edge and signals_conflict(edge.signal, ref_edge.signal): + strength = "none" + + spread_1h = self._percentile_spread(self._pct_1h) + spread_24h = self._percentile_spread(self._pct_24h) + confidence = self.compute_confidence(spread_1h, spread_24h) + + high_uncertainty = any( + s is not None and s > 0.05 for s in (spread_1h, spread_24h) + ) + conflict = ref_edge is not None and signals_conflict(edge.signal, ref_edge.signal) + no_trade = conflict or strength == "none" or high_uncertainty + + bias = self._directional_bias(self._pct_24h) or self._directional_bias(self._pct_1h) + explanation = self._build_single_explanation(edge, ref_edge, confidence, horizon) + invalidation = self._build_short_invalidation(edge, bias, horizon) + + return AnalysisResult( + primary=edge, + secondary=ref_edge, + strength=strength, + confidence_score=confidence, + no_trade=no_trade, + explanation=explanation, + invalidation=invalidation, + ) + + def _build_single_explanation( + self, edge: HorizonEdge, ref_edge: HorizonEdge | None, confidence: float, horizon: str + ) -> str: + """Build explanation for single-horizon analysis with optional reference context.""" + parts = [] + if edge.signal == "fair": + parts.append(f"Synth and Polymarket agree closely on this {horizon} market.") + else: + direction = "higher" if edge.edge_pct > 0 else "lower" + parts.append( + f"Synth forecasts {direction} probability than Polymarket " + f"by {abs(edge.edge_pct):.1f}pp on the {horizon} horizon." + ) + if ref_edge and ref_edge.signal != "fair": + if signals_conflict(edge.signal, ref_edge.signal): + parts.append("Reference horizon disagrees — signals conflict.") + else: + parts.append( + f"Reference horizon confirms with {abs(ref_edge.edge_pct):.1f}pp edge." + ) + if confidence >= 0.7: + parts.append("Forecast distribution is narrow — high confidence.") + elif confidence <= 0.3: + parts.append("Forecast distribution is wide — low confidence, treat with caution.") + return " ".join(parts) + + def _build_short_invalidation(self, edge: HorizonEdge, bias: float | None, horizon: str) -> str: + """Build invalidation text appropriate for short-term markets.""" + parts = [] + if edge.signal == "underpriced": + parts.append( + f"This {horizon} edge invalidates if price reverses, " + f"pushing Synth probability below market." + ) + elif edge.signal == "overpriced": + parts.append( + f"This {horizon} edge invalidates if price moves up, " + f"pushing Synth probability above market." + ) + else: + parts.append("No meaningful edge to invalidate — market is fairly priced.") + if bias is not None and abs(bias) > 0.02: + direction = "upward" if bias > 0 else "downward" + parts.append(f"Synth median shows a {direction} bias of {abs(bias)*100:.1f}%.") + return " ".join(parts) + def analyze(self, primary_horizon: str = "24h") -> AnalysisResult: if not self._daily or not self._hourly: raise ValueError("Both daily and hourly data required for analysis") diff --git a/tools/synth-overlay/extension/sidepanel.html b/tools/synth-overlay/extension/sidepanel.html index 561b3c0..eb2dc2b 100644 --- a/tools/synth-overlay/extension/sidepanel.html +++ b/tools/synth-overlay/extension/sidepanel.html @@ -25,11 +25,16 @@

Synth

Signal
-
1 h
-
24 h
+
Primary
+
Reference
Strength
+
+
Asset
+
Market
+
+
Confidence
diff --git a/tools/synth-overlay/extension/sidepanel.js b/tools/synth-overlay/extension/sidepanel.js index fae0a45..37682a5 100644 --- a/tools/synth-overlay/extension/sidepanel.js +++ b/tools/synth-overlay/extension/sidepanel.js @@ -7,9 +7,14 @@ const els = { synthUp: document.getElementById("synthUp"), synthDown: document.getElementById("synthDown"), edgeValue: document.getElementById("edgeValue"), - signal1h: document.getElementById("signal1h"), - signal24h: document.getElementById("signal24h"), + horizonLabel: document.getElementById("horizonLabel"), + signalPrimary: document.getElementById("signalPrimary"), + refRow: document.getElementById("refRow"), + refLabel: document.getElementById("refLabel"), + signalRef: document.getElementById("signalRef"), strength: document.getElementById("strength"), + assetName: document.getElementById("assetName"), + marketType: document.getElementById("marketType"), confFill: document.getElementById("confFill"), confText: document.getElementById("confText"), analysisText: document.getElementById("analysisText"), @@ -69,9 +74,14 @@ function render(state) { els.synthUp.textContent = state.synthUp; els.synthDown.textContent = state.synthDown; els.edgeValue.textContent = state.edge; - els.signal1h.textContent = state.signal1h; - els.signal24h.textContent = state.signal24h; + els.horizonLabel.textContent = state.horizonLabel || "Primary"; + els.signalPrimary.textContent = state.signalPrimary; + els.refLabel.textContent = state.refLabel || "Reference"; + els.signalRef.textContent = state.signalRef; + els.refRow.style.display = state.signalRef === "—" ? "none" : ""; els.strength.textContent = state.strength; + els.assetName.textContent = state.asset || "—"; + els.marketType.textContent = state.marketType || "—"; els.analysisText.textContent = state.analysis; els.noTrade.classList.toggle("hidden", !state.noTrade); els.invalidationText.textContent = state.invalidation; @@ -83,7 +93,9 @@ function render(state) { const EMPTY = { synthUp: "—", synthDown: "—", edge: "—", - signal1h: "—", signal24h: "—", strength: "—", + horizonLabel: "Primary", signalPrimary: "—", + refLabel: "Reference", signalRef: "—", + strength: "—", asset: "—", marketType: "—", analysis: "—", noTrade: false, invalidation: "—", confPct: 0, confColor: "#9ca3af", confText: "—", lastUpdate: "—", @@ -119,22 +131,46 @@ async function refresh() { return; } - const synthProbUp = edge.synth_probability_up != null ? edge.synth_probability_up : edge.synth_probability; - const conf = edge.confidence_score != null ? edge.confidence_score : 0.5; - const confPct = Math.round(conf * 100); + var synthProbUp = edge.synth_probability_up != null ? edge.synth_probability_up : edge.synth_probability; + var conf = edge.confidence_score != null ? edge.confidence_score : 0.5; + var confPct = Math.round(conf * 100); + var horizon = edge.horizon || "24h"; + var mtype = edge.market_type || "daily"; + + // Build signal labels based on response shape + var horizonLabel = horizon; + var signalPrimary = (edge.signal || "—") + " " + fmtEdge(edge.edge_pct); + var refLabel = "Reference"; + var signalRef = "—"; + + // Dual-horizon (daily/hourly): use 1h/24h fields + if (edge.signal_1h && edge.signal_24h) { + horizonLabel = "1 h"; + signalPrimary = edge.signal_1h + " " + fmtEdge(edge.edge_1h_pct); + refLabel = "24 h"; + signalRef = edge.signal_24h + " " + fmtEdge(edge.edge_24h_pct); + } else if (edge.ref_signal) { + // Short-horizon with reference context + refLabel = edge.ref_horizon || "Ref"; + signalRef = edge.ref_signal + " " + fmtEdge(edge.ref_edge_pct); + } render({ - status: "Synced — showing Synth forecast data.", + status: "Synced — " + (edge.asset || "BTC") + " " + horizon + " forecast.", synthUp: fmtCentsFromProb(synthProbUp), synthDown: synthProbUp == null ? "—" : fmtCentsFromProb(1 - synthProbUp), edge: fmtEdge(edge.edge_pct), - signal1h: edge.signal_1h ? edge.signal_1h + " " + fmtEdge(edge.edge_1h_pct) : (edge.signal || "—"), - signal24h: edge.signal_24h ? edge.signal_24h + " " + fmtEdge(edge.edge_24h_pct) : "—", + horizonLabel: horizonLabel, + signalPrimary: signalPrimary, + refLabel: refLabel, + signalRef: signalRef, strength: edge.strength || "—", + asset: edge.asset || "BTC", + marketType: mtype, analysis: edge.explanation || "No explanation available.", invalidation: edge.invalidation || "—", noTrade: !!edge.no_trade_warning, - confPct, + confPct: confPct, confColor: confidenceColor(conf), confText: (conf >= 0.7 ? "High" : conf >= 0.4 ? "Medium" : "Low") + " (" + confPct + "%)", lastUpdate: fmtApiTime(edge.current_time), diff --git a/tools/synth-overlay/matcher.py b/tools/synth-overlay/matcher.py index f3c9dae..7f5c596 100644 --- a/tools/synth-overlay/matcher.py +++ b/tools/synth-overlay/matcher.py @@ -5,15 +5,22 @@ MARKET_DAILY = "daily" MARKET_HOURLY = "hourly" +MARKET_15MIN = "15min" +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-|(? str | None: return None -def get_market_type(slug: str) -> Literal["daily", "hourly", "range"] | 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.""" 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: diff --git a/tools/synth-overlay/server.py b/tools/synth-overlay/server.py index c155791..7629f06 100644 --- a/tools/synth-overlay/server.py +++ b/tools/synth-overlay/server.py @@ -45,6 +45,98 @@ def health(): return jsonify({"status": "ok", "mock": get_client().mock_mode}) +_HORIZON_MAP = {"5min": "5min", "15min": "15min", "hourly": "1h", "daily": "24h"} + + +def _fetch_updown_pair(client: SynthClient, asset: str, market_type: str) -> tuple[dict, dict]: + """Fetch primary + reference up/down data for cross-horizon context.""" + fetchers = { + "5min": (client.get_polymarket_5min, client.get_polymarket_15min), + "15min": (client.get_polymarket_15min, client.get_polymarket_hourly), + "hourly": (client.get_polymarket_hourly, client.get_polymarket_daily), + "daily": (client.get_polymarket_daily, client.get_polymarket_hourly), + } + primary_fn, ref_fn = fetchers[market_type] + primary = primary_fn(asset) + try: + reference = ref_fn(asset) + except Exception: + reference = None + return primary, reference + + +def _handle_updown_market(client: SynthClient, slug: str, asset: str, market_type: str): + """Handle up/down markets for any supported asset and horizon.""" + primary_data, reference_data = _fetch_updown_pair(client, asset, market_type) + + pct_1h = None + pct_24h = None + try: + pct_1h = client.get_prediction_percentiles(asset, horizon="1h") + pct_24h = client.get_prediction_percentiles(asset, horizon="24h") + except Exception: + pass + + primary_horizon = _HORIZON_MAP[market_type] + + # Daily/hourly: preserve dual-horizon analysis (1h vs 24h cross-comparison) + if market_type in ("daily", "hourly") and reference_data: + daily = primary_data if market_type == "daily" else reference_data + hourly = reference_data if market_type == "daily" else primary_data + analyzer = EdgeAnalyzer(daily, hourly, pct_1h, pct_24h) + result = analyzer.analyze(primary_horizon=primary_horizon) + primary_src = daily if market_type == "daily" else hourly + return jsonify({ + "slug": slug, + "asset": asset, + "horizon": primary_horizon, + "market_type": market_type, + "edge_pct": result.primary.edge_pct, + "signal": result.primary.signal, + "strength": result.strength, + "confidence_score": result.confidence_score, + "edge_1h_pct": result.secondary.edge_pct if primary_horizon == "24h" else result.primary.edge_pct, + "signal_1h": result.secondary.signal if primary_horizon == "24h" else result.primary.signal, + "edge_24h_pct": result.primary.edge_pct if primary_horizon == "24h" else result.secondary.edge_pct, + "signal_24h": result.primary.signal if primary_horizon == "24h" else result.secondary.signal, + "no_trade_warning": result.no_trade, + "explanation": result.explanation, + "invalidation": result.invalidation, + "synth_probability_up": primary_src.get("synth_probability_up"), + "polymarket_probability_up": primary_src.get("polymarket_probability_up"), + "current_time": primary_src.get("current_time"), + }) + + # 5min/15min: single-horizon analysis with optional reference context + analyzer = EdgeAnalyzer(primary_data, reference_data, pct_1h, pct_24h) + result = analyzer.analyze_single_horizon(primary_data, horizon=primary_horizon) + + resp = { + "slug": slug, + "asset": asset, + "horizon": primary_horizon, + "market_type": market_type, + "edge_pct": result.primary.edge_pct, + "signal": result.primary.signal, + "strength": result.strength, + "confidence_score": result.confidence_score, + "no_trade_warning": result.no_trade, + "explanation": result.explanation, + "invalidation": result.invalidation, + "synth_probability_up": primary_data.get("synth_probability_up"), + "polymarket_probability_up": primary_data.get("polymarket_probability_up"), + "current_time": primary_data.get("current_time"), + } + # Include reference horizon context when available + if reference_data and result.secondary: + resp["ref_horizon"] = _HORIZON_MAP.get( + {"5min": "15min", "15min": "hourly"}.get(market_type, ""), "" + ) + resp["ref_edge_pct"] = result.secondary.edge_pct + resp["ref_signal"] = result.secondary.signal + return jsonify(resp) + + @app.route("/api/edge", methods=["GET", "OPTIONS"]) def edge(): if request.method == "OPTIONS": @@ -59,47 +151,8 @@ def edge(): asset = asset_from_slug(slug) or "BTC" try: client = get_client() - if market_type in ("daily", "hourly"): - if asset != "BTC": - return jsonify({ - "error": f"Only BTC up-or-down markets are currently supported. " - f"This market appears to be {asset}.", - "slug": slug, - "asset": asset, - }), 404 - daily_data = client.get_polymarket_daily() - hourly_data = client.get_polymarket_hourly() - pct_1h = None - pct_24h = None - try: - pct_1h = client.get_prediction_percentiles(asset, horizon="1h") - pct_24h = client.get_prediction_percentiles(asset, horizon="24h") - except Exception: - pass - primary_horizon = "24h" if market_type == "daily" else "1h" - analyzer = EdgeAnalyzer(daily_data, hourly_data, pct_1h, pct_24h) - result = analyzer.analyze(primary_horizon=primary_horizon) - primary_data = daily_data if market_type == "daily" else hourly_data - return jsonify({ - # Echo requested slug so extension can run on active matching markets, - # even when mock payload carries a different representative slug. - "slug": slug, - "horizon": primary_horizon, - "edge_pct": result.primary.edge_pct, - "signal": result.primary.signal, - "strength": result.strength, - "confidence_score": result.confidence_score, - "edge_1h_pct": result.secondary.edge_pct if primary_horizon == "24h" else result.primary.edge_pct, - "signal_1h": result.secondary.signal if primary_horizon == "24h" else result.primary.signal, - "edge_24h_pct": result.primary.edge_pct if primary_horizon == "24h" else result.secondary.edge_pct, - "signal_24h": result.primary.signal if primary_horizon == "24h" else result.secondary.signal, - "no_trade_warning": result.no_trade, - "explanation": result.explanation, - "invalidation": result.invalidation, - "synth_probability_up": primary_data.get("synth_probability_up"), - "polymarket_probability_up": primary_data.get("polymarket_probability_up"), - "current_time": primary_data.get("current_time"), - }) + if market_type in ("daily", "hourly", "15min", "5min"): + return _handle_updown_market(client, slug, asset, market_type) # range data = client.get_polymarket_range() if not isinstance(data, list): diff --git a/tools/synth-overlay/tests/test_analyzer.py b/tools/synth-overlay/tests/test_analyzer.py index 4727efb..5752adf 100644 --- a/tools/synth-overlay/tests/test_analyzer.py +++ b/tools/synth-overlay/tests/test_analyzer.py @@ -185,3 +185,46 @@ def test_range_no_trade_on_weak_edge(self): sel = _bracket("[66000, 68000]", 0.40, 0.40) r = EdgeAnalyzer().analyze_range(sel, [sel]) assert r.no_trade is True + + +class TestSingleHorizonAnalysis: + def test_basic_single_horizon(self): + data = _daily(0.55, 0.50) + r = EdgeAnalyzer(data, None).analyze_single_horizon(data, horizon="15min") + assert isinstance(r, AnalysisResult) + assert r.primary.horizon == "15min" + assert r.primary.edge_pct == 5.0 + assert r.primary.signal == "underpriced" + + def test_single_horizon_with_reference(self): + primary = _daily(0.55, 0.50) + ref = _daily(0.54, 0.50) + r = EdgeAnalyzer(primary, ref).analyze_single_horizon(primary, horizon="5min") + assert r.secondary is not None + assert "confirms" in r.explanation.lower() or "higher" in r.explanation.lower() + + def test_single_horizon_conflict_with_reference(self): + primary = _daily(0.55, 0.50) + ref = _daily(0.44, 0.50) + r = EdgeAnalyzer(primary, ref).analyze_single_horizon(primary, horizon="15min") + assert r.no_trade is True + assert r.strength == "none" + assert "conflict" in r.explanation.lower() + + def test_single_horizon_no_reference(self): + data = _daily(0.50, 0.50) + r = EdgeAnalyzer(data, None).analyze_single_horizon(data, horizon="5min") + assert r.secondary is None + assert r.primary.signal == "fair" + assert "agree" in r.explanation.lower() + + def test_single_horizon_invalidation_uses_horizon(self): + data = _daily(0.55, 0.50) + r = EdgeAnalyzer(data, None).analyze_single_horizon(data, horizon="5min") + assert "5min" in r.invalidation + + def test_single_horizon_confidence_with_percentiles(self): + data = _daily(0.55, 0.50) + pct_narrow = _pct(100, 99.5, 100, 100.5) + r = EdgeAnalyzer(data, None, pct_narrow, pct_narrow).analyze_single_horizon(data) + assert r.confidence_score >= 0.7 diff --git a/tools/synth-overlay/tests/test_matcher.py b/tools/synth-overlay/tests/test_matcher.py index a11dac6..a8f74dc 100644 --- a/tools/synth-overlay/tests/test_matcher.py +++ b/tools/synth-overlay/tests/test_matcher.py @@ -33,6 +33,20 @@ def test_get_market_type_hourly(): assert get_market_type("btc-up-or-down-march-1-3pm-et") == "hourly" +def test_get_market_type_15min(): + assert get_market_type("btc-updown-15m-1772204400") == "15min" + assert get_market_type("eth-updown-15m-1772204400") == "15min" + assert get_market_type("sol-up-down-15m-1772204400") == "15min" + assert get_market_type("bitcoin-15min-market") == "15min" + + +def test_get_market_type_5min(): + assert get_market_type("btc-updown-5m-1772205000") == "5min" + assert get_market_type("eth-updown-5m-1772205000") == "5min" + assert get_market_type("sol-up-down-5m-1772205000") == "5min" + assert get_market_type("bitcoin-5min-market") == "5min" + + def test_get_market_type_range(): assert get_market_type("bitcoin-price-on-february-26") == "range" @@ -44,6 +58,8 @@ def test_get_market_type_unsupported(): def test_is_supported(): assert is_supported("bitcoin-up-or-down-on-february-26") is True assert is_supported("bitcoin-price-on-february-26") is True + assert is_supported("btc-updown-15m-1772204400") is True + assert is_supported("eth-updown-5m-1772205000") is True assert is_supported("unknown-market") is False @@ -54,8 +70,13 @@ def test_asset_from_slug(): assert asset_from_slug("xrp-up-or-down-on-march-1") == "XRP" +def test_asset_from_slug_short_prefixes(): + assert asset_from_slug("btc-up-or-down-on-march-1") == "BTC" + assert asset_from_slug("eth-updown-15m-1772204400") == "ETH" + assert asset_from_slug("sol-updown-5m-1772205000") == "SOL" + + def test_asset_from_slug_unknown(): - assert asset_from_slug("btc-up-or-down-on-march-1") is None assert asset_from_slug("random-slug") is None assert asset_from_slug("") is None assert asset_from_slug(None) is None diff --git a/tools/synth-overlay/tests/test_server.py b/tools/synth-overlay/tests/test_server.py index 11c4117..b2cb182 100644 --- a/tools/synth-overlay/tests/test_server.py +++ b/tools/synth-overlay/tests/test_server.py @@ -37,6 +37,9 @@ def test_edge_daily(client): assert data["signal"] in ("underpriced", "overpriced", "fair") assert data["strength"] in ("strong", "moderate", "none") assert data["horizon"] == "24h" + assert data["market_type"] == "daily" + assert data["asset"] == "BTC" + # Dual-horizon fields preserved for daily/hourly assert "edge_1h_pct" in data assert "edge_24h_pct" in data assert "signal_1h" in data @@ -55,9 +58,10 @@ def test_edge_hourly_uses_hourly_primary_fields(client): assert resp.status_code == 200 data = resp.get_json() assert data["horizon"] == "1h" + assert data["market_type"] == "hourly" assert data["slug"] == "bitcoin-up-or-down-february-25-6pm-et" - assert data["synth_probability_up"] == 0.0004 - assert data["polymarket_probability_up"] == 0.006500000000000001 + assert "synth_probability_up" in data + assert "polymarket_probability_up" in data def test_edge_missing_slug(client): @@ -109,17 +113,65 @@ def test_edge_range_unknown_slug_404(client): assert resp.status_code == 404 -def test_edge_non_btc_daily_rejected(client): - resp = client.get("/api/edge?slug=ethereum-up-or-down-on-february-28") - assert resp.status_code == 404 +def test_edge_eth_daily_supported(client): + resp = client.get("/api/edge?slug=ethereum-up-or-down-on-february-26") + assert resp.status_code == 200 data = resp.get_json() - assert "Only BTC" in data["error"] assert data["asset"] == "ETH" + assert data["market_type"] == "daily" + assert "edge_pct" in data -def test_edge_non_btc_hourly_rejected(client): - resp = client.get("/api/edge?slug=solana-up-or-down-february-28-3pm-et") - assert resp.status_code == 404 +def test_edge_sol_hourly_supported(client): + resp = client.get("/api/edge?slug=solana-up-or-down-february-25-6pm-et") + assert resp.status_code == 200 + data = resp.get_json() + assert data["asset"] == "SOL" + assert data["market_type"] == "hourly" + assert "edge_pct" in data + + +def test_edge_15min_btc(client): + resp = client.get("/api/edge?slug=btc-updown-15m-1772204400") + assert resp.status_code == 200 + data = resp.get_json() + assert data["horizon"] == "15min" + assert data["market_type"] == "15min" + assert data["asset"] == "BTC" + assert "edge_pct" in data + assert "confidence_score" in data + assert "explanation" in data + assert len(data["explanation"]) > 10 + assert "invalidation" in data + assert "15min" in data["invalidation"] + # Should NOT have dual-horizon 1h/24h fields + assert "edge_1h_pct" not in data + assert "signal_24h" not in data + + +def test_edge_15min_eth(client): + resp = client.get("/api/edge?slug=eth-updown-15m-1772204400") + assert resp.status_code == 200 + data = resp.get_json() + assert data["asset"] == "ETH" + assert data["market_type"] == "15min" + + +def test_edge_5min_btc(client): + resp = client.get("/api/edge?slug=btc-updown-5m-1772205000") + assert resp.status_code == 200 + data = resp.get_json() + assert data["horizon"] == "5min" + assert data["market_type"] == "5min" + assert data["asset"] == "BTC" + assert "edge_pct" in data + assert "5min" in data["invalidation"] + assert "edge_1h_pct" not in data + + +def test_edge_5min_sol(client): + resp = client.get("/api/edge?slug=sol-updown-5m-1772205000") + assert resp.status_code == 200 data = resp.get_json() - assert "Only BTC" in data["error"] assert data["asset"] == "SOL" + assert data["market_type"] == "5min" From 03e9bb25d4ef0fb8b6778c9cebc7661e94a6d86b Mon Sep 17 00:00:00 2001 From: bitloi Date: Wed, 4 Mar 2026 18:56:34 +0100 Subject: [PATCH 2/8] fix: address PR feedback - live DOM price scraping and polling optimization 1. Real-time Polymarket Price Tracking: - Added scrapeLivePrices() in content.js with 3 fallback strategies - Pass live_prob_up to server for real-time edge calculation - Server overrides API price with live DOM price when available - Added live_price_used field in response for debugging 2. API Polling Frequency: - Changed polling from 15s to 30s (Synth API updates ~60s) - Documented rationale in code comments - Live DOM prices provide real-time updates independent of API Tests: 82 passed --- tools/synth-overlay/extension/content.js | 83 ++++++++++++++++++++++ tools/synth-overlay/extension/sidepanel.js | 18 +++-- tools/synth-overlay/server.py | 35 ++++++++- tools/synth-overlay/tests/test_server.py | 24 +++++++ 4 files changed, 153 insertions(+), 7 deletions(-) diff --git a/tools/synth-overlay/extension/content.js b/tools/synth-overlay/extension/content.js index 9fbed49..3d31907 100644 --- a/tools/synth-overlay/extension/content.js +++ b/tools/synth-overlay/extension/content.js @@ -18,12 +18,95 @@ return segments[segments.length - 1] || null; } + /** + * Scrape live Polymarket prices from the DOM. + * Looks for Up/Down outcome buttons and extracts their cent values. + * Returns { upPrice: 0.XX, downPrice: 0.XX } or null if not found. + */ + function scrapeLivePrices() { + var upPrice = null; + var downPrice = null; + + // Strategy 1: Look for buttons/elements containing "Up" or "Down" with cent values + // Polymarket typically shows prices like "Up 85¢" or "Down 16¢" + var allElements = document.querySelectorAll("button, [role='button'], a"); + + for (var i = 0; i < allElements.length; i++) { + var el = allElements[i]; + var text = (el.textContent || "").trim(); + + // Match patterns like "Up 85¢", "Up85¢", "Up 0.85", "Yes 85¢" + var upMatch = text.match(/^(Up|Yes)\s*(\d+)\s*[¢c]?$/i); + var downMatch = text.match(/^(Down|No)\s*(\d+)\s*[¢c]?$/i); + + if (upMatch && upMatch[2]) { + upPrice = parseInt(upMatch[2], 10) / 100; + } + if (downMatch && downMatch[2]) { + downPrice = parseInt(downMatch[2], 10) / 100; + } + } + + // Strategy 2: Look for data attributes or structured price elements + if (upPrice === null || downPrice === null) { + var priceEls = document.querySelectorAll("[data-outcome], [data-price]"); + for (var j = 0; j < priceEls.length; j++) { + var priceEl = priceEls[j]; + var outcome = priceEl.getAttribute("data-outcome"); + var priceVal = priceEl.getAttribute("data-price"); + if (outcome && priceVal) { + var p = parseFloat(priceVal); + if (outcome.toLowerCase() === "up" || outcome.toLowerCase() === "yes") { + upPrice = p; + } else if (outcome.toLowerCase() === "down" || outcome.toLowerCase() === "no") { + downPrice = p; + } + } + } + } + + // Strategy 3: Look for common Polymarket class patterns + if (upPrice === null || downPrice === null) { + // Try finding outcome cards with price text + var cards = document.querySelectorAll("[class*='outcome'], [class*='Outcome']"); + for (var k = 0; k < cards.length; k++) { + var card = cards[k]; + var cardText = (card.textContent || "").toLowerCase(); + var centMatch = cardText.match(/(\d+)\s*[¢c]/); + if (centMatch) { + var cents = parseInt(centMatch[1], 10) / 100; + if (cardText.indexOf("up") !== -1 || cardText.indexOf("yes") !== -1) { + upPrice = cents; + } else if (cardText.indexOf("down") !== -1 || cardText.indexOf("no") !== -1) { + downPrice = cents; + } + } + } + } + + if (upPrice !== null && downPrice !== null) { + return { upPrice: upPrice, downPrice: downPrice }; + } + + // Fallback: if we only have one, derive the other (prices should sum to ~1) + if (upPrice !== null && downPrice === null) { + return { upPrice: upPrice, downPrice: 1 - upPrice }; + } + if (downPrice !== null && upPrice === null) { + return { upPrice: 1 - downPrice, downPrice: downPrice }; + } + + return null; + } + function getContext() { + var livePrices = scrapeLivePrices(); return { slug: slugFromPage(), url: window.location.href, host: window.location.hostname, pageUpdatedAt: Date.now(), + livePrices: livePrices, }; } diff --git a/tools/synth-overlay/extension/sidepanel.js b/tools/synth-overlay/extension/sidepanel.js index 37682a5..7a6d455 100644 --- a/tools/synth-overlay/extension/sidepanel.js +++ b/tools/synth-overlay/extension/sidepanel.js @@ -63,8 +63,13 @@ async function getContextFromPage(tabId) { } } -async function fetchEdge(slug) { - const res = await fetch(API_BASE + "/api/edge?slug=" + encodeURIComponent(slug)); +async function fetchEdge(slug, livePrices) { + var url = API_BASE + "/api/edge?slug=" + encodeURIComponent(slug); + // 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(); } @@ -122,7 +127,7 @@ async function refresh() { return; } - const edge = await fetchEdge(ctx.slug); + const edge = await fetchEdge(ctx.slug, ctx.livePrices); if (!edge || edge.error) { render(Object.assign({}, EMPTY, { status: "Market not supported by Synth for this slug.", @@ -179,4 +184,9 @@ async function refresh() { els.refreshBtn.addEventListener("click", refresh); refresh(); -setInterval(refresh, 15000); + +// Polling frequency: Synth API updates forecasts every ~60 seconds for short-term markets. +// We poll every 30 seconds to balance freshness vs API load. +// Live DOM prices are scraped on each refresh for real-time edge calculation. +const SYNTH_POLL_INTERVAL_MS = 30000; +setInterval(refresh, SYNTH_POLL_INTERVAL_MS); diff --git a/tools/synth-overlay/server.py b/tools/synth-overlay/server.py index 7629f06..2855fab 100644 --- a/tools/synth-overlay/server.py +++ b/tools/synth-overlay/server.py @@ -65,10 +65,22 @@ def _fetch_updown_pair(client: SynthClient, asset: str, market_type: str) -> tup return primary, reference -def _handle_updown_market(client: SynthClient, slug: str, asset: str, market_type: str): - """Handle up/down markets for any supported asset and horizon.""" +def _handle_updown_market( + client: SynthClient, slug: str, asset: str, market_type: str, live_prob_up: float | None = None +): + """Handle up/down markets for any supported asset and horizon. + + Args: + live_prob_up: Real-time Polymarket price scraped from DOM. If provided, + overrides the API's polymarket_probability_up for edge calculation. + """ primary_data, reference_data = _fetch_updown_pair(client, asset, market_type) + # Override Polymarket probability with live DOM price if available + if live_prob_up is not None: + primary_data = dict(primary_data) # Copy to avoid mutating cached data + primary_data["polymarket_probability_up"] = live_prob_up + pct_1h = None pct_24h = None try: @@ -83,6 +95,14 @@ def _handle_updown_market(client: SynthClient, slug: str, asset: str, market_typ if market_type in ("daily", "hourly") and reference_data: daily = primary_data if market_type == "daily" else reference_data hourly = reference_data if market_type == "daily" else primary_data + # Apply live price override to the appropriate horizon data + if live_prob_up is not None: + if market_type == "daily": + daily = dict(daily) + daily["polymarket_probability_up"] = live_prob_up + else: + hourly = dict(hourly) + hourly["polymarket_probability_up"] = live_prob_up analyzer = EdgeAnalyzer(daily, hourly, pct_1h, pct_24h) result = analyzer.analyze(primary_horizon=primary_horizon) primary_src = daily if market_type == "daily" else hourly @@ -105,6 +125,7 @@ def _handle_updown_market(client: SynthClient, slug: str, asset: str, market_typ "synth_probability_up": primary_src.get("synth_probability_up"), "polymarket_probability_up": primary_src.get("polymarket_probability_up"), "current_time": primary_src.get("current_time"), + "live_price_used": live_prob_up is not None, }) # 5min/15min: single-horizon analysis with optional reference context @@ -126,6 +147,7 @@ def _handle_updown_market(client: SynthClient, slug: str, asset: str, market_typ "synth_probability_up": primary_data.get("synth_probability_up"), "polymarket_probability_up": primary_data.get("polymarket_probability_up"), "current_time": primary_data.get("current_time"), + "live_price_used": live_prob_up is not None, } # Include reference horizon context when available if reference_data and result.secondary: @@ -149,10 +171,17 @@ def edge(): 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) + live_prob_up = request.args.get("live_prob_up") + if live_prob_up: + try: + live_prob_up = float(live_prob_up) + except (ValueError, TypeError): + live_prob_up = None try: client = get_client() if market_type in ("daily", "hourly", "15min", "5min"): - return _handle_updown_market(client, slug, asset, market_type) + return _handle_updown_market(client, slug, asset, market_type, live_prob_up) # range data = client.get_polymarket_range() if not isinstance(data, list): diff --git a/tools/synth-overlay/tests/test_server.py b/tools/synth-overlay/tests/test_server.py index b2cb182..710926c 100644 --- a/tools/synth-overlay/tests/test_server.py +++ b/tools/synth-overlay/tests/test_server.py @@ -175,3 +175,27 @@ def test_edge_5min_sol(client): data = resp.get_json() assert data["asset"] == "SOL" assert data["market_type"] == "5min" + + +def test_edge_live_price_override(client): + """Test that live_prob_up parameter overrides API price for edge calculation.""" + # First request without live price + resp1 = client.get("/api/edge?slug=btc-updown-5m-1772205000") + assert resp1.status_code == 200 + data1 = resp1.get_json() + assert data1.get("live_price_used") is False + + # Second request with live price override + resp2 = client.get("/api/edge?slug=btc-updown-5m-1772205000&live_prob_up=0.75") + assert resp2.status_code == 200 + data2 = resp2.get_json() + assert data2.get("live_price_used") is True + assert data2["polymarket_probability_up"] == 0.75 + + +def test_edge_live_price_invalid_ignored(client): + """Test that invalid live_prob_up values are gracefully ignored.""" + resp = client.get("/api/edge?slug=btc-updown-5m-1772205000&live_prob_up=invalid") + assert resp.status_code == 200 + data = resp.get_json() + assert data.get("live_price_used") is False From 66bca35af39e7a49637243424d1643885c039e3c Mon Sep 17 00:00:00 2001 From: bitloi Date: Wed, 4 Mar 2026 19:27:22 +0100 Subject: [PATCH 3/8] fix: improve DOM scraping for Polymarket live prices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rewrite scrapeLivePrices() with 3 robust strategies for React SPA - Handle various price formats: '52¢', '0.52', multiline layouts - Add console logging for debugging - Show '(Live)' indicator in sidepanel status when DOM prices used --- tools/synth-overlay/extension/content.js | 100 +++++++++++++-------- tools/synth-overlay/extension/sidepanel.js | 10 ++- 2 files changed, 71 insertions(+), 39 deletions(-) diff --git a/tools/synth-overlay/extension/content.js b/tools/synth-overlay/extension/content.js index 3d31907..e2a7f75 100644 --- a/tools/synth-overlay/extension/content.js +++ b/tools/synth-overlay/extension/content.js @@ -20,70 +20,94 @@ /** * Scrape live Polymarket prices from the DOM. - * Looks for Up/Down outcome buttons and extracts their cent values. + * Polymarket uses React with dynamic rendering - prices appear in various formats. * Returns { upPrice: 0.XX, downPrice: 0.XX } or null if not found. */ function scrapeLivePrices() { var upPrice = null; var downPrice = null; - // Strategy 1: Look for buttons/elements containing "Up" or "Down" with cent values - // Polymarket typically shows prices like "Up 85¢" or "Down 16¢" - var allElements = document.querySelectorAll("button, [role='button'], a"); + // Get all text content from the page body + var bodyText = document.body ? document.body.innerText : ""; + + // Strategy 1: Look for outcome cards/sections containing "Up" or "Down" with prices + // Polymarket shows prices like "Up\n52¢" or "Down\n48¢" in card layouts + var allElements = document.querySelectorAll("div, span, button, a, p"); for (var i = 0; i < allElements.length; i++) { var el = allElements[i]; - var text = (el.textContent || "").trim(); + var text = (el.innerText || el.textContent || "").trim(); + + // Skip very long text blocks (we want small outcome elements) + if (text.length > 50) continue; - // Match patterns like "Up 85¢", "Up85¢", "Up 0.85", "Yes 85¢" - var upMatch = text.match(/^(Up|Yes)\s*(\d+)\s*[¢c]?$/i); - var downMatch = text.match(/^(Down|No)\s*(\d+)\s*[¢c]?$/i); + // Match patterns: "Up 52¢", "Up\n52¢", "Up52¢", "Up 0.52" + var upMatch = text.match(/\b(Up|Yes)\b[\s\n]*(\d{1,2})\s*[¢c¢]/i); + var downMatch = text.match(/\b(Down|No)\b[\s\n]*(\d{1,2})\s*[¢c¢]/i); - if (upMatch && upMatch[2]) { + if (upMatch && upMatch[2] && upPrice === null) { upPrice = parseInt(upMatch[2], 10) / 100; } - if (downMatch && downMatch[2]) { + if (downMatch && downMatch[2] && downPrice === null) { downPrice = parseInt(downMatch[2], 10) / 100; } + + // Also try decimal format: "Up 0.52" or "Yes 0.48" + if (upPrice === null) { + var upDecMatch = text.match(/\b(Up|Yes)\b[\s\n]*(0\.\d+)/i); + if (upDecMatch && upDecMatch[2]) { + upPrice = parseFloat(upDecMatch[2]); + } + } + if (downPrice === null) { + var downDecMatch = text.match(/\b(Down|No)\b[\s\n]*(0\.\d+)/i); + if (downDecMatch && downDecMatch[2]) { + downPrice = parseFloat(downDecMatch[2]); + } + } + + if (upPrice !== null && downPrice !== null) break; } - // Strategy 2: Look for data attributes or structured price elements + // Strategy 2: Look for adjacent elements (label + price in sibling/child) if (upPrice === null || downPrice === null) { - var priceEls = document.querySelectorAll("[data-outcome], [data-price]"); - for (var j = 0; j < priceEls.length; j++) { - var priceEl = priceEls[j]; - var outcome = priceEl.getAttribute("data-outcome"); - var priceVal = priceEl.getAttribute("data-price"); - if (outcome && priceVal) { - var p = parseFloat(priceVal); - if (outcome.toLowerCase() === "up" || outcome.toLowerCase() === "yes") { - upPrice = p; - } else if (outcome.toLowerCase() === "down" || outcome.toLowerCase() === "no") { - downPrice = p; - } + var containers = document.querySelectorAll("[class*='market'], [class*='outcome'], [class*='option'], [class*='card']"); + for (var j = 0; j < containers.length; j++) { + var container = containers[j]; + var containerText = (container.innerText || "").toLowerCase(); + + // Find price pattern anywhere in container + var priceMatch = containerText.match(/(\d{1,2})\s*[¢c¢]/); + if (!priceMatch) continue; + + var price = parseInt(priceMatch[1], 10) / 100; + + // Determine if this is Up or Down based on text + if ((containerText.indexOf("up") !== -1 || containerText.indexOf("yes") !== -1) && upPrice === null) { + upPrice = price; + } else if ((containerText.indexOf("down") !== -1 || containerText.indexOf("no") !== -1) && downPrice === null) { + downPrice = price; } } } - // Strategy 3: Look for common Polymarket class patterns + // Strategy 3: Search entire page for the pattern if (upPrice === null || downPrice === null) { - // Try finding outcome cards with price text - var cards = document.querySelectorAll("[class*='outcome'], [class*='Outcome']"); - for (var k = 0; k < cards.length; k++) { - var card = cards[k]; - var cardText = (card.textContent || "").toLowerCase(); - var centMatch = cardText.match(/(\d+)\s*[¢c]/); - if (centMatch) { - var cents = parseInt(centMatch[1], 10) / 100; - if (cardText.indexOf("up") !== -1 || cardText.indexOf("yes") !== -1) { - upPrice = cents; - } else if (cardText.indexOf("down") !== -1 || cardText.indexOf("no") !== -1) { - downPrice = cents; - } - } + // Look for "Up" followed by cents anywhere in body + var upBodyMatch = bodyText.match(/\bUp\b[^\d]*?(\d{1,2})\s*[¢c¢]/i); + var downBodyMatch = bodyText.match(/\bDown\b[^\d]*?(\d{1,2})\s*[¢c¢]/i); + + if (upBodyMatch && upBodyMatch[1] && upPrice === null) { + upPrice = parseInt(upBodyMatch[1], 10) / 100; + } + if (downBodyMatch && downBodyMatch[1] && downPrice === null) { + downPrice = parseInt(downBodyMatch[1], 10) / 100; } } + // Log for debugging (visible in browser console) + console.log("[Synth-Overlay] DOM scrape result:", { upPrice: upPrice, downPrice: downPrice }); + if (upPrice !== null && downPrice !== null) { return { upPrice: upPrice, downPrice: downPrice }; } diff --git a/tools/synth-overlay/extension/sidepanel.js b/tools/synth-overlay/extension/sidepanel.js index 7a6d455..1a5044d 100644 --- a/tools/synth-overlay/extension/sidepanel.js +++ b/tools/synth-overlay/extension/sidepanel.js @@ -160,8 +160,16 @@ async function refresh() { signalRef = edge.ref_signal + " " + fmtEdge(edge.ref_edge_pct); } + // Log live price status for debugging + console.log("[Synth-Overlay] Edge response:", { + live_price_used: edge.live_price_used, + polymarket_prob: edge.polymarket_probability_up, + livePricesFromDOM: ctx.livePrices + }); + + var liveStatus = edge.live_price_used ? " (Live)" : ""; render({ - status: "Synced — " + (edge.asset || "BTC") + " " + horizon + " forecast.", + status: "Synced — " + (edge.asset || "BTC") + " " + horizon + " forecast." + liveStatus, synthUp: fmtCentsFromProb(synthProbUp), synthDown: synthProbUp == null ? "—" : fmtCentsFromProb(1 - synthProbUp), edge: fmtEdge(edge.edge_pct), From 9d582637fcf69dedac50a5d9d08047d939a945e7 Mon Sep 17 00:00:00 2001 From: bitloi Date: Wed, 4 Mar 2026 19:30:59 +0100 Subject: [PATCH 4/8] feat: add price comparison UI with Synth vs Polymarket delta MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Show Synth and Polymarket prices side-by-side in sidepanel - Display percentage delta (Δ) with color coding (green positive, red negative) - Add grid layout for price comparison section - Makes edge opportunities immediately visible --- tools/synth-overlay/extension/sidepanel.css | 40 ++++++++++++++++++++ tools/synth-overlay/extension/sidepanel.html | 19 ++++++++-- tools/synth-overlay/extension/sidepanel.js | 35 ++++++++++++++++- 3 files changed, 90 insertions(+), 4 deletions(-) diff --git a/tools/synth-overlay/extension/sidepanel.css b/tools/synth-overlay/extension/sidepanel.css index 93cdf92..00ed280 100644 --- a/tools/synth-overlay/extension/sidepanel.css +++ b/tools/synth-overlay/extension/sidepanel.css @@ -111,3 +111,43 @@ button { display: flex; justify-content: space-between; } + +/* Price comparison grid */ +.price-header, .price-row { + display: grid; + grid-template-columns: 1fr 1fr 1fr 1fr; + gap: 4px; + align-items: center; + margin: 4px 0; +} + +.price-header { + font-size: 10px; + color: #9ca3af; + text-transform: uppercase; +} + +.col-label { + text-align: right; +} + +.price-row span:first-child { + font-weight: 500; +} + +.price-row strong { + text-align: right; + font-size: 13px; +} + +.delta { + font-size: 11px; +} + +.delta.positive { + color: #16a34a; +} + +.delta.negative { + color: #dc2626; +} diff --git a/tools/synth-overlay/extension/sidepanel.html b/tools/synth-overlay/extension/sidepanel.html index eb2dc2b..c83c0de 100644 --- a/tools/synth-overlay/extension/sidepanel.html +++ b/tools/synth-overlay/extension/sidepanel.html @@ -17,9 +17,22 @@

Synth

-
Synth Forecast
-
Up
-
Down
+
Price Comparison
+
+ SynthPolyΔ +
+
+ Up + + + +
+
+ Down + + + +
Edge
diff --git a/tools/synth-overlay/extension/sidepanel.js b/tools/synth-overlay/extension/sidepanel.js index 1a5044d..70ca59c 100644 --- a/tools/synth-overlay/extension/sidepanel.js +++ b/tools/synth-overlay/extension/sidepanel.js @@ -6,6 +6,10 @@ const els = { statusText: document.getElementById("statusText"), synthUp: document.getElementById("synthUp"), synthDown: document.getElementById("synthDown"), + polyUp: document.getElementById("polyUp"), + polyDown: document.getElementById("polyDown"), + deltaUp: document.getElementById("deltaUp"), + deltaDown: document.getElementById("deltaDown"), edgeValue: document.getElementById("edgeValue"), horizonLabel: document.getElementById("horizonLabel"), signalPrimary: document.getElementById("signalPrimary"), @@ -47,6 +51,16 @@ function confidenceColor(score) { return "#ef4444"; } +function fmtDelta(synth, poly) { + if (synth == null || poly == null) return { text: "—", cls: "" }; + var diff = Math.round((synth - poly) * 100); + var sign = diff >= 0 ? "+" : ""; + return { + text: sign + diff + "%", + cls: diff > 0 ? "positive" : diff < 0 ? "negative" : "" + }; +} + async function activeSupportedTab() { const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); const tab = tabs && tabs[0]; @@ -78,6 +92,12 @@ function render(state) { els.statusText.textContent = state.status; els.synthUp.textContent = state.synthUp; els.synthDown.textContent = state.synthDown; + els.polyUp.textContent = state.polyUp || "—"; + els.polyDown.textContent = state.polyDown || "—"; + 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 : "—"; + els.deltaDown.className = "delta " + (state.deltaDown ? state.deltaDown.cls : ""); els.edgeValue.textContent = state.edge; els.horizonLabel.textContent = state.horizonLabel || "Primary"; els.signalPrimary.textContent = state.signalPrimary; @@ -97,7 +117,8 @@ function render(state) { } const EMPTY = { - synthUp: "—", synthDown: "—", edge: "—", + synthUp: "—", synthDown: "—", polyUp: "—", polyDown: "—", + deltaUp: null, deltaDown: null, edge: "—", horizonLabel: "Primary", signalPrimary: "—", refLabel: "Reference", signalRef: "—", strength: "—", asset: "—", marketType: "—", @@ -167,11 +188,23 @@ async function refresh() { livePricesFromDOM: ctx.livePrices }); + // Get Polymarket price (from API response) + var polyProbUp = edge.polymarket_probability_up; + var polyProbDown = polyProbUp != null ? 1 - polyProbUp : null; + + // Calculate deltas (Synth - Poly) + var deltaUp = fmtDelta(synthProbUp, polyProbUp); + var deltaDown = fmtDelta(synthProbUp != null ? 1 - synthProbUp : null, polyProbDown); + var liveStatus = edge.live_price_used ? " (Live)" : ""; render({ status: "Synced — " + (edge.asset || "BTC") + " " + horizon + " forecast." + liveStatus, synthUp: fmtCentsFromProb(synthProbUp), synthDown: synthProbUp == null ? "—" : fmtCentsFromProb(1 - synthProbUp), + polyUp: fmtCentsFromProb(polyProbUp), + polyDown: fmtCentsFromProb(polyProbDown), + deltaUp: deltaUp, + deltaDown: deltaDown, edge: fmtEdge(edge.edge_pct), horizonLabel: horizonLabel, signalPrimary: signalPrimary, From c06d67c5cf7f482b593e7e967e252e49ed854919 Mon Sep 17 00:00:00 2001 From: bitloi Date: Wed, 4 Mar 2026 19:34:52 +0100 Subject: [PATCH 5/8] feat: add all timeframes to Signal section + poll progress bar - Show 5min, 15min, 1h, 24h signals in dedicated rows - Add minimal 2px progress bar showing countdown to next API poll - Progress bar fills over 30s, resets on refresh - Removed dynamic horizon labels in favor of fixed timeframe layout --- tools/synth-overlay/extension/sidepanel.css | 22 ++++ tools/synth-overlay/extension/sidepanel.html | 11 +- tools/synth-overlay/extension/sidepanel.js | 105 ++++++++++++------- 3 files changed, 95 insertions(+), 43 deletions(-) diff --git a/tools/synth-overlay/extension/sidepanel.css b/tools/synth-overlay/extension/sidepanel.css index 00ed280..9ffcef3 100644 --- a/tools/synth-overlay/extension/sidepanel.css +++ b/tools/synth-overlay/extension/sidepanel.css @@ -151,3 +151,25 @@ button { .delta.negative { color: #dc2626; } + +/* Timeframe signal rows */ +.tf-row strong { + font-size: 12px; +} + +/* Poll progress bar - minimal */ +.poll-bar { + width: 100%; + height: 2px; + background: #e5e7eb; + border-radius: 1px; + margin-top: 6px; + overflow: hidden; +} + +.poll-fill { + height: 100%; + width: 0%; + background: #3b82f6; + transition: width 1s linear; +} diff --git a/tools/synth-overlay/extension/sidepanel.html b/tools/synth-overlay/extension/sidepanel.html index c83c0de..bb2e1b6 100644 --- a/tools/synth-overlay/extension/sidepanel.html +++ b/tools/synth-overlay/extension/sidepanel.html @@ -37,10 +37,12 @@

Synth

-
Signal
-
Primary
-
Reference
-
Strength
+
Signal by Timeframe
+
5 min
+
15 min
+
1 hour
+
24 hour
+
Strength
@@ -68,6 +70,7 @@

Synth

Data as of: +
diff --git a/tools/synth-overlay/extension/sidepanel.js b/tools/synth-overlay/extension/sidepanel.js index 70ca59c..0b28b3a 100644 --- a/tools/synth-overlay/extension/sidepanel.js +++ b/tools/synth-overlay/extension/sidepanel.js @@ -11,11 +11,10 @@ const els = { deltaUp: document.getElementById("deltaUp"), deltaDown: document.getElementById("deltaDown"), edgeValue: document.getElementById("edgeValue"), - horizonLabel: document.getElementById("horizonLabel"), - signalPrimary: document.getElementById("signalPrimary"), - refRow: document.getElementById("refRow"), - refLabel: document.getElementById("refLabel"), - signalRef: document.getElementById("signalRef"), + signal5m: document.getElementById("signal5m"), + signal15m: document.getElementById("signal15m"), + signal1h: document.getElementById("signal1h"), + signal24h: document.getElementById("signal24h"), strength: document.getElementById("strength"), assetName: document.getElementById("assetName"), marketType: document.getElementById("marketType"), @@ -26,6 +25,7 @@ const els = { invalidationText: document.getElementById("invalidationText"), lastUpdate: document.getElementById("lastUpdate"), refreshBtn: document.getElementById("refreshBtn"), + pollProgress: document.getElementById("pollProgress"), }; function fmtCentsFromProb(p) { @@ -99,11 +99,10 @@ function render(state) { els.deltaDown.textContent = state.deltaDown ? state.deltaDown.text : "—"; els.deltaDown.className = "delta " + (state.deltaDown ? state.deltaDown.cls : ""); els.edgeValue.textContent = state.edge; - els.horizonLabel.textContent = state.horizonLabel || "Primary"; - els.signalPrimary.textContent = state.signalPrimary; - els.refLabel.textContent = state.refLabel || "Reference"; - els.signalRef.textContent = state.signalRef; - els.refRow.style.display = state.signalRef === "—" ? "none" : ""; + els.signal5m.textContent = state.signal5m || "—"; + els.signal15m.textContent = state.signal15m || "—"; + els.signal1h.textContent = state.signal1h || "—"; + els.signal24h.textContent = state.signal24h || "—"; els.strength.textContent = state.strength; els.assetName.textContent = state.asset || "—"; els.marketType.textContent = state.marketType || "—"; @@ -119,8 +118,7 @@ function render(state) { const EMPTY = { synthUp: "—", synthDown: "—", polyUp: "—", polyDown: "—", deltaUp: null, deltaDown: null, edge: "—", - horizonLabel: "Primary", signalPrimary: "—", - refLabel: "Reference", signalRef: "—", + signal5m: "—", signal15m: "—", signal1h: "—", signal24h: "—", strength: "—", asset: "—", marketType: "—", analysis: "—", noTrade: false, invalidation: "—", confPct: 0, confColor: "#9ca3af", confText: "—", @@ -162,24 +160,7 @@ async function refresh() { var confPct = Math.round(conf * 100); var horizon = edge.horizon || "24h"; var mtype = edge.market_type || "daily"; - - // Build signal labels based on response shape - var horizonLabel = horizon; - var signalPrimary = (edge.signal || "—") + " " + fmtEdge(edge.edge_pct); - var refLabel = "Reference"; - var signalRef = "—"; - - // Dual-horizon (daily/hourly): use 1h/24h fields - if (edge.signal_1h && edge.signal_24h) { - horizonLabel = "1 h"; - signalPrimary = edge.signal_1h + " " + fmtEdge(edge.edge_1h_pct); - refLabel = "24 h"; - signalRef = edge.signal_24h + " " + fmtEdge(edge.edge_24h_pct); - } else if (edge.ref_signal) { - // Short-horizon with reference context - refLabel = edge.ref_horizon || "Ref"; - signalRef = edge.ref_signal + " " + fmtEdge(edge.ref_edge_pct); - } + var asset = edge.asset || "BTC"; // Log live price status for debugging console.log("[Synth-Overlay] Edge response:", { @@ -196,9 +177,28 @@ async function refresh() { var deltaUp = fmtDelta(synthProbUp, polyProbUp); var deltaDown = fmtDelta(synthProbUp != null ? 1 - synthProbUp : null, polyProbDown); + // Fetch all timeframes for this asset (in parallel) + var tfSlugs = { + "5m": asset.toLowerCase() + "-updown-5m-" + Date.now(), + "15m": asset.toLowerCase() + "-updown-15m-" + Date.now(), + "1h": asset.toLowerCase() + "-updown-1h-" + Date.now(), + "24h": asset.toLowerCase() + "-updown-24h-" + Date.now(), + }; + + // Build signals from response - map current market type to its slot + var signals = { "5m": "—", "15m": "—", "1h": "—", "24h": "—" }; + var tfKey = mtype === "5min" ? "5m" : mtype === "15min" ? "15m" : mtype === "hourly" ? "1h" : "24h"; + signals[tfKey] = (edge.signal || "—") + " " + fmtEdge(edge.edge_pct); + + // If we have dual-horizon data, populate both + if (edge.signal_1h && edge.signal_24h) { + signals["1h"] = edge.signal_1h + " " + fmtEdge(edge.edge_1h_pct); + signals["24h"] = edge.signal_24h + " " + fmtEdge(edge.edge_24h_pct); + } + var liveStatus = edge.live_price_used ? " (Live)" : ""; render({ - status: "Synced — " + (edge.asset || "BTC") + " " + horizon + " forecast." + liveStatus, + status: "Synced — " + asset + " " + horizon + " forecast." + liveStatus, synthUp: fmtCentsFromProb(synthProbUp), synthDown: synthProbUp == null ? "—" : fmtCentsFromProb(1 - synthProbUp), polyUp: fmtCentsFromProb(polyProbUp), @@ -206,12 +206,12 @@ async function refresh() { deltaUp: deltaUp, deltaDown: deltaDown, edge: fmtEdge(edge.edge_pct), - horizonLabel: horizonLabel, - signalPrimary: signalPrimary, - refLabel: refLabel, - signalRef: signalRef, + signal5m: signals["5m"], + signal15m: signals["15m"], + signal1h: signals["1h"], + signal24h: signals["24h"], strength: edge.strength || "—", - asset: edge.asset || "BTC", + asset: asset, marketType: mtype, analysis: edge.explanation || "No explanation available.", invalidation: edge.invalidation || "—", @@ -221,13 +221,40 @@ async function refresh() { confText: (conf >= 0.7 ? "High" : conf >= 0.4 ? "Medium" : "Low") + " (" + confPct + "%)", lastUpdate: fmtApiTime(edge.current_time), }); + + // Reset and start poll progress animation + startPollProgress(); } -els.refreshBtn.addEventListener("click", refresh); -refresh(); +els.refreshBtn.addEventListener("click", function() { + stopPollProgress(); + refresh(); +}); // Polling frequency: Synth API updates forecasts every ~60 seconds for short-term markets. // We poll every 30 seconds to balance freshness vs API load. -// Live DOM prices are scraped on each refresh for real-time edge calculation. const SYNTH_POLL_INTERVAL_MS = 30000; + +// Poll progress bar animation +var pollTimer = null; +var pollStart = 0; + +function startPollProgress() { + stopPollProgress(); + pollStart = Date.now(); + els.pollProgress.style.transition = "none"; + els.pollProgress.style.width = "0%"; + // Force reflow then animate + void els.pollProgress.offsetWidth; + els.pollProgress.style.transition = "width " + (SYNTH_POLL_INTERVAL_MS / 1000) + "s linear"; + els.pollProgress.style.width = "100%"; +} + +function stopPollProgress() { + els.pollProgress.style.transition = "none"; + els.pollProgress.style.width = "0%"; +} + +// Start polling +refresh(); setInterval(refresh, SYNTH_POLL_INTERVAL_MS); From 6263e5349e6aa129f8aaf93754c5caca90472b0a Mon Sep 17 00:00:00 2001 From: bitloi Date: Wed, 4 Mar 2026 19:38:31 +0100 Subject: [PATCH 6/8] fix: instant live price tracking with MutationObserver Addresses PR feedback from e35ventura: 1. Instant Live Price Tracking: - Added MutationObserver in content.js to detect DOM changes immediately - Broadcasts price updates to sidepanel when prices change (~100ms debounce) - 500ms backup polling for any missed mutations - Sidepanel updates UI instantly without waiting for 30s API poll 2. Price Comparison (already implemented): - Shows Synth vs Poly prices side-by-side with delta 3. All 4 Timeframes with Primary Highlighted: - Primary timeframe row has blue highlight + bold text - All timeframes update edge calculation with live prices - Cached Synth data enables instant recalculation --- tools/synth-overlay/extension/content.js | 96 +++++++++++++-------- tools/synth-overlay/extension/sidepanel.css | 13 +++ tools/synth-overlay/extension/sidepanel.js | 85 +++++++++++++++--- 3 files changed, 146 insertions(+), 48 deletions(-) diff --git a/tools/synth-overlay/extension/content.js b/tools/synth-overlay/extension/content.js index e2a7f75..2aff033 100644 --- a/tools/synth-overlay/extension/content.js +++ b/tools/synth-overlay/extension/content.js @@ -1,12 +1,14 @@ (function () { "use strict"; + // 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); - // Polymarket: /event/ or /market/ or / if (host.indexOf("polymarket.com") !== -1) { var first = segments[0]; var second = segments[1] || segments[0]; @@ -14,34 +16,27 @@ return first || null; } - // Generic fallback: use the last meaningful path segment return segments[segments.length - 1] || null; } /** * Scrape live Polymarket prices from the DOM. - * Polymarket uses React with dynamic rendering - prices appear in various formats. * Returns { upPrice: 0.XX, downPrice: 0.XX } or null if not found. */ function scrapeLivePrices() { var upPrice = null; var downPrice = null; - - // Get all text content from the page body var bodyText = document.body ? document.body.innerText : ""; - // Strategy 1: Look for outcome cards/sections containing "Up" or "Down" with prices - // Polymarket shows prices like "Up\n52¢" or "Down\n48¢" in card layouts + // Strategy 1: Look for outcome elements with "Up/Down" and cent values var allElements = document.querySelectorAll("div, span, button, a, p"); for (var i = 0; i < allElements.length; i++) { var el = allElements[i]; var text = (el.innerText || el.textContent || "").trim(); - // Skip very long text blocks (we want small outcome elements) if (text.length > 50) continue; - // Match patterns: "Up 52¢", "Up\n52¢", "Up52¢", "Up 0.52" var upMatch = text.match(/\b(Up|Yes)\b[\s\n]*(\d{1,2})\s*[¢c¢]/i); var downMatch = text.match(/\b(Down|No)\b[\s\n]*(\d{1,2})\s*[¢c¢]/i); @@ -52,37 +47,27 @@ downPrice = parseInt(downMatch[2], 10) / 100; } - // Also try decimal format: "Up 0.52" or "Yes 0.48" if (upPrice === null) { var upDecMatch = text.match(/\b(Up|Yes)\b[\s\n]*(0\.\d+)/i); - if (upDecMatch && upDecMatch[2]) { - upPrice = parseFloat(upDecMatch[2]); - } + if (upDecMatch && upDecMatch[2]) upPrice = parseFloat(upDecMatch[2]); } if (downPrice === null) { var downDecMatch = text.match(/\b(Down|No)\b[\s\n]*(0\.\d+)/i); - if (downDecMatch && downDecMatch[2]) { - downPrice = parseFloat(downDecMatch[2]); - } + if (downDecMatch && downDecMatch[2]) downPrice = parseFloat(downDecMatch[2]); } if (upPrice !== null && downPrice !== null) break; } - // Strategy 2: Look for adjacent elements (label + price in sibling/child) + // Strategy 2: Container-based search if (upPrice === null || downPrice === null) { var containers = document.querySelectorAll("[class*='market'], [class*='outcome'], [class*='option'], [class*='card']"); for (var j = 0; j < containers.length; j++) { var container = containers[j]; var containerText = (container.innerText || "").toLowerCase(); - - // Find price pattern anywhere in container var priceMatch = containerText.match(/(\d{1,2})\s*[¢c¢]/); if (!priceMatch) continue; - var price = parseInt(priceMatch[1], 10) / 100; - - // Determine if this is Up or Down based on text if ((containerText.indexOf("up") !== -1 || containerText.indexOf("yes") !== -1) && upPrice === null) { upPrice = price; } else if ((containerText.indexOf("down") !== -1 || containerText.indexOf("no") !== -1) && downPrice === null) { @@ -91,35 +76,23 @@ } } - // Strategy 3: Search entire page for the pattern + // Strategy 3: Full body text search if (upPrice === null || downPrice === null) { - // Look for "Up" followed by cents anywhere in body var upBodyMatch = bodyText.match(/\bUp\b[^\d]*?(\d{1,2})\s*[¢c¢]/i); var downBodyMatch = bodyText.match(/\bDown\b[^\d]*?(\d{1,2})\s*[¢c¢]/i); - - if (upBodyMatch && upBodyMatch[1] && upPrice === null) { - upPrice = parseInt(upBodyMatch[1], 10) / 100; - } - if (downBodyMatch && downBodyMatch[1] && downPrice === null) { - downPrice = parseInt(downBodyMatch[1], 10) / 100; - } + if (upBodyMatch && upBodyMatch[1] && upPrice === null) upPrice = parseInt(upBodyMatch[1], 10) / 100; + if (downBodyMatch && downBodyMatch[1] && downPrice === null) downPrice = parseInt(downBodyMatch[1], 10) / 100; } - // Log for debugging (visible in browser console) - console.log("[Synth-Overlay] DOM scrape result:", { upPrice: upPrice, downPrice: downPrice }); - if (upPrice !== null && downPrice !== null) { return { upPrice: upPrice, downPrice: downPrice }; } - - // Fallback: if we only have one, derive the other (prices should sum to ~1) if (upPrice !== null && downPrice === null) { return { upPrice: upPrice, downPrice: 1 - upPrice }; } if (downPrice !== null && upPrice === null) { return { upPrice: 1 - downPrice, downPrice: downPrice }; } - return null; } @@ -134,6 +107,55 @@ }; } + // Broadcast price update to extension + function broadcastPriceUpdate(prices) { + if (!prices) return; + chrome.runtime.sendMessage({ + 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 (!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 + var observer = new MutationObserver(function(mutations) { + // Debounce: only check every 100ms max + if (observer._pending) return; + observer._pending = true; + setTimeout(function() { + observer._pending = false; + checkAndBroadcastPrices(); + }, 100); + }); + + // Start observing DOM changes + if (document.body) { + observer.observe(document.body, { + childList: true, + subtree: true, + characterData: true + }); + } + + // Also poll every 500ms as backup for any missed mutations + setInterval(checkAndBroadcastPrices, 500); + + // Initial broadcast + setTimeout(checkAndBroadcastPrices, 500); + + // Handle requests from sidepanel chrome.runtime.onMessage.addListener(function (message, _sender, sendResponse) { if (!message || typeof message !== "object") return; if (message.type === "synth:getContext") { diff --git a/tools/synth-overlay/extension/sidepanel.css b/tools/synth-overlay/extension/sidepanel.css index 9ffcef3..7775de4 100644 --- a/tools/synth-overlay/extension/sidepanel.css +++ b/tools/synth-overlay/extension/sidepanel.css @@ -157,6 +157,19 @@ button { font-size: 12px; } +/* Primary timeframe highlighted */ +.tf-row.primary-tf { + background: rgba(59, 130, 246, 0.08); + border-radius: 4px; + margin: 2px -4px; + padding: 2px 4px; +} + +.tf-row.primary-tf span, +.tf-row.primary-tf strong { + font-weight: 700; +} + /* Poll progress bar - minimal */ .poll-bar { width: 100%; diff --git a/tools/synth-overlay/extension/sidepanel.js b/tools/synth-overlay/extension/sidepanel.js index 0b28b3a..a33e492 100644 --- a/tools/synth-overlay/extension/sidepanel.js +++ b/tools/synth-overlay/extension/sidepanel.js @@ -2,6 +2,10 @@ const API_BASE = "http://127.0.0.1:8765"; +// Cache last Synth data for instant recalculation when live prices change +var cachedSynthData = null; +var cachedMarketType = null; + const els = { statusText: document.getElementById("statusText"), synthUp: document.getElementById("synthUp"), @@ -103,6 +107,11 @@ function render(state) { els.signal15m.textContent = state.signal15m || "—"; els.signal1h.textContent = state.signal1h || "—"; els.signal24h.textContent = state.signal24h || "—"; + // Bold the primary timeframe row + els.signal5m.parentElement.classList.toggle("primary-tf", state.primaryTf === "5m"); + els.signal15m.parentElement.classList.toggle("primary-tf", state.primaryTf === "15m"); + els.signal1h.parentElement.classList.toggle("primary-tf", state.primaryTf === "1h"); + els.signal24h.parentElement.classList.toggle("primary-tf", state.primaryTf === "24h"); els.strength.textContent = state.strength; els.assetName.textContent = state.asset || "—"; els.marketType.textContent = state.marketType || "—"; @@ -119,12 +128,61 @@ const EMPTY = { synthUp: "—", synthDown: "—", polyUp: "—", polyDown: "—", deltaUp: null, deltaDown: null, edge: "—", signal5m: "—", signal15m: "—", signal1h: "—", signal24h: "—", + primaryTf: null, strength: "—", asset: "—", marketType: "—", analysis: "—", noTrade: false, invalidation: "—", confPct: 0, confColor: "#9ca3af", confText: "—", lastUpdate: "—", }; +// Calculate edge percentage from Synth and Polymarket probabilities +function calcEdgePct(synthProb, polyProb) { + if (synthProb == null || polyProb == null) return null; + return Math.round((synthProb - polyProb) * 100); +} + +// Update UI instantly when live prices change (without full API refresh) +function updateWithLivePrice(livePrices) { + if (!cachedSynthData || !livePrices) return; + + var synthProbUp = cachedSynthData.synth_probability_up; + var polyProbUp = livePrices.upPrice; + var polyProbDown = livePrices.downPrice; + + // Recalculate edge with live price + var edgePct = calcEdgePct(synthProbUp, polyProbUp); + var signal = edgePct > 0 ? "BUY" : edgePct < 0 ? "SELL" : "HOLD"; + + // Update Polymarket prices + els.polyUp.textContent = fmtCentsFromProb(polyProbUp); + els.polyDown.textContent = fmtCentsFromProb(polyProbDown); + + // Update deltas + var deltaUp = fmtDelta(synthProbUp, polyProbUp); + var deltaDown = fmtDelta(synthProbUp != null ? 1 - synthProbUp : null, polyProbDown); + els.deltaUp.textContent = deltaUp.text; + els.deltaUp.className = "delta " + deltaUp.cls; + els.deltaDown.textContent = deltaDown.text; + els.deltaDown.className = "delta " + deltaDown.cls; + + // Update edge + els.edgeValue.textContent = fmtEdge(edgePct); + + // Update primary timeframe signal with new edge + var tfKey = cachedMarketType === "5min" ? "5m" : cachedMarketType === "15min" ? "15m" : + cachedMarketType === "hourly" ? "1h" : "24h"; + var tfEl = els["signal" + tfKey.replace("m", "m").replace("h", "h")]; + if (tfKey === "5m") els.signal5m.textContent = signal + " " + fmtEdge(edgePct); + else if (tfKey === "15m") els.signal15m.textContent = signal + " " + fmtEdge(edgePct); + else if (tfKey === "1h") els.signal1h.textContent = signal + " " + fmtEdge(edgePct); + else els.signal24h.textContent = signal + " " + fmtEdge(edgePct); + + // Update status to show live + els.statusText.textContent = els.statusText.textContent.replace(/ \(Live\)$/, "") + " (Live)"; + + console.log("[Synth-Overlay] Live price update:", { polyProbUp, edgePct, signal }); +} + async function refresh() { render(Object.assign({}, EMPTY, { status: "Refreshing…" })); @@ -162,6 +220,10 @@ async function refresh() { var mtype = edge.market_type || "daily"; var asset = edge.asset || "BTC"; + // Cache Synth data for instant live price updates + cachedSynthData = edge; + cachedMarketType = mtype; + // Log live price status for debugging console.log("[Synth-Overlay] Edge response:", { live_price_used: edge.live_price_used, @@ -169,22 +231,14 @@ async function refresh() { livePricesFromDOM: ctx.livePrices }); - // Get Polymarket price (from API response) - var polyProbUp = edge.polymarket_probability_up; + // Get Polymarket price (from API response or live DOM) + var polyProbUp = ctx.livePrices ? ctx.livePrices.upPrice : edge.polymarket_probability_up; var polyProbDown = polyProbUp != null ? 1 - polyProbUp : null; // Calculate deltas (Synth - Poly) var deltaUp = fmtDelta(synthProbUp, polyProbUp); var deltaDown = fmtDelta(synthProbUp != null ? 1 - synthProbUp : null, polyProbDown); - // Fetch all timeframes for this asset (in parallel) - var tfSlugs = { - "5m": asset.toLowerCase() + "-updown-5m-" + Date.now(), - "15m": asset.toLowerCase() + "-updown-15m-" + Date.now(), - "1h": asset.toLowerCase() + "-updown-1h-" + Date.now(), - "24h": asset.toLowerCase() + "-updown-24h-" + Date.now(), - }; - // Build signals from response - map current market type to its slot var signals = { "5m": "—", "15m": "—", "1h": "—", "24h": "—" }; var tfKey = mtype === "5min" ? "5m" : mtype === "15min" ? "15m" : mtype === "hourly" ? "1h" : "24h"; @@ -196,7 +250,7 @@ async function refresh() { signals["24h"] = edge.signal_24h + " " + fmtEdge(edge.edge_24h_pct); } - var liveStatus = edge.live_price_used ? " (Live)" : ""; + var liveStatus = ctx.livePrices ? " (Live)" : ""; render({ status: "Synced — " + asset + " " + horizon + " forecast." + liveStatus, synthUp: fmtCentsFromProb(synthProbUp), @@ -210,6 +264,7 @@ async function refresh() { signal15m: signals["15m"], signal1h: signals["1h"], signal24h: signals["24h"], + primaryTf: tfKey, strength: edge.strength || "—", asset: asset, marketType: mtype, @@ -255,6 +310,14 @@ function stopPollProgress() { els.pollProgress.style.width = "0%"; } +// Listen for real-time price updates from content script +chrome.runtime.onMessage.addListener(function(message, sender, sendResponse) { + if (message && message.type === "synth:priceUpdate") { + console.log("[Synth-Overlay] Received live price update:", message.prices); + updateWithLivePrice(message.prices); + } +}); + // Start polling refresh(); setInterval(refresh, SYNTH_POLL_INTERVAL_MS); From c9af431a365e0054c20a0d2c3ac28860f7f4b948 Mon Sep 17 00:00:00 2001 From: bitloi Date: Wed, 4 Mar 2026 19:41:46 +0100 Subject: [PATCH 7/8] fix: review cleanup - dead code, footer layout, redundant override - Remove unused tfEl variable with no-op replacements in sidepanel.js - Remove unused pollTimer variable - Fix footer flex-wrap so poll-bar renders on its own line - Remove redundant live_prob_up double override in server.py --- tools/synth-overlay/extension/sidepanel.css | 1 + tools/synth-overlay/extension/sidepanel.js | 8 ++------ tools/synth-overlay/server.py | 9 +-------- 3 files changed, 4 insertions(+), 14 deletions(-) diff --git a/tools/synth-overlay/extension/sidepanel.css b/tools/synth-overlay/extension/sidepanel.css index 7775de4..9898765 100644 --- a/tools/synth-overlay/extension/sidepanel.css +++ b/tools/synth-overlay/extension/sidepanel.css @@ -110,6 +110,7 @@ button { color: #6b7280; display: flex; justify-content: space-between; + flex-wrap: wrap; } /* Price comparison grid */ diff --git a/tools/synth-overlay/extension/sidepanel.js b/tools/synth-overlay/extension/sidepanel.js index a33e492..a9e2719 100644 --- a/tools/synth-overlay/extension/sidepanel.js +++ b/tools/synth-overlay/extension/sidepanel.js @@ -171,11 +171,8 @@ function updateWithLivePrice(livePrices) { // Update primary timeframe signal with new edge var tfKey = cachedMarketType === "5min" ? "5m" : cachedMarketType === "15min" ? "15m" : cachedMarketType === "hourly" ? "1h" : "24h"; - var tfEl = els["signal" + tfKey.replace("m", "m").replace("h", "h")]; - if (tfKey === "5m") els.signal5m.textContent = signal + " " + fmtEdge(edgePct); - else if (tfKey === "15m") els.signal15m.textContent = signal + " " + fmtEdge(edgePct); - else if (tfKey === "1h") els.signal1h.textContent = signal + " " + fmtEdge(edgePct); - else els.signal24h.textContent = signal + " " + fmtEdge(edgePct); + var tfMap = { "5m": els.signal5m, "15m": els.signal15m, "1h": els.signal1h, "24h": els.signal24h }; + if (tfMap[tfKey]) tfMap[tfKey].textContent = signal + " " + fmtEdge(edgePct); // Update status to show live els.statusText.textContent = els.statusText.textContent.replace(/ \(Live\)$/, "") + " (Live)"; @@ -291,7 +288,6 @@ els.refreshBtn.addEventListener("click", function() { const SYNTH_POLL_INTERVAL_MS = 30000; // Poll progress bar animation -var pollTimer = null; var pollStart = 0; function startPollProgress() { diff --git a/tools/synth-overlay/server.py b/tools/synth-overlay/server.py index 2855fab..32aec42 100644 --- a/tools/synth-overlay/server.py +++ b/tools/synth-overlay/server.py @@ -93,16 +93,9 @@ def _handle_updown_market( # Daily/hourly: preserve dual-horizon analysis (1h vs 24h cross-comparison) if market_type in ("daily", "hourly") and reference_data: + # primary_data already has live_prob_up override applied above daily = primary_data if market_type == "daily" else reference_data hourly = reference_data if market_type == "daily" else primary_data - # Apply live price override to the appropriate horizon data - if live_prob_up is not None: - if market_type == "daily": - daily = dict(daily) - daily["polymarket_probability_up"] = live_prob_up - else: - hourly = dict(hourly) - hourly["polymarket_probability_up"] = live_prob_up analyzer = EdgeAnalyzer(daily, hourly, pct_1h, pct_24h) result = analyzer.analyze(primary_horizon=primary_horizon) primary_src = daily if market_type == "daily" else hourly From 6684cc200fb3bd21ace4b87a0bc10a389ba0436e Mon Sep 17 00:00:00 2001 From: bitloi Date: Wed, 4 Mar 2026 20:41:05 +0100 Subject: [PATCH 8/8] fix: rewrite DOM scraper for accurate Polymarket price detection --- tools/synth-overlay/extension/content.js | 164 ++++++++++++++++------- 1 file changed, 115 insertions(+), 49 deletions(-) diff --git a/tools/synth-overlay/extension/content.js b/tools/synth-overlay/extension/content.js index 2aff033..bc87c11 100644 --- a/tools/synth-overlay/extension/content.js +++ b/tools/synth-overlay/extension/content.js @@ -19,80 +19,146 @@ return segments[segments.length - 1] || null; } + /** + * Validate that a pair of binary market prices sums to roughly 100¢. + * Allows spread of 90-110 to account for market maker spread. + */ + function validatePricePair(up, down) { + if (up == null || down == null) return false; + var sum = Math.round((up + down) * 100); + return sum >= 90 && sum <= 110; + } + + /** + * Recursively search an object for Polymarket outcome prices. + * Looks for {outcomes: ["Up","Down"], outcomePrices: ["0.51","0.49"]} pattern. + */ + function findOutcomePricesInObject(obj) { + if (!obj || typeof obj !== "object") return null; + + if (Array.isArray(obj.outcomePrices) && Array.isArray(obj.outcomes)) { + var upIdx = -1, downIdx = -1; + for (var i = 0; i < obj.outcomes.length; i++) { + var name = String(obj.outcomes[i] || "").toLowerCase().trim(); + if (name === "up" || name === "yes") upIdx = i; + else if (name === "down" || name === "no") downIdx = i; + } + if (upIdx >= 0 && downIdx >= 0) { + var upP = parseFloat(obj.outcomePrices[upIdx]); + var downP = parseFloat(obj.outcomePrices[downIdx]); + if (!isNaN(upP) && !isNaN(downP) && upP > 0 && downP > 0) { + return { upPrice: upP, downPrice: downP }; + } + } + } + + var keys = Object.keys(obj); + for (var j = 0; j < keys.length; j++) { + var val = obj[keys[j]]; + if (val && typeof val === "object") { + var result = findOutcomePricesInObject(val); + if (result) return result; + } + } + return null; + } + /** * Scrape live Polymarket prices from the DOM. * Returns { upPrice: 0.XX, downPrice: 0.XX } or null if not found. + * + * Three strategies in order of reliability: + * 1. __NEXT_DATA__ JSON (structured, from Next.js SSR) + * 2. Compact DOM elements with anchored "Up XX¢" patterns + * 3. Price-only leaf elements with parent context walk */ function scrapeLivePrices() { var upPrice = null; var downPrice = null; - var bodyText = document.body ? document.body.innerText : ""; - - // Strategy 1: Look for outcome elements with "Up/Down" and cent values - var allElements = document.querySelectorAll("div, span, button, a, p"); - - for (var i = 0; i < allElements.length; i++) { - var el = allElements[i]; - var text = (el.innerText || el.textContent || "").trim(); - - if (text.length > 50) continue; - - var upMatch = text.match(/\b(Up|Yes)\b[\s\n]*(\d{1,2})\s*[¢c¢]/i); - var downMatch = text.match(/\b(Down|No)\b[\s\n]*(\d{1,2})\s*[¢c¢]/i); - - if (upMatch && upMatch[2] && upPrice === null) { - upPrice = parseInt(upMatch[2], 10) / 100; - } - if (downMatch && downMatch[2] && downPrice === null) { - downPrice = parseInt(downMatch[2], 10) / 100; + + // Strategy 1: Parse __NEXT_DATA__ for structured market data + try { + var ndEl = document.getElementById("__NEXT_DATA__"); + if (ndEl) { + var nd = JSON.parse(ndEl.textContent); + var fromND = findOutcomePricesInObject(nd); + if (fromND && validatePricePair(fromND.upPrice, fromND.downPrice)) { + console.log("[Synth-Overlay] Prices from __NEXT_DATA__:", fromND); + return fromND; + } } - + } catch (e) { + console.log("[Synth-Overlay] __NEXT_DATA__ parse failed:", e.message); + } + + // Strategy 2: Scan compact DOM elements for anchored "Up XX¢" / "Down XX¢" + // Only considers elements with very short text (< 20 chars) to avoid false positives. + // Regex is anchored (^...$) so entire text must match the pattern. + var els = document.querySelectorAll("button, a, span, div, p, [role='button']"); + for (var i = 0; i < els.length; i++) { + var text = (els[i].textContent || "").trim(); + if (text.length > 20 || text.length < 3) continue; + if (upPrice === null) { - var upDecMatch = text.match(/\b(Up|Yes)\b[\s\n]*(0\.\d+)/i); - if (upDecMatch && upDecMatch[2]) upPrice = parseFloat(upDecMatch[2]); + var um = text.match(/^\s*(Up|Yes)\s*(\d{1,2})\s*[¢%]\s*$/i); + if (um) { + var up = parseInt(um[2], 10) / 100; + if (up >= 0.01 && up <= 0.99) upPrice = up; + } } if (downPrice === null) { - var downDecMatch = text.match(/\b(Down|No)\b[\s\n]*(0\.\d+)/i); - if (downDecMatch && downDecMatch[2]) downPrice = parseFloat(downDecMatch[2]); + var dm = text.match(/^\s*(Down|No)\s*(\d{1,2})\s*[¢%]\s*$/i); + if (dm) { + var dn = parseInt(dm[2], 10) / 100; + if (dn >= 0.01 && dn <= 0.99) downPrice = dn; + } } - if (upPrice !== null && downPrice !== null) break; } - // Strategy 2: Container-based search - if (upPrice === null || downPrice === null) { - var containers = document.querySelectorAll("[class*='market'], [class*='outcome'], [class*='option'], [class*='card']"); - for (var j = 0; j < containers.length; j++) { - var container = containers[j]; - var containerText = (container.innerText || "").toLowerCase(); - var priceMatch = containerText.match(/(\d{1,2})\s*[¢c¢]/); - if (!priceMatch) continue; - var price = parseInt(priceMatch[1], 10) / 100; - if ((containerText.indexOf("up") !== -1 || containerText.indexOf("yes") !== -1) && upPrice === null) { - upPrice = price; - } else if ((containerText.indexOf("down") !== -1 || containerText.indexOf("no") !== -1) && downPrice === null) { - downPrice = price; - } - } + if (upPrice !== null && downPrice !== null && validatePricePair(upPrice, downPrice)) { + console.log("[Synth-Overlay] Prices from compact DOM:", { upPrice: upPrice, downPrice: downPrice }); + return { upPrice: upPrice, downPrice: downPrice }; } - // Strategy 3: Full body text search - if (upPrice === null || downPrice === null) { - var upBodyMatch = bodyText.match(/\bUp\b[^\d]*?(\d{1,2})\s*[¢c¢]/i); - var downBodyMatch = bodyText.match(/\bDown\b[^\d]*?(\d{1,2})\s*[¢c¢]/i); - if (upBodyMatch && upBodyMatch[1] && upPrice === null) upPrice = parseInt(upBodyMatch[1], 10) / 100; - if (downBodyMatch && downBodyMatch[1] && downPrice === null) downPrice = parseInt(downBodyMatch[1], 10) / 100; + // Strategy 3: Find leaf elements containing just "XX¢" or "XX%", + // then walk up the DOM tree to find "Up" or "Down" context. + upPrice = null; + downPrice = null; + for (var k = 0; k < els.length; k++) { + var el = els[k]; + var t = (el.textContent || "").trim(); + if (!t.match(/^\d{1,2}\s*[¢%]$/)) continue; + if (el.children.length > 1) continue; + + var price = parseInt(t, 10) / 100; + if (price < 0.01 || price > 0.99) continue; + + var parent = el.parentElement; + for (var d = 0; d < 4 && parent; d++) { + var pText = (parent.textContent || "").toLowerCase(); + if (pText.length > 80) break; + if (/\bup\b/.test(pText) && upPrice === null) { upPrice = price; break; } + if (/\bdown\b/.test(pText) && downPrice === null) { downPrice = price; break; } + parent = parent.parentElement; + } + if (upPrice !== null && downPrice !== null) break; } - if (upPrice !== null && downPrice !== null) { + 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 (upPrice !== null && downPrice === null) { + + // If only one price found, infer the other (no sum validation possible) + if (upPrice !== null && upPrice >= 0.01 && upPrice <= 0.99) { return { upPrice: upPrice, downPrice: 1 - upPrice }; } - if (downPrice !== null && upPrice === null) { + if (downPrice !== null && downPrice >= 0.01 && downPrice <= 0.99) { return { upPrice: 1 - downPrice, downPrice: downPrice }; } + + console.log("[Synth-Overlay] Could not scrape live prices from DOM"); return null; }