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"