diff --git a/tools/synth-overlay/README.md b/tools/synth-overlay/README.md new file mode 100644 index 0000000..8afb6be --- /dev/null +++ b/tools/synth-overlay/README.md @@ -0,0 +1,56 @@ +# Synth Overlay — Polymarket Edge Extension + +Chrome extension that adds a live "fair value" layer on Polymarket market pages using Synth forecasts. Shows whether the current YES price is **underpriced**, **overpriced**, or **fair** and exposes edge strength (Strong / Moderate / No Edge). + +## What it does + +- **Side panel**: A "Synth" tab on the right edge opens a slide-out panel (not a floating card). Click to view full analysis. +- **Data & analysis visibility**: Panel shows Market YES price, Synth fair value, Edge %, and a clear explanation of the data behind the decision. +- **Confidence bar**: Discrete colors — red (<40%), amber (40–70%), green (≥70%). 45% confidence is never green. +- **Inline overlays**: Synth hints ("Synth: buy", "Synth: avoid", "Synth: sell") appear directly on Up/Down outcome buttons so users see actionable signals at a glance. +- **Contextual only**: Overlay appears only when the page slug maps to a Synth-supported market (daily up/down, hourly up/down, or range). Unsupported markets show nothing. + +## How it works + +1. **Extension** (content script on `polymarket.com`) reads the page URL and extracts the market slug. +2. **Local API** (Flask on `http://127.0.0.1:8765`) is called with `GET /api/edge?slug=...`. The server uses `SynthClient` (mock or live) to load Polymarket comparison data. +3. **Edge logic** computes `edge_pct = (synth_prob - market_prob) * 100` and classifies signal (underpriced / fair / overpriced) and strength (strong / moderate / none) from thresholds. +4. **Overlay** is injected only when the API returns 200; 404 (unsupported market) keeps the page unchanged. + +## Synth API usage + +- `get_polymarket_daily()` — daily up/down (24h) Synth vs Polymarket. +- `get_polymarket_hourly()` — hourly up/down (1h). +- `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. + +## Run locally + +1. Install: `pip install -r requirements.txt` (from repo root: `pip install -r tools/synth-overlay/requirements.txt`). +2. Start server (from repo root): `python tools/synth-overlay/server.py` (or from `tools/synth-overlay`: `python server.py`). Listens on `127.0.0.1:8765`. +3. Load extension: Chrome → Extensions → Load unpacked → select `tools/synth-overlay/extension`. +4. Open a Polymarket event/market URL whose slug matches a supported market (e.g. `bitcoin-up-or-down-on-february-26` for mock daily). The Synth tab appears when the server is running and the slug is supported. + +## Verify the overlay (before recording) + +1. **Check the API** (server must be running): + ```bash + curl -s "http://127.0.0.1:8765/api/edge?slug=bitcoin-up-or-down-on-february-26" | head -c 200 + ``` + 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` + - The extension reads the slug from the path and calls the API. If the API returned 200 in step 1, the **Synth tab** appears on the right edge and **inline overlays** may appear on Up/Down buttons. + +3. **Interaction:** + - **Synth tab** = vertical tab on the right edge. Click it to open the **side panel**. + - **Side panel** shows: Data & Analysis (market price, Synth fair value, edge %, explanation), Signal (1h/24h), Confidence (color-coded bar), and invalidation. + - **Inline overlays** on Up/Down buttons: "Synth: buy", "Synth: avoid", or "Synth: sell" so users see the signal where they act. + +4. **If nothing appears:** Ensure (a) server is running, (b) you loaded the extension from `tools/synth-overlay/extension` (not the parent folder), (c) the address bar is exactly one of the supported URLs above. Open DevTools → Network: you should see a request to `127.0.0.1:8765/api/edge?slug=...` with status 200. + +## Tests + +From repo root: `python -m pytest tools/synth-overlay/tests/ -v`. Uses mock data; no API key required. diff --git a/tools/synth-overlay/analyzer.py b/tools/synth-overlay/analyzer.py new file mode 100644 index 0000000..d4134e6 --- /dev/null +++ b/tools/synth-overlay/analyzer.py @@ -0,0 +1,285 @@ +""" +EdgeAnalyzer: consolidated dual-horizon edge analysis with confidence scoring +and human-readable signal explanations using Synth forecast percentiles. +""" + +from dataclasses import dataclass +from typing import Literal + +from edge import ( + compute_edge_pct, + signal_from_edge, + strength_from_edge, + signals_conflict, + strength_from_horizons, +) + + +@dataclass +class HorizonEdge: + horizon: str + edge_pct: float + signal: str + synth_prob: float + market_prob: float + + +@dataclass +class AnalysisResult: + primary: HorizonEdge + secondary: HorizonEdge | None + strength: Literal["strong", "moderate", "none"] + confidence_score: float + no_trade: bool + explanation: str + invalidation: str + + +class EdgeAnalyzer: + """Analyzes Synth vs Polymarket across horizons with percentile-based confidence.""" + + def __init__( + self, + daily_data: dict | None = None, + hourly_data: dict | None = None, + percentiles_1h: dict | None = None, + percentiles_24h: dict | None = None, + ): + self._daily = daily_data + self._hourly = hourly_data + self._pct_1h = percentiles_1h + self._pct_24h = percentiles_24h + + def _extract_edge(self, data: dict, horizon: str) -> HorizonEdge: + synth = float(data["synth_probability_up"]) + market = float(data["polymarket_probability_up"]) + edge_pct = compute_edge_pct(synth, market) + return HorizonEdge( + horizon=horizon, + edge_pct=edge_pct, + signal=signal_from_edge(edge_pct), + synth_prob=synth, + market_prob=market, + ) + + def _percentile_spread(self, pct_data: dict | None) -> float | None: + """Relative spread (p95 - p05) / price. Returns None if data unavailable.""" + if not pct_data: + return None + try: + steps = pct_data.get("forecast_future", {}).get("percentiles") or [] + if not steps: + return None + last = steps[-1] + price = pct_data.get("current_price") or 1.0 + if price <= 0: + return None + p95 = float(last.get("0.95", 0)) + p05 = float(last.get("0.05", 0)) + return abs(p95 - p05) / price + except (TypeError, KeyError, ValueError): + return None + + def _directional_bias(self, pct_data: dict | None) -> float | None: + """How much median deviates from current price: (p50 - price) / price.""" + if not pct_data: + return None + try: + steps = pct_data.get("forecast_future", {}).get("percentiles") or [] + if not steps: + return None + last = steps[-1] + price = pct_data.get("current_price") or 1.0 + if price <= 0: + return None + p50 = float(last.get("0.5", 0)) + return (p50 - price) / price + except (TypeError, KeyError, ValueError): + return None + + def compute_confidence(self, spread_1h: float | None, spread_24h: float | None) -> float: + """ + Confidence score [0.0, 1.0] inversely proportional to forecast spread. + Narrow distributions = high confidence; wide = low. + """ + spreads = [s for s in (spread_1h, spread_24h) if s is not None] + if not spreads: + return 0.5 + avg_spread = sum(spreads) / len(spreads) + if avg_spread <= 0.01: + return 1.0 + if avg_spread >= 0.10: + return 0.1 + return round(1.0 - (avg_spread - 0.01) / 0.09 * 0.9, 2) + + def _build_explanation(self, edge_1h: HorizonEdge, edge_24h: HorizonEdge, confidence: float) -> str: + direction_1h = "higher" if edge_1h.edge_pct > 0 else "lower" + direction_24h = "higher" if edge_24h.edge_pct > 0 else "lower" + parts = [] + if signals_conflict(edge_1h.signal, edge_24h.signal): + parts.append( + f"Synth forecasts {direction_1h} on the 1h horizon " + f"but {direction_24h} on the 24h horizon — signals conflict." + ) + elif edge_1h.signal == "fair" and edge_24h.signal == "fair": + parts.append("Synth and Polymarket agree closely on both horizons.") + else: + dominant = "up" if edge_24h.edge_pct > 0 else "down" + parts.append( + f"Synth forecasts {dominant} probability {direction_24h} than " + f"Polymarket on both horizons: 1h by {abs(edge_1h.edge_pct)}pp, " + f"24h by {abs(edge_24h.edge_pct)}pp." + ) + 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_invalidation(self, edge_24h: HorizonEdge, bias_24h: float | None) -> str: + parts = [] + if edge_24h.signal == "underpriced": + parts.append( + "This edge invalidates if price drops sharply, " + "pushing Synth probability below market." + ) + elif edge_24h.signal == "overpriced": + parts.append( + "This edge invalidates if price rallies, " + "pushing Synth probability above market." + ) + else: + parts.append("No meaningful edge to invalidate — market is fairly priced.") + if bias_24h is not None and abs(bias_24h) > 0.02: + direction = "upward" if bias_24h > 0 else "downward" + parts.append(f"Synth median shows a {direction} bias of {abs(bias_24h)*100:.1f}%.") + return " ".join(parts) + + def analyze_range( + self, + selected_bracket: dict, + all_brackets: list[dict], + percentiles_24h: dict | None = None, + ) -> AnalysisResult: + """Analyze a range market bracket with context from all brackets.""" + synth = float(selected_bracket.get("synth_probability", 0)) + market = float(selected_bracket.get("polymarket_probability", 0)) + edge_pct = compute_edge_pct(synth, market) + signal = signal_from_edge(edge_pct) + strength = strength_from_edge(edge_pct) + title = selected_bracket.get("title", "") + + spread_24h = self._percentile_spread(percentiles_24h) + confidence = self.compute_confidence(None, spread_24h) + high_uncertainty = spread_24h is not None and spread_24h > 0.05 + no_trade = strength == "none" or high_uncertainty + + mispriced = [ + b for b in all_brackets + if abs(float(b.get("synth_probability", 0)) - float(b.get("polymarket_probability", 0))) > 0.005 + ] + explanation = self._build_range_explanation( + title, edge_pct, signal, len(mispriced), len(all_brackets), confidence + ) + invalidation = self._build_range_invalidation( + selected_bracket, signal + ) + + primary = HorizonEdge( + horizon="24h", + edge_pct=edge_pct, + signal=signal, + synth_prob=synth, + market_prob=market, + ) + return AnalysisResult( + primary=primary, + secondary=None, + strength=strength, + confidence_score=confidence, + no_trade=no_trade, + explanation=explanation, + invalidation=invalidation, + ) + + def _build_range_explanation( + self, + title: str, + edge_pct: float, + signal: str, + mispriced_count: int, + total_count: int, + confidence: float, + ) -> str: + parts = [] + if signal == "fair": + parts.append( + f"Bracket {title}: Synth and Polymarket agree closely " + f"(edge {edge_pct:+.1f}pp)." + ) + else: + direction = "higher" if edge_pct > 0 else "lower" + parts.append( + f"Bracket {title}: Synth assigns {direction} probability " + f"than Polymarket by {abs(edge_pct):.1f}pp." + ) + if mispriced_count > 1: + parts.append( + f"{mispriced_count} of {total_count} brackets show mispricing." + ) + 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_range_invalidation(self, bracket: dict, signal: str) -> str: + title = bracket.get("title", "") + if signal == "underpriced": + return ( + f"Edge on {title} invalidates if price moves away from this range, " + f"reducing the probability of landing here." + ) + if signal == "overpriced": + return ( + f"Edge on {title} invalidates if price moves toward this range, " + f"increasing the probability of landing here." + ) + return f"No meaningful edge on {title} — bracket is fairly priced." + + 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") + + edge_24h = self._extract_edge(self._daily, "24h") + edge_1h = self._extract_edge(self._hourly, "1h") + + strength = strength_from_horizons(edge_1h.edge_pct, edge_24h.edge_pct) + conflict = signals_conflict(edge_1h.signal, edge_24h.signal) + + 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) + ) + no_trade = conflict or strength == "none" or high_uncertainty + + bias_24h = self._directional_bias(self._pct_24h) + + explanation = self._build_explanation(edge_1h, edge_24h, confidence) + invalidation = self._build_invalidation(edge_24h, bias_24h) + + primary = edge_24h if primary_horizon == "24h" else edge_1h + secondary = edge_1h if primary_horizon == "24h" else edge_24h + + return AnalysisResult( + primary=primary, + secondary=secondary, + strength=strength, + confidence_score=confidence, + no_trade=no_trade, + explanation=explanation, + invalidation=invalidation, + ) diff --git a/tools/synth-overlay/edge.py b/tools/synth-overlay/edge.py new file mode 100644 index 0000000..08196c7 --- /dev/null +++ b/tools/synth-overlay/edge.py @@ -0,0 +1,106 @@ +"""Edge calculation: Synth vs Polymarket probability difference and signal classification.""" + +from typing import Literal + +FAIR_THRESHOLD_PCT = 0.5 +STRONG_EDGE_PCT = 3.0 +MODERATE_EDGE_PCT = 1.0 + + +def compute_edge_pct(synth_prob: float, market_prob: float) -> float: + """YES-edge in percentage points: positive = Synth higher than market (underpriced YES).""" + if not 0 <= synth_prob <= 1 or not 0 <= market_prob <= 1: + raise ValueError("Probabilities must be in [0, 1]") + return round((synth_prob - market_prob) * 100, 1) + + +def signal_from_edge(edge_pct: float, fair_threshold: float = FAIR_THRESHOLD_PCT) -> str: + """Classify edge into underpriced / fair / overpriced (for YES).""" + if edge_pct >= fair_threshold: + return "underpriced" + if edge_pct <= -fair_threshold: + return "overpriced" + return "fair" + + +def strength_from_edge( + edge_pct: float, + strong_threshold: float = STRONG_EDGE_PCT, + moderate_threshold: float = MODERATE_EDGE_PCT, +) -> Literal["strong", "moderate", "none"]: + """Classify edge strength for display (Strong / Moderate / No Edge).""" + abs_edge = abs(edge_pct) + if abs_edge >= strong_threshold: + return "strong" + if abs_edge >= moderate_threshold: + return "moderate" + return "none" + + +def signals_conflict(signal_1h: str, signal_24h: str) -> bool: + """True when 1h and 24h point in opposite directions (one underpriced, other overpriced).""" + if signal_1h == "fair" or signal_24h == "fair": + return False + return signal_1h != signal_24h + + +def strength_from_horizons( + edge_1h: float, + edge_24h: float, + strong_threshold: float = STRONG_EDGE_PCT, + moderate_threshold: float = MODERATE_EDGE_PCT, +) -> Literal["strong", "moderate", "none"]: + """Strength from aligned 1h/24h edges: strong when aligned and meaningful, none when conflicting.""" + if signals_conflict( + signal_from_edge(edge_1h), signal_from_edge(edge_24h) + ): + return "none" + abs_1h = abs(edge_1h) + abs_24h = abs(edge_24h) + min_edge = min(abs_1h, abs_24h) + if min_edge >= strong_threshold: + return "strong" + if min_edge >= moderate_threshold: + return "moderate" + return "none" + + +def edge_from_daily_or_hourly(data: dict) -> tuple[float, str, str]: + """From up/down daily or hourly payload: (edge_pct, signal, strength).""" + synth = data.get("synth_probability_up") + market = data.get("polymarket_probability_up") + if synth is None or market is None: + raise ValueError("Missing synth_probability_up or polymarket_probability_up") + edge_pct = compute_edge_pct(float(synth), float(market)) + return edge_pct, signal_from_edge(edge_pct), strength_from_edge(edge_pct) + + +def edge_from_range_bracket(bracket: dict) -> tuple[float, str, str]: + """From one range bracket: (edge_pct, signal, strength).""" + synth = bracket.get("synth_probability") + market = bracket.get("polymarket_probability") + if synth is None or market is None: + raise ValueError("Missing synth_probability or polymarket_probability") + edge_pct = compute_edge_pct(float(synth), float(market)) + return edge_pct, signal_from_edge(edge_pct), strength_from_edge(edge_pct) + + +def uncertainty_high_from_percentiles( + percentiles_data: dict, + relative_spread_threshold: float = 0.05, +) -> bool: + """True when forecast distribution is wide (95th - 5th percentile) relative to price.""" + try: + steps = percentiles_data.get("forecast_future", {}).get("percentiles") or [] + if not steps: + return False + last = steps[-1] + current_price = percentiles_data.get("current_price") or 1.0 + p95 = float(last.get("0.95", 0)) + p05 = float(last.get("0.05", 0)) + if current_price <= 0: + return False + spread = abs(p95 - p05) / current_price + return spread > relative_spread_threshold + except (TypeError, KeyError): + return False diff --git a/tools/synth-overlay/extension/content.css b/tools/synth-overlay/extension/content.css new file mode 100644 index 0000000..68db081 --- /dev/null +++ b/tools/synth-overlay/extension/content.css @@ -0,0 +1,168 @@ +/* Side panel tab (persistent trigger) */ +.synth-overlay-tab { + font-family: system-ui, -apple-system, sans-serif; + font-size: 11px; + font-weight: 600; + z-index: 9998; + position: fixed; + top: 50%; + right: 0; + transform: translateY(-50%); + writing-mode: vertical-rl; + text-orientation: mixed; + padding: 10px 8px; + background: #1e40af; + color: #fff; + border-radius: 6px 0 0 6px; + cursor: pointer; + box-shadow: -2px 0 8px rgba(0, 0, 0, 0.15); + transition: right 0.25s ease, background 0.2s; +} +.synth-overlay-tab:hover { + background: #1d4ed8; +} +.synth-overlay-tab.synth-overlay-tab-hidden { + right: -50px; +} + +.synth-overlay-no-trade { + margin-top: 6px; + padding: 6px 8px; + background: rgba(180, 120, 0, 0.12); + border-radius: 4px; + color: #8a5a00; + font-size: 11px; +} + +/* confidence bar */ +.synth-overlay-conf-bar { + display: inline-block; + width: 60px; + height: 6px; + background: #e5e7eb; + border-radius: 3px; + vertical-align: middle; + margin-left: 4px; +} +.synth-overlay-conf-bar-lg { + width: 100%; + height: 8px; + margin-left: 0; + margin-top: 4px; +} +.synth-overlay-conf-fill { + height: 100%; + border-radius: 3px; + transition: width 0.3s; +} +/* Discrete colors: <40% red, 40-70% amber, >=70% green (45% must NOT be green) */ +.synth-overlay-conf-fill.synth-overlay-conf-low { + background: #ef4444; +} +.synth-overlay-conf-fill.synth-overlay-conf-medium { + background: #f59e0b; +} +.synth-overlay-conf-fill.synth-overlay-conf-high { + background: #22c55e; +} + +/* slide-out panel */ +.synth-overlay-panel { + position: fixed; + top: 0; + right: -360px; + width: 340px; + height: 100vh; + background: #fff; + box-shadow: -4px 0 20px rgba(0, 0, 0, 0.15); + z-index: 99999; + font-family: system-ui, -apple-system, sans-serif; + font-size: 13px; + transition: right 0.25s ease; + overflow-y: auto; + display: flex; + flex-direction: column; +} +.synth-overlay-panel-open { + right: 0; +} +.synth-overlay-panel-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 14px 16px; + border-bottom: 1px solid #e5e7eb; + background: #f9fafb; +} +.synth-overlay-panel-title { + font-weight: 700; + font-size: 15px; +} +.synth-overlay-panel-close { + cursor: pointer; + font-size: 18px; + color: #6b7280; + line-height: 1; +} +.synth-overlay-panel-close:hover { + color: #111; +} +.synth-overlay-panel-body { + padding: 16px; + flex: 1; +} +.synth-overlay-panel-section { + margin-bottom: 16px; +} +.synth-overlay-panel-label { + font-weight: 600; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.5px; + color: #6b7280; + margin-bottom: 4px; +} +.synth-overlay-panel-row { + margin-bottom: 3px; +} +.synth-overlay-panel-text { + line-height: 1.5; + color: #374151; +} +.synth-overlay-panel-meta { + margin-top: 12px; + font-size: 10px; + color: #9ca3af; + border-top: 1px solid #e5e7eb; + padding-top: 8px; +} + +/* Inline overlays on Up/Down buttons */ +.synth-overlay-inline { + position: absolute; + right: 4px; + bottom: 2px; + z-index: 2; + display: inline-block; + font-size: 9px; + font-weight: 600; + padding: 1px 5px; + border-radius: 10px; + background: rgba(255, 255, 255, 0.96); + border: 1px solid #d1d5db; + white-space: nowrap; + pointer-events: none; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.12); +} +.synth-overlay-inline-buy { + color: #15803d; + border-color: #86efac; +} +.synth-overlay-inline-caution { + color: #b91c1c; + border-color: #fca5a5; +} +.synth-overlay-inline-neutral { + color: #6b7280; + border-color: #d1d5db; +} diff --git a/tools/synth-overlay/extension/content.js b/tools/synth-overlay/extension/content.js new file mode 100644 index 0000000..410f935 --- /dev/null +++ b/tools/synth-overlay/extension/content.js @@ -0,0 +1,345 @@ +(function () { + "use strict"; + + var API_BASE = "http://127.0.0.1:8765"; + var currentSlug = null; + + function slugFromPage() { + var path = window.location.pathname || ""; + var segments = path.split("/").filter(Boolean); + var first = segments[0]; + var second = segments[1] || segments[0]; + if (first === "event" || first === "market") { + return second || null; + } + return first || null; + } + + function formatLabel(signal, edgePct) { + var prefix = edgePct >= 0 ? "+" : ""; + if (signal === "fair") return "Fair " + prefix + edgePct + "%"; + return "YES Edge " + prefix + edgePct + "%"; + } + + function confidenceLabel(score) { + if (score >= 0.7) return "High"; + if (score >= 0.4) return "Medium"; + return "Low"; + } + + function confidenceBarWidth(score) { + return Math.max(5, Math.min(100, Math.round(score * 100))); + } + + function confidenceColorClass(score) { + if (score >= 0.7) return "synth-overlay-conf-high"; + if (score >= 0.4) return "synth-overlay-conf-medium"; + return "synth-overlay-conf-low"; + } + + function formatProbAsCents(p) { + if (p == null || p === undefined) return "—"; + return Math.round(p * 100) + "¢"; + } + + function formatTime(isoString) { + if (!isoString || typeof isoString !== "string") return ""; + var d = new Date(isoString.trim()); + if (isNaN(d.getTime())) return isoString; + var months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + var mon = months[d.getUTCMonth()]; + var day = d.getUTCDate(); + var h = d.getUTCHours(); + var m = d.getUTCMinutes(); + var ampm = h >= 12 ? "PM" : "AM"; + h = h % 12; + if (h === 0) h = 12; + var min = m < 10 ? "0" + m : String(m); + return mon + " " + day + ", " + h + ":" + min + " " + ampm + " UTC"; + } + + function escapeHtml(s) { + var div = document.createElement("div"); + div.textContent = s; + return div.innerHTML; + } + + function showPanel(data) { + closePanel(); + var tab = document.querySelector("[data-synth-overlay=tab]"); + if (tab) tab.classList.add("synth-overlay-tab-hidden"); + var panel = document.createElement("div"); + panel.className = "synth-overlay-panel"; + panel.setAttribute("data-synth-overlay", "panel"); + + var explanation = data.explanation || "No explanation available."; + var invalidation = data.invalidation || ""; + var confScore = data.confidence_score != null ? data.confidence_score : 0.5; + var barWidth = confidenceBarWidth(confScore); + + var hasDual = data.edge_1h_pct != null && data.edge_24h_pct != null; + var now1h = hasDual ? formatLabel(data.signal_1h, data.edge_1h_pct) : formatLabel(data.signal, data.edge_pct); + var byClose24h = hasDual ? formatLabel(data.signal_24h, data.edge_24h_pct) : formatLabel(data.signal, data.edge_pct); + + var synthProb = data.synth_probability_up != null ? data.synth_probability_up : data.synth_probability; + var marketProb = data.polymarket_probability_up != null ? data.polymarket_probability_up : data.polymarket_probability; + var synthCents = formatProbAsCents(synthProb); + var marketCents = formatProbAsCents(marketProb); + + panel.innerHTML = + '
' + + 'Synth Analysis' + + '\u2715' + + "
" + + '
' + + '
' + + '
Data & Analysis
' + + '
Market YES price: ' + escapeHtml(marketCents) + "
" + + '
Synth fair value: ' + escapeHtml(synthCents) + "
" + + '
Edge: ' + (data.edge_pct >= 0 ? "+" : "") + escapeHtml(String(data.edge_pct)) + "%
" + + '
' + escapeHtml(explanation) + "
" + + "
" + + '
' + + '
Signal
' + + '
Now (1h): ' + escapeHtml(now1h) + "
" + + '
By close (24h): ' + escapeHtml(byClose24h) + "
" + + '
Strength: ' + escapeHtml(data.strength) + "
" + + "
" + + '
' + + '
Confidence
' + + '
' + + '
' + + "
" + + '
' + escapeHtml(confidenceLabel(confScore)) + + " (" + Math.round(confScore * 100) + "%)
" + + "
" + + (invalidation + ? '
' + + '
What would invalidate it
' + + '
' + escapeHtml(invalidation) + "
" + + "
" + : "") + + (data.no_trade_warning + ? '
' + + "No trade \u2014 uncertainty is high or signals conflict." + + "
" + : "") + + '
Last update: ' + + escapeHtml(formatTime(data.current_time) || "unknown") + "
" + + "
"; + + var closeBtn = panel.querySelector(".synth-overlay-panel-close"); + if (closeBtn) { + closeBtn.addEventListener("click", function (e) { + e.stopPropagation(); + closePanel(); + var t = document.querySelector("[data-synth-overlay=tab]"); + if (t) t.classList.remove("synth-overlay-tab-hidden"); + }); + } + document.body.appendChild(panel); + requestAnimationFrame(function () { + panel.classList.add("synth-overlay-panel-open"); + }); + } + + function closePanel() { + var panels = document.querySelectorAll("[data-synth-overlay=panel]"); + for (var i = 0; i < panels.length; i++) panels[i].remove(); + var t = document.querySelector("[data-synth-overlay=tab]"); + if (t) t.classList.remove("synth-overlay-tab-hidden"); + } + + function createSidePanelTab(data) { + var existing = document.querySelector("[data-synth-overlay=tab]"); + if (existing) existing.remove(); + var tab = document.createElement("div"); + tab.className = "synth-overlay-tab"; + tab.setAttribute("data-synth-overlay", "tab"); + tab.textContent = "Synth"; + tab.addEventListener("click", function (e) { + e.stopPropagation(); + showPanel(data); + }); + document.body.appendChild(tab); + } + + function injectInlineOverlays(data) { + removeInlineOverlays(); + var signal = data.signal; + var edgePct = data.edge_pct; + var synthProb = data.synth_probability_up != null ? data.synth_probability_up : data.synth_probability; + var marketProb = data.polymarket_probability_up != null ? data.polymarket_probability_up : data.polymarket_probability; + var synthCents = formatProbAsCents(synthProb); + var marketCents = formatProbAsCents(marketProb); + var edgeSigned = (edgePct >= 0 ? "+" : "") + edgePct + "%"; + var yesEdgeAbs = "+" + Math.abs(edgePct) + "%"; + + var actionUp = signal === "underpriced" ? "Buy YES" : signal === "overpriced" ? "Avoid YES" : "Fair"; + var actionDown = signal === "overpriced" ? "Buy NO" : signal === "underpriced" ? "Avoid NO" : "Fair"; + var colorUp = signal === "underpriced" ? "#15803d" : signal === "overpriced" ? "#b91c1c" : "#6b7280"; + var colorDown = signal === "overpriced" ? "#15803d" : signal === "underpriced" ? "#b91c1c" : "#6b7280"; + + var bar = document.createElement("div"); + bar.setAttribute("data-synth-inline", "1"); + bar.style.cssText = + "display:flex !important;gap:8px !important;align-items:center !important;" + + "padding:6px 12px !important;" + + "background:#f0f9ff !important;border:1px solid #93c5fd !important;" + + "border-radius:8px !important;font-family:system-ui,-apple-system,sans-serif !important;" + + "font-size:12px !important;z-index:99998 !important;" + + "box-shadow:0 2px 8px rgba(0,0,0,0.10) !important;" + + "visibility:visible !important;opacity:1 !important;"; + + var upSpan = document.createElement("span"); + upSpan.style.cssText = "font-weight:700 !important;color:" + colorUp + " !important;"; + upSpan.textContent = "\u2191 " + actionUp + " " + edgeSigned; + + var sep = document.createElement("span"); + sep.style.cssText = "color:#9ca3af !important;"; + sep.textContent = "|"; + + var downSpan = document.createElement("span"); + downSpan.style.cssText = "font-weight:700 !important;color:" + colorDown + " !important;"; + downSpan.textContent = "\u2193 " + actionDown + " " + (actionDown === "Buy NO" ? yesEdgeAbs : edgeSigned); + + var dataSpan = document.createElement("span"); + dataSpan.style.cssText = "color:#6b7280 !important;font-size:10px !important;margin-left:4px !important;"; + dataSpan.textContent = "FV " + synthCents + " / MKT " + marketCents; + + bar.appendChild(upSpan); + bar.appendChild(sep); + bar.appendChild(downSpan); + bar.appendChild(dataSpan); + + var anchor = findTradeWidgetAnchor(); + if (anchor && anchor.parentNode) { + bar.style.margin = "0 0 6px 0"; + anchor.parentNode.insertBefore(bar, anchor); + } else { + // Fallback for unexpected layouts + bar.style.position = "fixed"; + bar.style.top = "60px"; + bar.style.right = "60px"; + document.body.appendChild(bar); + } + } + + function findTradeWidgetAnchor() { + var candidates = Array.prototype.slice.call(document.querySelectorAll("div, section, aside, form")); + var best = null; + var bestLen = Infinity; + for (var i = 0; i < candidates.length; i++) { + var el = candidates[i]; + var text = (el.textContent || "").replace(/\s+/g, " ").trim(); + if (!text || text.length < 20 || text.length > 4000) continue; + if (!/\bbuy\b/i.test(text) || !/\bsell\b/i.test(text)) continue; + if (!/¢/.test(text)) continue; + if (!/\btrade\b/i.test(text) && !/\bamount\b/i.test(text)) continue; + if (text.length < bestLen) { + best = el; + bestLen = text.length; + } + } + return best; + } + + function removeInlineOverlays() { + var hints = document.querySelectorAll("[data-synth-inline]"); + for (var i = 0; i < hints.length; i++) hints[i].remove(); + } + + function injectBadge(container, data) { + removeBadge(); + createSidePanelTab(data); + injectInlineOverlays(data); + var retries = [1000, 2500, 5000]; + retries.forEach(function (ms) { + setTimeout(function () { + if (!document.querySelector("[data-synth-overlay=tab]")) return; + if (!document.querySelector("[data-synth-inline]")) injectInlineOverlays(data); + }, ms); + }); + } + + function removeBadge() { + var tabs = document.querySelectorAll("[data-synth-overlay=tab]"); + for (var i = 0; i < tabs.length; i++) tabs[i].remove(); + removeInlineOverlays(); + closePanel(); + } + + function findInjectionTarget() { + return document.body; + } + + function fetchEdge(slug) { + return fetch(API_BASE + "/api/edge?slug=" + encodeURIComponent(slug), { + method: "GET", + mode: "cors", + }) + .then(function (r) { + if (!r.ok) return null; + return r.json(); + }) + .catch(function () { + return null; + }); + } + + function run() { + var slug = slugFromPage(); + if (!slug) { + currentSlug = null; + removeBadge(); + return; + } + var requestedSlug = slug; + fetchEdge(slug).then(function (data) { + if (slugFromPage() !== requestedSlug) return; + if (!data || data.error) { + currentSlug = null; + removeBadge(); + return; + } + currentSlug = requestedSlug; + var target = findInjectionTarget(); + if (target) injectBadge(target, data); + }); + } + + function debounce(fn, ms) { + var t = null; + return function () { + if (t) clearTimeout(t); + t = setTimeout(fn, ms); + }; + } + + var runDebounced = debounce(run, 400); + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", run); + } else { + run(); + } + + var observer = new MutationObserver(function () { + var slug = slugFromPage(); + if (slug === currentSlug && document.querySelector("[data-synth-overlay=tab]")) { + return; + } + runDebounced(); + }); + observer.observe(document.body, { childList: true, subtree: true }); + + var lastHref = window.location.href; + setInterval(function () { + if (window.location.href !== lastHref) { + lastHref = window.location.href; + currentSlug = null; + removeBadge(); + run(); + } + }, 500); +})(); diff --git a/tools/synth-overlay/extension/manifest.json b/tools/synth-overlay/extension/manifest.json new file mode 100644 index 0000000..5f1ff65 --- /dev/null +++ b/tools/synth-overlay/extension/manifest.json @@ -0,0 +1,16 @@ +{ + "manifest_version": 3, + "name": "Synth Overlay", + "version": "1.0.0", + "description": "Live fair-value edge layer for Polymarket using Synth forecasts", + "permissions": ["activeTab"], + "host_permissions": ["https://polymarket.com/*", "http://127.0.0.1:8765/*"], + "content_scripts": [ + { + "matches": ["https://polymarket.com/*"], + "js": ["content.js"], + "css": ["content.css"], + "run_at": "document_idle" + } + ] +} diff --git a/tools/synth-overlay/matcher.py b/tools/synth-overlay/matcher.py new file mode 100644 index 0000000..f6dff63 --- /dev/null +++ b/tools/synth-overlay/matcher.py @@ -0,0 +1,42 @@ +"""Map Polymarket URL/slug to Synth market type and supported asset.""" + +import re +from typing import Literal + +MARKET_DAILY = "daily" +MARKET_HOURLY = "hourly" +MARKET_RANGE = "range" + +_HOURLY_TIME_PATTERN = re.compile(r"\d{1,2}(am|pm)") + + +def normalize_slug(url_or_slug: str) -> str | None: + """Extract market slug from Polymarket URL or return slug as-is if already a slug.""" + if not url_or_slug or not isinstance(url_or_slug, str): + return None + s = url_or_slug.strip() + m = re.search(r"polymarket\.com/(?:event/|market/)?([a-zA-Z0-9-]+)", s) + if m: + return m.group(1) + if re.match(r"^[a-zA-Z0-9-]+$", s): + return s + return None + + +def get_market_type(slug: str) -> Literal["daily", "hourly", "range"] | None: + """Infer Synth market type from slug. Returns None if not recognizable.""" + if not slug: + return None + slug_lower = slug.lower() + if "up-or-down" in slug_lower and _HOURLY_TIME_PATTERN.search(slug_lower): + return MARKET_HOURLY + if "up-or-down" in slug_lower and "on-" in slug_lower: + return MARKET_DAILY + if "price-on" in slug_lower: + return MARKET_RANGE + return None + + +def is_supported(slug: str) -> bool: + """True if slug maps to a Synth-supported market (daily, hourly, or range).""" + return get_market_type(slug) is not None diff --git a/tools/synth-overlay/requirements.txt b/tools/synth-overlay/requirements.txt new file mode 100644 index 0000000..ae490a8 --- /dev/null +++ b/tools/synth-overlay/requirements.txt @@ -0,0 +1 @@ +flask>=2.3.0 diff --git a/tools/synth-overlay/server.py b/tools/synth-overlay/server.py new file mode 100644 index 0000000..fc3fdf3 --- /dev/null +++ b/tools/synth-overlay/server.py @@ -0,0 +1,162 @@ +""" +Local API server for the Synth Overlay extension. +Serves edge data from SynthClient; extension calls this from Polymarket pages. +""" + +import os +import sys + +_here = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.join(_here, "../..")) +if _here not in sys.path: + sys.path.insert(0, _here) + +from flask import Flask, jsonify, request + +from synth_client import SynthClient + +from analyzer import EdgeAnalyzer +from edge import edge_from_range_bracket +from matcher import get_market_type, normalize_slug + +app = Flask(__name__) +_client: SynthClient | None = None + + +def get_client() -> SynthClient: + global _client + if _client is None: + _client = SynthClient() + return _client + + +@app.after_request +def cors_headers(response): + response.headers["Access-Control-Allow-Origin"] = "*" + response.headers["Access-Control-Allow-Methods"] = "GET, OPTIONS" + response.headers["Access-Control-Allow-Headers"] = "Content-Type" + return response + + +@app.route("/api/health", methods=["GET", "OPTIONS"]) +def health(): + if request.method == "OPTIONS": + return "", 204 + return jsonify({"status": "ok", "mock": get_client().mock_mode}) + + +@app.route("/api/edge", methods=["GET", "OPTIONS"]) +def edge(): + if request.method == "OPTIONS": + return "", 204 + raw = request.args.get("slug") or request.args.get("url") or "" + slug = normalize_slug(raw) + if not slug: + return jsonify({"error": "Missing or invalid slug/url"}), 400 + market_type = get_market_type(slug) + if not market_type: + return jsonify({"error": "Unsupported market", "slug": slug}), 404 + try: + client = get_client() + if market_type in ("daily", "hourly"): + 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("BTC", horizon="1h") + pct_24h = client.get_prediction_percentiles("BTC", 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"), + }) + # range + data = client.get_polymarket_range() + if not isinstance(data, list): + return jsonify({"error": "Invalid range data"}), 500 + bracket_title = request.args.get("bracket_title") + brackets = [b for b in data if (b.get("slug") or "").strip() == slug] + if not brackets: + return jsonify({"error": "No brackets for slug", "slug": slug}), 404 + selected = None + if bracket_title: + matched = [b for b in brackets if (b.get("title") or "").strip() == bracket_title.strip()] + if matched: + selected = matched[0] + if selected is None: + selected = max( + brackets, + key=lambda b: float(b.get("polymarket_probability") or 0), + ) + pct_24h = None + try: + pct_24h = client.get_prediction_percentiles("BTC", horizon="24h") + except Exception: + pass + analyzer = EdgeAnalyzer() + result = analyzer.analyze_range(selected, brackets, pct_24h) + bracket_edges = [] + for bracket in brackets: + b_edge, b_signal, b_strength = edge_from_range_bracket(bracket) + bracket_edges.append( + { + "title": bracket.get("title"), + "edge_pct": b_edge, + "signal": b_signal, + "strength": b_strength, + "synth_probability": bracket.get("synth_probability"), + "polymarket_probability": bracket.get("polymarket_probability"), + } + ) + return jsonify({ + "slug": selected.get("slug"), + "horizon": "24h", + "bracket_title": selected.get("title"), + "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": selected.get("synth_probability"), + "polymarket_probability": selected.get("polymarket_probability"), + "current_time": selected.get("current_time"), + "range_brackets": bracket_edges, + }) + except ValueError as e: + return jsonify({"error": str(e)}), 400 + except Exception as e: + return jsonify({"error": "Server error", "detail": str(e)}), 500 + + +def main(): + import warnings + warnings.filterwarnings("ignore", message="No SYNTH_API_KEY") + app.run(host="127.0.0.1", port=8765, debug=False, use_reloader=False) + + +if __name__ == "__main__": + main() diff --git a/tools/synth-overlay/tests/test_analyzer.py b/tools/synth-overlay/tests/test_analyzer.py new file mode 100644 index 0000000..4727efb --- /dev/null +++ b/tools/synth-overlay/tests/test_analyzer.py @@ -0,0 +1,187 @@ +"""Tests for EdgeAnalyzer: dual-horizon analysis, confidence, and explanations.""" + +import sys +import os + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import pytest +from analyzer import EdgeAnalyzer, AnalysisResult, HorizonEdge + + +def _daily(synth_up, market_up): + return {"synth_probability_up": synth_up, "polymarket_probability_up": market_up} + + +def _pct(price, p05, p50, p95): + return { + "current_price": price, + "forecast_future": { + "percentiles": [{"0.05": p05, "0.5": p50, "0.95": p95}], + }, + } + + +class TestEdgeAnalyzer: + def test_analyze_returns_analysis_result(self): + a = EdgeAnalyzer(_daily(0.50, 0.40), _daily(0.45, 0.40)) + r = a.analyze() + assert isinstance(r, AnalysisResult) + assert isinstance(r.primary, HorizonEdge) + assert isinstance(r.secondary, HorizonEdge) + + def test_analyze_primary_horizon_daily(self): + daily = _daily(0.50, 0.40) + hourly = _daily(0.45, 0.40) + r = EdgeAnalyzer(daily, hourly).analyze(primary_horizon="24h") + assert r.primary.horizon == "24h" + assert r.secondary.horizon == "1h" + + def test_analyze_primary_horizon_hourly(self): + daily = _daily(0.50, 0.40) + hourly = _daily(0.45, 0.40) + r = EdgeAnalyzer(daily, hourly).analyze(primary_horizon="1h") + assert r.primary.horizon == "1h" + assert r.secondary.horizon == "24h" + + def test_aligned_strong_edge(self): + daily = _daily(0.60, 0.50) + hourly = _daily(0.58, 0.50) + r = EdgeAnalyzer(daily, hourly).analyze() + assert r.strength == "strong" + assert r.primary.signal == "underpriced" + assert not r.no_trade + + def test_conflicting_signals_no_trade(self): + daily = _daily(0.55, 0.50) + hourly = _daily(0.45, 0.50) + r = EdgeAnalyzer(daily, hourly).analyze() + assert r.strength == "none" + assert r.no_trade is True + assert "conflict" in r.explanation.lower() + + def test_fair_on_both_horizons(self): + daily = _daily(0.50, 0.50) + hourly = _daily(0.50, 0.50) + r = EdgeAnalyzer(daily, hourly).analyze() + assert r.primary.signal == "fair" + assert "agree" in r.explanation.lower() + + def test_missing_data_raises(self): + with pytest.raises(ValueError): + EdgeAnalyzer(None, None).analyze() + + def test_no_trade_on_high_uncertainty(self): + daily = _daily(0.55, 0.50) + hourly = _daily(0.54, 0.50) + pct_wide = _pct(100, 80, 100, 120) + r = EdgeAnalyzer(daily, hourly, pct_wide, pct_wide).analyze() + assert r.no_trade is True + + def test_confidence_high_with_narrow_spread(self): + pct_narrow = _pct(100, 99.5, 100, 100.5) + a = EdgeAnalyzer(_daily(0.55, 0.50), _daily(0.54, 0.50), pct_narrow, pct_narrow) + r = a.analyze() + assert r.confidence_score >= 0.7 + + def test_confidence_low_with_wide_spread(self): + pct_wide = _pct(100, 85, 100, 115) + a = EdgeAnalyzer(_daily(0.55, 0.50), _daily(0.54, 0.50), pct_wide, pct_wide) + r = a.analyze() + assert r.confidence_score <= 0.3 + + +class TestConfidenceScoring: + def test_no_percentiles_returns_default(self): + a = EdgeAnalyzer(_daily(0.5, 0.4), _daily(0.5, 0.4)) + assert a.compute_confidence(None, None) == 0.5 + + def test_very_narrow_returns_one(self): + a = EdgeAnalyzer(_daily(0.5, 0.4), _daily(0.5, 0.4)) + assert a.compute_confidence(0.005, 0.008) == 1.0 + + def test_very_wide_returns_low(self): + a = EdgeAnalyzer(_daily(0.5, 0.4), _daily(0.5, 0.4)) + assert a.compute_confidence(0.15, 0.12) == 0.1 + + def test_moderate_spread(self): + a = EdgeAnalyzer(_daily(0.5, 0.4), _daily(0.5, 0.4)) + score = a.compute_confidence(0.03, 0.04) + assert 0.3 < score < 0.9 + + +class TestExplanations: + def test_explanation_contains_direction(self): + r = EdgeAnalyzer(_daily(0.55, 0.50), _daily(0.54, 0.50)).analyze() + assert "higher" in r.explanation + + def test_invalidation_for_underpriced(self): + r = EdgeAnalyzer(_daily(0.60, 0.50), _daily(0.58, 0.50)).analyze() + assert "drops" in r.invalidation.lower() or "invalidat" in r.invalidation.lower() + + def test_invalidation_for_overpriced(self): + r = EdgeAnalyzer(_daily(0.40, 0.50), _daily(0.42, 0.50)).analyze() + assert "rall" in r.invalidation.lower() or "invalidat" in r.invalidation.lower() + + def test_invalidation_for_fair(self): + r = EdgeAnalyzer(_daily(0.50, 0.50), _daily(0.50, 0.50)).analyze() + assert "no meaningful edge" in r.invalidation.lower() + + def test_bias_mentioned_when_significant(self): + pct = _pct(100, 98, 105, 112) + r = EdgeAnalyzer(_daily(0.55, 0.50), _daily(0.54, 0.50), pct, pct).analyze() + assert "bias" in r.invalidation.lower() + + +def _bracket(title, synth_prob, market_prob): + return { + "slug": "bitcoin-price-on-february-26", + "title": title, + "synth_probability": synth_prob, + "polymarket_probability": market_prob, + "current_time": "2026-02-25T23:45:00+00:00", + } + + +class TestRangeAnalysis: + def test_analyze_range_returns_result(self): + sel = _bracket("[66000, 68000]", 0.38, 0.40) + r = EdgeAnalyzer().analyze_range(sel, [sel]) + assert isinstance(r, AnalysisResult) + assert r.primary.horizon == "24h" + assert r.secondary is None + + def test_range_underpriced(self): + sel = _bracket("[68000, 70000]", 0.34, 0.32) + r = EdgeAnalyzer().analyze_range(sel, [sel]) + assert r.primary.signal == "underpriced" + assert "higher" in r.explanation.lower() + + def test_range_overpriced(self): + sel = _bracket("[66000, 68000]", 0.35, 0.40) + r = EdgeAnalyzer().analyze_range(sel, [sel]) + assert r.primary.signal == "overpriced" + assert "lower" in r.explanation.lower() + + def test_range_fair(self): + sel = _bracket("[66000, 68000]", 0.40, 0.40) + r = EdgeAnalyzer().analyze_range(sel, [sel]) + assert r.primary.signal == "fair" + assert "agree" in r.explanation.lower() + + def test_range_has_explanation_and_invalidation(self): + sel = _bracket("[68000, 70000]", 0.34, 0.32) + r = EdgeAnalyzer().analyze_range(sel, [sel]) + assert len(r.explanation) > 10 + assert len(r.invalidation) > 10 + + def test_range_confidence_with_percentiles(self): + sel = _bracket("[66000, 68000]", 0.38, 0.40) + pct = _pct(67000, 66500, 67000, 67500) + r = EdgeAnalyzer().analyze_range(sel, [sel], pct) + assert r.confidence_score >= 0.7 + + 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 diff --git a/tools/synth-overlay/tests/test_edge.py b/tools/synth-overlay/tests/test_edge.py new file mode 100644 index 0000000..72f47fd --- /dev/null +++ b/tools/synth-overlay/tests/test_edge.py @@ -0,0 +1,133 @@ +"""Tests for edge calculation.""" + +import sys +import os + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import pytest +from edge import ( + compute_edge_pct, + signal_from_edge, + strength_from_edge, + signals_conflict, + strength_from_horizons, + uncertainty_high_from_percentiles, + edge_from_daily_or_hourly, + edge_from_range_bracket, +) + + +def test_compute_edge_pct_positive(): + assert compute_edge_pct(0.50, 0.40) == 10.0 + + +def test_compute_edge_pct_negative(): + assert compute_edge_pct(0.35, 0.45) == -10.0 + + +def test_compute_edge_pct_fair(): + assert compute_edge_pct(0.40, 0.40) == 0.0 + + +def test_compute_edge_pct_invalid_raises(): + with pytest.raises(ValueError): + compute_edge_pct(1.5, 0.5) + with pytest.raises(ValueError): + compute_edge_pct(0.5, -0.1) + + +def test_signal_underpriced(): + assert signal_from_edge(3.0) == "underpriced" + assert signal_from_edge(0.6) == "underpriced" + + +def test_signal_overpriced(): + assert signal_from_edge(-3.0) == "overpriced" + assert signal_from_edge(-0.6) == "overpriced" + + +def test_signal_fair(): + assert signal_from_edge(0.0) == "fair" + assert signal_from_edge(0.4) == "fair" + assert signal_from_edge(-0.4) == "fair" + + +def test_strength_strong(): + assert strength_from_edge(5.0) == "strong" + assert strength_from_edge(-4.0) == "strong" + + +def test_strength_moderate(): + assert strength_from_edge(2.0) == "moderate" + assert strength_from_edge(-1.5) == "moderate" + + +def test_strength_none(): + assert strength_from_edge(0.5) == "none" + assert strength_from_edge(0.0) == "none" + + +def test_edge_from_daily_or_hourly(): + data = {"synth_probability_up": 0.4151, "polymarket_probability_up": 0.395} + edge_pct, signal, strength = edge_from_daily_or_hourly(data) + assert edge_pct == 2.0 + assert signal == "underpriced" + assert strength == "moderate" + + +def test_edge_from_daily_or_hourly_missing_keys(): + with pytest.raises(ValueError): + edge_from_daily_or_hourly({"synth_probability_up": 0.5}) + + +def test_edge_from_range_bracket(): + bracket = {"synth_probability": 0.3773, "polymarket_probability": 0.395} + edge_pct, signal, strength = edge_from_range_bracket(bracket) + assert edge_pct == -1.8 + assert signal == "overpriced" + assert strength == "moderate" + + +def test_signals_conflict(): + assert signals_conflict("underpriced", "overpriced") is True + assert signals_conflict("overpriced", "underpriced") is True + assert signals_conflict("underpriced", "underpriced") is False + assert signals_conflict("fair", "overpriced") is False + assert signals_conflict("underpriced", "fair") is False + + +def test_strength_from_horizons_aligned_strong(): + assert strength_from_horizons(4.0, 5.0) == "strong" + + +def test_strength_from_horizons_aligned_moderate(): + assert strength_from_horizons(1.5, 2.0) == "moderate" + + +def test_strength_from_horizons_conflicting_none(): + assert strength_from_horizons(3.0, -3.0) == "none" + + +def test_strength_from_horizons_weak_none(): + assert strength_from_horizons(0.3, 0.4) == "none" + + +def test_uncertainty_high_from_percentiles_wide_spread(): + data = { + "current_price": 100.0, + "forecast_future": { + "percentiles": [{"0.05": 90.0, "0.95": 115.0}], + }, + } + assert uncertainty_high_from_percentiles(data, relative_spread_threshold=0.05) is True + + +def test_uncertainty_high_from_percentiles_narrow_spread(): + data = { + "current_price": 100.0, + "forecast_future": { + "percentiles": [{"0.05": 99.0, "0.95": 101.0}], + }, + } + assert uncertainty_high_from_percentiles(data, relative_spread_threshold=0.05) is False diff --git a/tools/synth-overlay/tests/test_matcher.py b/tools/synth-overlay/tests/test_matcher.py new file mode 100644 index 0000000..3dcf37a --- /dev/null +++ b/tools/synth-overlay/tests/test_matcher.py @@ -0,0 +1,47 @@ +"""Tests for market slug / URL matcher.""" + +import sys +import os + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from matcher import normalize_slug, get_market_type, is_supported + + +def test_normalize_slug_from_url(): + assert normalize_slug("https://polymarket.com/event/bitcoin-up-or-down-on-february-26") == "bitcoin-up-or-down-on-february-26" + assert normalize_slug("https://polymarket.com/market/bitcoin-price-on-february-26") == "bitcoin-price-on-february-26" + + +def test_normalize_slug_passthrough(): + assert normalize_slug("bitcoin-up-or-down-on-february-26") == "bitcoin-up-or-down-on-february-26" + + +def test_normalize_slug_invalid(): + assert normalize_slug("") is None + assert normalize_slug(None) is None + + +def test_get_market_type_daily(): + assert get_market_type("bitcoin-up-or-down-on-february-26") == "daily" + assert get_market_type("btc-up-or-down-on-march-1") == "daily" + + +def test_get_market_type_hourly(): + assert get_market_type("bitcoin-up-or-down-february-25-6pm-et") == "hourly" + assert get_market_type("bitcoin-up-or-down-february-26-10am-et") == "hourly" + assert get_market_type("btc-up-or-down-march-1-3pm-et") == "hourly" + + +def test_get_market_type_range(): + assert get_market_type("bitcoin-price-on-february-26") == "range" + + +def test_get_market_type_unsupported(): + assert get_market_type("random-slug") is None + + +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("unknown-market") is False diff --git a/tools/synth-overlay/tests/test_server.py b/tools/synth-overlay/tests/test_server.py new file mode 100644 index 0000000..b0aa2ad --- /dev/null +++ b/tools/synth-overlay/tests/test_server.py @@ -0,0 +1,109 @@ +"""Tests for overlay API server (mock client).""" + +import sys +import os +import warnings + +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../..")) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +with warnings.catch_warnings(): + warnings.simplefilter("ignore") + from synth_client import SynthClient + +from server import app + + +@pytest.fixture +def client(): + return app.test_client() + + +def test_health(client): + resp = client.get("/api/health") + assert resp.status_code == 200 + data = resp.get_json() + assert data["status"] == "ok" + assert "mock" in data + + +def test_edge_daily(client): + resp = client.get("/api/edge?slug=bitcoin-up-or-down-on-february-26") + assert resp.status_code == 200 + data = resp.get_json() + assert "edge_pct" in data + assert data["signal"] in ("underpriced", "overpriced", "fair") + assert data["strength"] in ("strong", "moderate", "none") + assert data["horizon"] == "24h" + assert "edge_1h_pct" in data + assert "edge_24h_pct" in data + assert "signal_1h" in data + assert "signal_24h" in data + assert "no_trade_warning" in data + assert "confidence_score" in data + assert 0 <= data["confidence_score"] <= 1 + assert "explanation" in data + assert len(data["explanation"]) > 10 + assert "invalidation" in data + assert len(data["invalidation"]) > 10 + + +def test_edge_hourly_uses_hourly_primary_fields(client): + resp = client.get("/api/edge?slug=bitcoin-up-or-down-february-25-6pm-et") + assert resp.status_code == 200 + data = resp.get_json() + assert data["horizon"] == "1h" + 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 + + +def test_edge_missing_slug(client): + resp = client.get("/api/edge") + assert resp.status_code == 400 + + +def test_edge_unsupported_slug(client): + resp = client.get("/api/edge?slug=unsupported-random-market") + assert resp.status_code == 404 + + +def test_edge_pattern_matched_slug_supported(client): + resp = client.get("/api/edge?slug=btc-up-or-down-on-march-1") + assert resp.status_code == 200 + data = resp.get_json() + assert data["slug"] == "btc-up-or-down-on-march-1" + + +def test_edge_range(client): + resp = client.get("/api/edge?slug=bitcoin-price-on-february-26") + assert resp.status_code == 200 + data = resp.get_json() + assert "edge_pct" in data + assert "bracket_title" in data + assert "no_trade_warning" in data + assert "range_brackets" in data + assert isinstance(data["range_brackets"], list) + assert len(data["range_brackets"]) > 1 + assert "confidence_score" in data + assert 0 <= data["confidence_score"] <= 1 + assert "explanation" in data + assert len(data["explanation"]) > 10 + assert "invalidation" in data + assert len(data["invalidation"]) > 10 + + +def test_edge_range_respects_bracket_title(client): + resp = client.get( + "/api/edge?slug=bitcoin-price-on-february-26&bracket_title=%5B68000%2C%2070000%5D" + ) + assert resp.status_code == 200 + data = resp.get_json() + assert data["bracket_title"] == "[68000, 70000]" + + +def test_edge_range_unknown_slug_404(client): + resp = client.get("/api/edge?slug=bitcoin-price-on-february-26-nonexistent") + assert resp.status_code == 404