From 6b6e3ed6180d1236797978f3bedde0ddd3943b99 Mon Sep 17 00:00:00 2001 From: bitloi Date: Thu, 26 Feb 2026 19:46:23 +0100 Subject: [PATCH 1/7] Add Synth Overlay: Polymarket Edge Extension (Closes #3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - edge.py: compute YES-edge from Synth vs market prob, classify signal (underpriced/fair/overpriced) and strength (strong/moderate/none) - matcher.py: normalize slug from Polymarket URL, infer market type (daily/hourly/range), is_supported() - server.py: Flask API on 127.0.0.1:8765 — GET /api/health, GET /api/edge?slug=... using SynthClient (mock or live) - extension/: MV3 content script on polymarket.com, injects badge + detail card, fetches edge from local server - tests: 26 tests for edge, matcher, server (mock mode) - README: technical doc and run instructions --- tools/synth-overlay/README.md | 34 ++++++ tools/synth-overlay/edge.py | 57 +++++++++ tools/synth-overlay/extension/content.css | 60 +++++++++ tools/synth-overlay/extension/content.js | 114 +++++++++++++++++ tools/synth-overlay/extension/manifest.json | 16 +++ tools/synth-overlay/matcher.py | 53 ++++++++ tools/synth-overlay/requirements.txt | 1 + tools/synth-overlay/server.py | 128 ++++++++++++++++++++ tools/synth-overlay/tests/test_edge.py | 86 +++++++++++++ tools/synth-overlay/tests/test_matcher.py | 45 +++++++ tools/synth-overlay/tests/test_server.py | 57 +++++++++ 11 files changed, 651 insertions(+) create mode 100644 tools/synth-overlay/README.md create mode 100644 tools/synth-overlay/edge.py create mode 100644 tools/synth-overlay/extension/content.css create mode 100644 tools/synth-overlay/extension/content.js create mode 100644 tools/synth-overlay/extension/manifest.json create mode 100644 tools/synth-overlay/matcher.py create mode 100644 tools/synth-overlay/requirements.txt create mode 100644 tools/synth-overlay/server.py create mode 100644 tools/synth-overlay/tests/test_edge.py create mode 100644 tools/synth-overlay/tests/test_matcher.py create mode 100644 tools/synth-overlay/tests/test_server.py diff --git a/tools/synth-overlay/README.md b/tools/synth-overlay/README.md new file mode 100644 index 0000000..9990228 --- /dev/null +++ b/tools/synth-overlay/README.md @@ -0,0 +1,34 @@ +# 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 + +- **Inline badge** next to the decision point: YES Edge +X% (green), -X% (red), or Fair ±0.5% (neutral). +- **Detail card** on click: horizon (1h / 24h), confidence, last update. +- **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)` — available for deeper analysis or confidence (not yet wired in the minimal overlay). + +## 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 badge appears when the server is running and the slug is supported. + +## 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/edge.py b/tools/synth-overlay/edge.py new file mode 100644 index 0000000..aeb5d51 --- /dev/null +++ b/tools/synth-overlay/edge.py @@ -0,0 +1,57 @@ +"""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 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) diff --git a/tools/synth-overlay/extension/content.css b/tools/synth-overlay/extension/content.css new file mode 100644 index 0000000..2958539 --- /dev/null +++ b/tools/synth-overlay/extension/content.css @@ -0,0 +1,60 @@ +.synth-overlay-root { + font-family: system-ui, -apple-system, sans-serif; + font-size: 12px; + z-index: 9999; +} + +.synth-overlay-badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: 6px; + cursor: pointer; + font-weight: 600; + margin: 4px 0; +} + +.synth-overlay-underpriced { + background: rgba(0, 128, 0, 0.15); + color: #0a6b0a; + border: 1px solid rgba(0, 128, 0, 0.4); +} + +.synth-overlay-overpriced { + background: rgba(180, 0, 0, 0.12); + color: #b00; + border: 1px solid rgba(180, 0, 0, 0.4); +} + +.synth-overlay-fair { + background: rgba(100, 100, 100, 0.12); + color: #444; + border: 1px solid rgba(100, 100, 100, 0.3); +} + +.synth-overlay-strength { + font-size: 10px; + text-transform: uppercase; + opacity: 0.85; +} + +.synth-overlay-detail { + padding: 8px 12px; + margin-top: 4px; + border-radius: 6px; + background: #fff; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12); + border: 1px solid #e0e0e0; + max-width: 280px; +} + +.synth-overlay-detail-row { + margin-bottom: 4px; +} + +.synth-overlay-detail-meta { + margin-top: 6px; + font-size: 10px; + color: #888; +} diff --git a/tools/synth-overlay/extension/content.js b/tools/synth-overlay/extension/content.js new file mode 100644 index 0000000..5023ed6 --- /dev/null +++ b/tools/synth-overlay/extension/content.js @@ -0,0 +1,114 @@ +(function () { + "use strict"; + + const API_BASE = "http://127.0.0.1:8765"; + + function slugFromPage() { + const path = window.location.pathname || ""; + const segments = path.split("/").filter(Boolean); + const eventOrMarket = segments[0]; + const slug = segments[1] || segments[0]; + if (eventOrMarket === "event" || eventOrMarket === "market") { + return slug || null; + } + return segments[0] || null; + } + + function createBadge(data) { + const edge = data.edge_pct; + const signal = data.signal; + const strength = data.strength; + const label = + signal === "fair" + ? `Fair ${edge >= 0 ? "+" : ""}${edge}%` + : `YES Edge ${edge >= 0 ? "+" : ""}${edge}%`; + const root = document.createElement("div"); + root.className = "synth-overlay-root"; + root.setAttribute("data-synth-overlay", "badge"); + root.innerHTML = ` +
+ ${escapeHtml(label)} + ${escapeHtml(strength)} +
+ + `; + const badge = root.querySelector(".synth-overlay-badge"); + const detail = root.querySelector(".synth-overlay-detail"); + if (badge) { + badge.addEventListener("click", function (e) { + e.stopPropagation(); + if (detail) detail.hidden = !detail.hidden; + }); + } + return root; + } + + function escapeHtml(s) { + const div = document.createElement("div"); + div.textContent = s; + return div.innerHTML; + } + + function injectBadge(container, data) { + const existing = container.querySelector("[data-synth-overlay=badge]"); + if (existing) existing.remove(); + const badge = createBadge(data); + container.appendChild(badge); + } + + function findInjectionTarget() { + const selectors = [ + "[class*='market']", + "[class*='outcome']", + "main", + "[role='main']", + ".pm-", + ]; + for (const sel of selectors) { + const el = document.querySelector(sel); + if (el && el.offsetParent) return el; + } + 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() { + const slug = slugFromPage(); + if (!slug) return; + fetchEdge(slug).then(function (data) { + if (!data || data.error) return; + const target = findInjectionTarget(); + if (target) injectBadge(target, data); + }); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", run); + } else { + run(); + } + + const observer = new MutationObserver(function () { + if (document.querySelector("[data-synth-overlay=badge]")) return; + run(); + }); + observer.observe(document.body, { childList: true, subtree: true }); +})(); 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..f753e93 --- /dev/null +++ b/tools/synth-overlay/matcher.py @@ -0,0 +1,53 @@ +"""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" + +# Slugs in mock data; real API would return data for the requested market. +MOCK_DAILY_SLUG = "bitcoin-up-or-down-on-february-26" +MOCK_HOURLY_SLUG = "bitcoin-up-or-down-february-25-6pm-et" +MOCK_RANGE_SLUG_PREFIX = "bitcoin-price-on-" + + +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() + # polymarket.com/event/... or .../market/slug + m = re.search(r"polymarket\.com/(?:event/|market/)?([a-zA-Z0-9-]+)", s) + if m: + return m.group(1) + # Already slug-like (alphanumeric and hyphens) + 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 "6pm" in slug_lower: + return MARKET_HOURLY + if "up-or-down" in slug_lower and ("on-" in slug_lower or "february" in slug_lower): + return MARKET_DAILY + if "price-on" in slug_lower or "price-on-" in slug_lower: + return MARKET_RANGE + if slug_lower == MOCK_DAILY_SLUG: + return MARKET_DAILY + if slug_lower == MOCK_HOURLY_SLUG: + return MARKET_HOURLY + if MOCK_RANGE_SLUG_PREFIX 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..b50d3aa --- /dev/null +++ b/tools/synth-overlay/server.py @@ -0,0 +1,128 @@ +""" +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 edge import edge_from_daily_or_hourly, 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 == "daily": + data = client.get_polymarket_daily() + edge_pct, signal, strength = edge_from_daily_or_hourly(data) + return jsonify({ + "slug": data.get("slug"), + "horizon": "24h", + "edge_pct": edge_pct, + "signal": signal, + "strength": strength, + "synth_probability_up": data.get("synth_probability_up"), + "polymarket_probability_up": data.get("polymarket_probability_up"), + "current_time": data.get("current_time"), + }) + if market_type == "hourly": + data = client.get_polymarket_hourly() + edge_pct, signal, strength = edge_from_daily_or_hourly(data) + return jsonify({ + "slug": data.get("slug"), + "horizon": "1h", + "edge_pct": edge_pct, + "signal": signal, + "strength": strength, + "synth_probability_up": data.get("synth_probability_up"), + "polymarket_probability_up": data.get("polymarket_probability_up"), + "current_time": 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") == slug] + if not brackets and data: + brackets = [b for b in data if slug in (b.get("slug") or "")] + if not brackets and data: + brackets = data + if not brackets: + return jsonify({"error": "No brackets for slug", "slug": slug}), 404 + if bracket_title: + matched = [b for b in brackets if (b.get("title") or "").strip() == bracket_title.strip()] + if matched: + brackets = matched + first = brackets[0] + edge_pct, signal, strength = edge_from_range_bracket(first) + return jsonify({ + "slug": first.get("slug"), + "horizon": "24h", + "bracket_title": first.get("title"), + "edge_pct": edge_pct, + "signal": signal, + "strength": strength, + "synth_probability": first.get("synth_probability"), + "polymarket_probability": first.get("polymarket_probability"), + "current_time": first.get("current_time"), + }) + 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_edge.py b/tools/synth-overlay/tests/test_edge.py new file mode 100644 index 0000000..7ad415e --- /dev/null +++ b/tools/synth-overlay/tests/test_edge.py @@ -0,0 +1,86 @@ +"""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, + 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" diff --git a/tools/synth-overlay/tests/test_matcher.py b/tools/synth-overlay/tests/test_matcher.py new file mode 100644 index 0000000..3ca6dba --- /dev/null +++ b/tools/synth-overlay/tests/test_matcher.py @@ -0,0 +1,45 @@ +"""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" + + +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..f7b4d09 --- /dev/null +++ b/tools/synth-overlay/tests/test_server.py @@ -0,0 +1,57 @@ +"""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" + + +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_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 From 68e07d80adb895cf2f35fdc45b6042596b02bdd4 Mon Sep 17 00:00:00 2001 From: bitloi Date: Thu, 26 Feb 2026 20:30:12 +0100 Subject: [PATCH 2/7] Add EdgeAnalyzer, confidence scoring, explanations, and slide-out panel - analyzer.py: EdgeAnalyzer class with dual-horizon analysis, percentile-based confidence scoring (0-1), signal explanations, and invalidation conditions - server.py: wired through EdgeAnalyzer; API returns confidence_score, explanation, invalidation alongside edge/signal/strength - content.js: slide-out panel with full analysis (why signal exists, what invalidates it, confidence bar); fixed MutationObserver infinite loop; SPA navigation handled via URL polling + slug tracking - content.css: slide-out panel, confidence bar, hover states - tests: 56 total (19 new analyzer tests covering aligned/conflicting/fair signals, confidence scoring, explanations, invalidation, bias detection) --- tools/synth-overlay/analyzer.py | 192 ++++++++++++++++ tools/synth-overlay/edge.py | 49 ++++ tools/synth-overlay/extension/content.css | 121 +++++++++- tools/synth-overlay/extension/content.js | 249 +++++++++++++++++---- tools/synth-overlay/matcher.py | 7 - tools/synth-overlay/server.py | 106 +++++---- tools/synth-overlay/tests/test_analyzer.py | 133 +++++++++++ tools/synth-overlay/tests/test_edge.py | 47 ++++ tools/synth-overlay/tests/test_server.py | 44 ++++ 9 files changed, 850 insertions(+), 98 deletions(-) create mode 100644 tools/synth-overlay/analyzer.py create mode 100644 tools/synth-overlay/tests/test_analyzer.py diff --git a/tools/synth-overlay/analyzer.py b/tools/synth-overlay/analyzer.py new file mode 100644 index 0000000..144c35b --- /dev/null +++ b/tools/synth-overlay/analyzer.py @@ -0,0 +1,192 @@ +""" +EdgeAnalyzer: consolidated dual-horizon edge analysis with confidence scoring +and human-readable signal explanations using Synth forecast percentiles. +""" + +from dataclasses import dataclass, field +from typing import Literal + +from edge import ( + compute_edge_pct, + signal_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(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 index aeb5d51..08196c7 100644 --- a/tools/synth-overlay/edge.py +++ b/tools/synth-overlay/edge.py @@ -37,6 +37,34 @@ def strength_from_edge( 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") @@ -55,3 +83,24 @@ def edge_from_range_bracket(bracket: dict) -> tuple[float, str, str]: 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 index 2958539..9a124e5 100644 --- a/tools/synth-overlay/extension/content.css +++ b/tools/synth-overlay/extension/content.css @@ -13,6 +13,10 @@ cursor: pointer; font-weight: 600; margin: 4px 0; + transition: box-shadow 0.15s; +} +.synth-overlay-badge:hover { + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.18); } .synth-overlay-underpriced { @@ -20,13 +24,11 @@ color: #0a6b0a; border: 1px solid rgba(0, 128, 0, 0.4); } - .synth-overlay-overpriced { background: rgba(180, 0, 0, 0.12); color: #b00; border: 1px solid rgba(180, 0, 0, 0.4); } - .synth-overlay-fair { background: rgba(100, 100, 100, 0.12); color: #444; @@ -48,13 +50,124 @@ border: 1px solid #e0e0e0; max-width: 280px; } - .synth-overlay-detail-row { margin-bottom: 4px; } - .synth-overlay-detail-meta { margin-top: 6px; font-size: 10px; color: #888; } +.synth-overlay-detail-expand { + margin-top: 6px; + font-size: 11px; + color: #2563eb; + cursor: pointer; + font-weight: 500; +} +.synth-overlay-detail-expand:hover { + text-decoration: underline; +} + +.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; + background: linear-gradient(90deg, #ef4444 0%, #f59e0b 40%, #22c55e 100%); + transition: width 0.3s; +} + +/* 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; +} diff --git a/tools/synth-overlay/extension/content.js b/tools/synth-overlay/extension/content.js index 5023ed6..6fec44a 100644 --- a/tools/synth-overlay/extension/content.js +++ b/tools/synth-overlay/extension/content.js @@ -1,83 +1,202 @@ (function () { "use strict"; - const API_BASE = "http://127.0.0.1:8765"; + var API_BASE = "http://127.0.0.1:8765"; + var currentSlug = null; function slugFromPage() { - const path = window.location.pathname || ""; - const segments = path.split("/").filter(Boolean); - const eventOrMarket = segments[0]; - const slug = segments[1] || segments[0]; - if (eventOrMarket === "event" || eventOrMarket === "market") { - return slug || null; + 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 segments[0] || 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 escapeHtml(s) { + var div = document.createElement("div"); + div.textContent = s; + return div.innerHTML; } function createBadge(data) { - const edge = data.edge_pct; - const signal = data.signal; - const strength = data.strength; - const label = - signal === "fair" - ? `Fair ${edge >= 0 ? "+" : ""}${edge}%` - : `YES Edge ${edge >= 0 ? "+" : ""}${edge}%`; - const root = document.createElement("div"); + var edge = data.edge_pct; + var signal = data.signal; + var strength = data.strength; + var label = formatLabel(signal, edge); + + var hasDual = data.edge_1h_pct != null && data.edge_24h_pct != null; + var now1h = hasDual ? formatLabel(data.signal_1h, data.edge_1h_pct) : label; + var byClose24h = hasDual ? formatLabel(data.signal_24h, data.edge_24h_pct) : label; + + var confScore = data.confidence_score != null ? data.confidence_score : 0.5; + var confText = confidenceLabel(confScore); + var barWidth = confidenceBarWidth(confScore); + + var noTradeRow = data.no_trade_warning + ? '
' + + "No trade \u2014 uncertainty high or signals conflict.
" + : ""; + + var root = document.createElement("div"); root.className = "synth-overlay-root"; root.setAttribute("data-synth-overlay", "badge"); - root.innerHTML = ` -
- ${escapeHtml(label)} - ${escapeHtml(strength)} -
- - `; - const badge = root.querySelector(".synth-overlay-badge"); - const detail = root.querySelector(".synth-overlay-detail"); + + root.innerHTML = + '
' + + '' + escapeHtml(label) + "" + + '' + escapeHtml(strength) + "" + + "
" + + '"; + + var badge = root.querySelector(".synth-overlay-badge"); + var detail = root.querySelector(".synth-overlay-detail"); + var expandBtn = root.querySelector(".synth-overlay-detail-expand"); + if (badge) { badge.addEventListener("click", function (e) { e.stopPropagation(); if (detail) detail.hidden = !detail.hidden; }); } + + if (expandBtn) { + expandBtn.addEventListener("click", function (e) { + e.stopPropagation(); + showPanel(data); + }); + } + return root; } - function escapeHtml(s) { - const div = document.createElement("div"); - div.textContent = s; - return div.innerHTML; + function showPanel(data) { + closePanel(); + 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); + + panel.innerHTML = + '
' + + 'Synth Analysis' + + '\u2715' + + "
" + + '
' + + '
' + + '
Signal
' + + '
Now (1h): ' + escapeHtml(now1h) + "
" + + '
By close (24h): ' + escapeHtml(byClose24h) + "
" + + '
Strength: ' + escapeHtml(data.strength) + "
" + + "
" + + '
' + + '
Confidence
' + + '
' + + '
' + + "
" + + '
' + escapeHtml(confidenceLabel(confScore)) + + " (" + Math.round(confScore * 100) + "%)
" + + "
" + + '
' + + '
Why this signal exists
' + + '
' + escapeHtml(explanation) + "
" + + "
" + + (invalidation + ? '
' + + '
What would invalidate it
' + + '
' + escapeHtml(invalidation) + "
" + + "
" + : "") + + (data.no_trade_warning + ? '
' + + "No trade \u2014 uncertainty is high or signals conflict." + + "
" + : "") + + '
Last update: ' + + escapeHtml(data.current_time || "unknown") + "
" + + "
"; + + var closeBtn = panel.querySelector(".synth-overlay-panel-close"); + if (closeBtn) { + closeBtn.addEventListener("click", function (e) { + e.stopPropagation(); + closePanel(); + }); + } + 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(); } function injectBadge(container, data) { - const existing = container.querySelector("[data-synth-overlay=badge]"); - if (existing) existing.remove(); - const badge = createBadge(data); + removeBadge(); + var badge = createBadge(data); container.appendChild(badge); } + function removeBadge() { + var badges = document.querySelectorAll("[data-synth-overlay=badge]"); + for (var i = 0; i < badges.length; i++) badges[i].remove(); + closePanel(); + } + function findInjectionTarget() { - const selectors = [ + var selectors = [ "[class*='market']", "[class*='outcome']", "main", "[role='main']", - ".pm-", ]; - for (const sel of selectors) { - const el = document.querySelector(sel); + for (var i = 0; i < selectors.length; i++) { + var el = document.querySelector(selectors[i]); if (el && el.offsetParent) return el; } return document.body; } function fetchEdge(slug) { - return fetch(`${API_BASE}/api/edge?slug=${encodeURIComponent(slug)}`, { + return fetch(API_BASE + "/api/edge?slug=" + encodeURIComponent(slug), { method: "GET", mode: "cors", }) @@ -91,24 +210,56 @@ } function run() { - const slug = slugFromPage(); - if (!slug) return; + var slug = slugFromPage(); + if (!slug) { + currentSlug = null; + removeBadge(); + return; + } fetchEdge(slug).then(function (data) { - if (!data || data.error) return; - const target = findInjectionTarget(); + if (!data || data.error) { + currentSlug = null; + removeBadge(); + return; + } + currentSlug = slug; + 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(); } - const observer = new MutationObserver(function () { - if (document.querySelector("[data-synth-overlay=badge]")) return; - run(); + var observer = new MutationObserver(function () { + var slug = slugFromPage(); + if (slug === currentSlug && document.querySelector("[data-synth-overlay=badge]")) { + 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/matcher.py b/tools/synth-overlay/matcher.py index f753e93..1e48830 100644 --- a/tools/synth-overlay/matcher.py +++ b/tools/synth-overlay/matcher.py @@ -7,9 +7,6 @@ MARKET_HOURLY = "hourly" MARKET_RANGE = "range" -# Slugs in mock data; real API would return data for the requested market. -MOCK_DAILY_SLUG = "bitcoin-up-or-down-on-february-26" -MOCK_HOURLY_SLUG = "bitcoin-up-or-down-february-25-6pm-et" MOCK_RANGE_SLUG_PREFIX = "bitcoin-price-on-" @@ -39,10 +36,6 @@ def get_market_type(slug: str) -> Literal["daily", "hourly", "range"] | None: return MARKET_DAILY if "price-on" in slug_lower or "price-on-" in slug_lower: return MARKET_RANGE - if slug_lower == MOCK_DAILY_SLUG: - return MARKET_DAILY - if slug_lower == MOCK_HOURLY_SLUG: - return MARKET_HOURLY if MOCK_RANGE_SLUG_PREFIX in slug_lower: return MARKET_RANGE return None diff --git a/tools/synth-overlay/server.py b/tools/synth-overlay/server.py index b50d3aa..4b6892d 100644 --- a/tools/synth-overlay/server.py +++ b/tools/synth-overlay/server.py @@ -15,7 +15,8 @@ from synth_client import SynthClient -from edge import edge_from_daily_or_hourly, edge_from_range_bracket +from analyzer import EdgeAnalyzer +from edge import edge_from_range_bracket from matcher import get_market_type, normalize_slug app = Flask(__name__) @@ -57,60 +58,89 @@ def edge(): return jsonify({"error": "Unsupported market", "slug": slug}), 404 try: client = get_client() - if market_type == "daily": - data = client.get_polymarket_daily() - edge_pct, signal, strength = edge_from_daily_or_hourly(data) + if market_type in ("daily", "hourly"): + daily_data = client.get_polymarket_daily() + hourly_data = client.get_polymarket_hourly() + expected_daily_slug = (daily_data.get("slug") or "").strip().lower() + expected_hourly_slug = (hourly_data.get("slug") or "").strip().lower() + request_slug = slug.strip().lower() + if market_type == "daily" and request_slug != expected_daily_slug: + return jsonify({"error": "Unsupported market", "slug": slug}), 404 + if market_type == "hourly" and request_slug != expected_hourly_slug: + return jsonify({"error": "Unsupported market", "slug": slug}), 404 + 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({ - "slug": data.get("slug"), - "horizon": "24h", - "edge_pct": edge_pct, - "signal": signal, - "strength": strength, - "synth_probability_up": data.get("synth_probability_up"), - "polymarket_probability_up": data.get("polymarket_probability_up"), - "current_time": data.get("current_time"), - }) - if market_type == "hourly": - data = client.get_polymarket_hourly() - edge_pct, signal, strength = edge_from_daily_or_hourly(data) - return jsonify({ - "slug": data.get("slug"), - "horizon": "1h", - "edge_pct": edge_pct, - "signal": signal, - "strength": strength, - "synth_probability_up": data.get("synth_probability_up"), - "polymarket_probability_up": data.get("polymarket_probability_up"), - "current_time": data.get("current_time"), + "slug": primary_data.get("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") == slug] - if not brackets and data: - brackets = [b for b in data if slug in (b.get("slug") or "")] - if not brackets and data: - brackets = data + 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: - brackets = matched - first = brackets[0] - edge_pct, signal, strength = edge_from_range_bracket(first) + selected = matched[0] + if selected is None: + selected = max( + brackets, + key=lambda b: float(b.get("polymarket_probability") or 0), + ) + edge_pct, signal, strength = edge_from_range_bracket(selected) + 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": first.get("slug"), + "slug": selected.get("slug"), "horizon": "24h", - "bracket_title": first.get("title"), + "bracket_title": selected.get("title"), "edge_pct": edge_pct, "signal": signal, "strength": strength, - "synth_probability": first.get("synth_probability"), - "polymarket_probability": first.get("polymarket_probability"), - "current_time": first.get("current_time"), + "no_trade_warning": strength == "none", + "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 diff --git a/tools/synth-overlay/tests/test_analyzer.py b/tools/synth-overlay/tests/test_analyzer.py new file mode 100644 index 0000000..0f546f6 --- /dev/null +++ b/tools/synth-overlay/tests/test_analyzer.py @@ -0,0 +1,133 @@ +"""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() diff --git a/tools/synth-overlay/tests/test_edge.py b/tools/synth-overlay/tests/test_edge.py index 7ad415e..72f47fd 100644 --- a/tools/synth-overlay/tests/test_edge.py +++ b/tools/synth-overlay/tests/test_edge.py @@ -10,6 +10,9 @@ 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, ) @@ -84,3 +87,47 @@ def test_edge_from_range_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_server.py b/tools/synth-overlay/tests/test_server.py index f7b4d09..e223961 100644 --- a/tools/synth-overlay/tests/test_server.py +++ b/tools/synth-overlay/tests/test_server.py @@ -37,6 +37,27 @@ def test_edge_daily(client): 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): @@ -49,9 +70,32 @@ def test_edge_unsupported_slug(client): assert resp.status_code == 404 +def test_edge_pattern_matched_but_unavailable_slug_404(client): + resp = client.get("/api/edge?slug=btc-up-or-down-on-march-1") + assert resp.status_code == 404 + + 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 + + +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 From fb0c3cefc9d49731a3a69f7bd974f40b6762ed60 Mon Sep 17 00:00:00 2001 From: bitloi Date: Thu, 26 Feb 2026 20:36:58 +0100 Subject: [PATCH 3/7] Fix stale response race, add range analysis, clean up matcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - content.js: capture requestedSlug before fetch, discard response if page slug changed during network round-trip (prevents stale overlay on fast SPA navigation) - analyzer.py: add analyze_range() with range-specific explanation, invalidation, and percentile-based confidence for range markets - server.py: wire analyze_range for range endpoint; range API now returns confidence_score, explanation, invalidation like up/down - matcher.py: remove dead code (redundant conditions, unused constant); broaden hourly detection from hardcoded "6pm" to regex matching any time pattern (e.g. 10am, 3pm) - tests: 63 total — 7 new range analysis tests, broader hourly matcher tests, range server response now asserts analysis fields --- tools/synth-overlay/analyzer.py | 102 +++++++++++++++++++++ tools/synth-overlay/extension/content.js | 4 +- tools/synth-overlay/matcher.py | 12 +-- tools/synth-overlay/server.py | 19 +++- tools/synth-overlay/tests/test_analyzer.py | 54 +++++++++++ tools/synth-overlay/tests/test_matcher.py | 2 + tools/synth-overlay/tests/test_server.py | 6 ++ 7 files changed, 185 insertions(+), 14 deletions(-) diff --git a/tools/synth-overlay/analyzer.py b/tools/synth-overlay/analyzer.py index 144c35b..657f7c8 100644 --- a/tools/synth-overlay/analyzer.py +++ b/tools/synth-overlay/analyzer.py @@ -9,6 +9,7 @@ from edge import ( compute_edge_pct, signal_from_edge, + strength_from_edge, signals_conflict, strength_from_horizons, ) @@ -23,6 +24,15 @@ class HorizonEdge: market_prob: float +@dataclass +class BracketEdge: + title: str + edge_pct: float + signal: str + synth_prob: float + market_prob: float + + @dataclass class AnalysisResult: primary: HorizonEdge @@ -154,6 +164,98 @@ def _build_invalidation(self, edge_24h: HorizonEdge, bias_24h: float | None) -> 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, mispriced, 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_brackets: list[dict], + 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 len(mispriced_brackets) > 1: + parts.append( + f"{len(mispriced_brackets)} of {len(mispriced_brackets)} " + f"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") diff --git a/tools/synth-overlay/extension/content.js b/tools/synth-overlay/extension/content.js index 6fec44a..50aaadf 100644 --- a/tools/synth-overlay/extension/content.js +++ b/tools/synth-overlay/extension/content.js @@ -216,13 +216,15 @@ removeBadge(); return; } + var requestedSlug = slug; fetchEdge(slug).then(function (data) { + if (slugFromPage() !== requestedSlug) return; if (!data || data.error) { currentSlug = null; removeBadge(); return; } - currentSlug = slug; + currentSlug = requestedSlug; var target = findInjectionTarget(); if (target) injectBadge(target, data); }); diff --git a/tools/synth-overlay/matcher.py b/tools/synth-overlay/matcher.py index 1e48830..f6dff63 100644 --- a/tools/synth-overlay/matcher.py +++ b/tools/synth-overlay/matcher.py @@ -7,7 +7,7 @@ MARKET_HOURLY = "hourly" MARKET_RANGE = "range" -MOCK_RANGE_SLUG_PREFIX = "bitcoin-price-on-" +_HOURLY_TIME_PATTERN = re.compile(r"\d{1,2}(am|pm)") def normalize_slug(url_or_slug: str) -> str | None: @@ -15,11 +15,9 @@ def normalize_slug(url_or_slug: str) -> str | None: if not url_or_slug or not isinstance(url_or_slug, str): return None s = url_or_slug.strip() - # polymarket.com/event/... or .../market/slug m = re.search(r"polymarket\.com/(?:event/|market/)?([a-zA-Z0-9-]+)", s) if m: return m.group(1) - # Already slug-like (alphanumeric and hyphens) if re.match(r"^[a-zA-Z0-9-]+$", s): return s return None @@ -30,13 +28,11 @@ def get_market_type(slug: str) -> Literal["daily", "hourly", "range"] | None: if not slug: return None slug_lower = slug.lower() - if "up-or-down" in slug_lower and "6pm" in 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 or "february" in slug_lower): + if "up-or-down" in slug_lower and "on-" in slug_lower: return MARKET_DAILY - if "price-on" in slug_lower or "price-on-" in slug_lower: - return MARKET_RANGE - if MOCK_RANGE_SLUG_PREFIX in slug_lower: + if "price-on" in slug_lower: return MARKET_RANGE return None diff --git a/tools/synth-overlay/server.py b/tools/synth-overlay/server.py index 4b6892d..c21aa7b 100644 --- a/tools/synth-overlay/server.py +++ b/tools/synth-overlay/server.py @@ -115,7 +115,13 @@ def edge(): brackets, key=lambda b: float(b.get("polymarket_probability") or 0), ) - edge_pct, signal, strength = edge_from_range_bracket(selected) + 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) @@ -133,10 +139,13 @@ def edge(): "slug": selected.get("slug"), "horizon": "24h", "bracket_title": selected.get("title"), - "edge_pct": edge_pct, - "signal": signal, - "strength": strength, - "no_trade_warning": strength == "none", + "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"), diff --git a/tools/synth-overlay/tests/test_analyzer.py b/tools/synth-overlay/tests/test_analyzer.py index 0f546f6..4727efb 100644 --- a/tools/synth-overlay/tests/test_analyzer.py +++ b/tools/synth-overlay/tests/test_analyzer.py @@ -131,3 +131,57 @@ 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_matcher.py b/tools/synth-overlay/tests/test_matcher.py index 3ca6dba..3dcf37a 100644 --- a/tools/synth-overlay/tests/test_matcher.py +++ b/tools/synth-overlay/tests/test_matcher.py @@ -29,6 +29,8 @@ def test_get_market_type_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(): diff --git a/tools/synth-overlay/tests/test_server.py b/tools/synth-overlay/tests/test_server.py index e223961..4524689 100644 --- a/tools/synth-overlay/tests/test_server.py +++ b/tools/synth-overlay/tests/test_server.py @@ -85,6 +85,12 @@ def test_edge_range(client): 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): From 51424ececa0bf707de8e06ca99f18df1019bfd75 Mon Sep 17 00:00:00 2001 From: bitloi Date: Thu, 26 Feb 2026 20:38:37 +0100 Subject: [PATCH 4/7] Fix range explanation bug and remove dead code in analyzer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _build_range_explanation: fix "X of X" bug — now correctly shows mispriced count vs total bracket count (e.g. "8 of 11") - Remove unused BracketEdge dataclass - Remove unused 'field' import from dataclasses --- tools/synth-overlay/analyzer.py | 21 ++++++--------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/tools/synth-overlay/analyzer.py b/tools/synth-overlay/analyzer.py index 657f7c8..d4134e6 100644 --- a/tools/synth-overlay/analyzer.py +++ b/tools/synth-overlay/analyzer.py @@ -3,7 +3,7 @@ and human-readable signal explanations using Synth forecast percentiles. """ -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import Literal from edge import ( @@ -24,15 +24,6 @@ class HorizonEdge: market_prob: float -@dataclass -class BracketEdge: - title: str - edge_pct: float - signal: str - synth_prob: float - market_prob: float - - @dataclass class AnalysisResult: primary: HorizonEdge @@ -188,7 +179,7 @@ def analyze_range( 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, mispriced, confidence + title, edge_pct, signal, len(mispriced), len(all_brackets), confidence ) invalidation = self._build_range_invalidation( selected_bracket, signal @@ -216,7 +207,8 @@ def _build_range_explanation( title: str, edge_pct: float, signal: str, - mispriced_brackets: list[dict], + mispriced_count: int, + total_count: int, confidence: float, ) -> str: parts = [] @@ -231,10 +223,9 @@ def _build_range_explanation( f"Bracket {title}: Synth assigns {direction} probability " f"than Polymarket by {abs(edge_pct):.1f}pp." ) - if len(mispriced_brackets) > 1: + if mispriced_count > 1: parts.append( - f"{len(mispriced_brackets)} of {len(mispriced_brackets)} " - f"brackets show mispricing." + f"{mispriced_count} of {total_count} brackets show mispricing." ) if confidence >= 0.7: parts.append("Forecast distribution is narrow — high confidence.") From 088cce45a54d521d349c94fe3c084017253b9fb5 Mon Sep 17 00:00:00 2001 From: bitloi Date: Thu, 26 Feb 2026 21:17:01 +0100 Subject: [PATCH 5/7] synth-overlay: fixed badge position, human-readable timestamps, README verification - Position overlay badge fixed top-right (always visible on Polymarket) - Format API timestamps as 'Feb 25, 11:45 PM UTC' in detail card and panel - Add 'Verify the overlay (before recording)' section to README --- tools/synth-overlay/README.md | 21 +++++++++++++++- tools/synth-overlay/extension/content.css | 3 +++ tools/synth-overlay/extension/content.js | 30 ++++++++++++++--------- 3 files changed, 41 insertions(+), 13 deletions(-) diff --git a/tools/synth-overlay/README.md b/tools/synth-overlay/README.md index 9990228..23add6c 100644 --- a/tools/synth-overlay/README.md +++ b/tools/synth-overlay/README.md @@ -20,7 +20,7 @@ Chrome extension that adds a live "fair value" layer on Polymarket market pages - `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)` — available for deeper analysis or confidence (not yet wired in the minimal overlay). +- `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 @@ -29,6 +29,25 @@ Chrome extension that adds a live "fair value" layer on Polymarket market pages 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 badge 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` + - The extension reads the slug from the path and calls the API. If the API returned 200 in step 1, the **badge** is appended to the page (near the first element with class containing "market" or "outcome", or `main`, or the top of the body). + +3. **Interaction:** + - **Badge** = inline pill (e.g. "YES Edge +2.1%" or "Fair +0.3%") with strength (Strong/Moderate). + - **Click the badge** → toggles the **detail card** (Now 1h / By close 24h, confidence bar, optional "No trade" row). + - **Click "Details ▶"** in the card → opens the **slide-out panel** (explanation, invalidation, full confidence). + +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/extension/content.css b/tools/synth-overlay/extension/content.css index 9a124e5..78aef3b 100644 --- a/tools/synth-overlay/extension/content.css +++ b/tools/synth-overlay/extension/content.css @@ -2,6 +2,9 @@ font-family: system-ui, -apple-system, sans-serif; font-size: 12px; z-index: 9999; + position: fixed; + top: 72px; + right: 24px; } .synth-overlay-badge { diff --git a/tools/synth-overlay/extension/content.js b/tools/synth-overlay/extension/content.js index 50aaadf..ea34a2a 100644 --- a/tools/synth-overlay/extension/content.js +++ b/tools/synth-overlay/extension/content.js @@ -31,6 +31,22 @@ return Math.max(5, Math.min(100, Math.round(score * 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; @@ -72,7 +88,7 @@ '
' + "" + noTradeRow + - '
' + escapeHtml(data.current_time || "") + "
" + + '
' + escapeHtml(formatTime(data.current_time) || "") + "
" + '
Details \u25B6
' + ""; @@ -148,7 +164,7 @@ "" : "") + '
Last update: ' + - escapeHtml(data.current_time || "unknown") + "
" + + escapeHtml(formatTime(data.current_time) || "unknown") + "" + ""; var closeBtn = panel.querySelector(".synth-overlay-panel-close"); @@ -182,16 +198,6 @@ } function findInjectionTarget() { - var selectors = [ - "[class*='market']", - "[class*='outcome']", - "main", - "[role='main']", - ]; - for (var i = 0; i < selectors.length; i++) { - var el = document.querySelector(selectors[i]); - if (el && el.offsetParent) return el; - } return document.body; } From ce49cc80aad5036b21da8c88110400db2ec038ed Mon Sep 17 00:00:00 2001 From: bitloi Date: Thu, 26 Feb 2026 21:20:26 +0100 Subject: [PATCH 6/7] synth-overlay: fix verification curl to use local API, add hourly example URL --- tools/synth-overlay/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/tools/synth-overlay/README.md b/tools/synth-overlay/README.md index 23add6c..8da7e7b 100644 --- a/tools/synth-overlay/README.md +++ b/tools/synth-overlay/README.md @@ -39,6 +39,7 @@ Chrome extension that adds a live "fair value" layer on Polymarket market pages 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 **badge** is appended to the page (near the first element with class containing "market" or "outcome", or `main`, or the top of the body). 3. **Interaction:** From e7157e895a7daaa21ad562bd9c6cbacba7f84248 Mon Sep 17 00:00:00 2001 From: bitloi Date: Fri, 27 Feb 2026 08:17:22 +0100 Subject: [PATCH 7/7] Synth overlay: side panel, data visibility, confidence colors, actionable bar - Side panel (not floating card); Synth tab opens panel - Data & Analysis: market YES, Synth FV, edge %, explanation - Confidence bar: discrete colors (45% amber, not green) - Action bar near trade widget: Up/Down guidance + FV/MKT numbers - Server: accept hourly/daily slugs by pattern (active markets) - README and PR description with demo link --- tools/synth-overlay/README.md | 16 +- tools/synth-overlay/extension/content.css | 128 ++++++------- tools/synth-overlay/extension/content.js | 212 +++++++++++++++------- tools/synth-overlay/server.py | 11 +- tools/synth-overlay/tests/test_server.py | 6 +- 5 files changed, 218 insertions(+), 155 deletions(-) diff --git a/tools/synth-overlay/README.md b/tools/synth-overlay/README.md index 8da7e7b..8afb6be 100644 --- a/tools/synth-overlay/README.md +++ b/tools/synth-overlay/README.md @@ -4,8 +4,10 @@ Chrome extension that adds a live "fair value" layer on Polymarket market pages ## What it does -- **Inline badge** next to the decision point: YES Edge +X% (green), -X% (red), or Fair ±0.5% (neutral). -- **Detail card** on click: horizon (1h / 24h), confidence, last update. +- **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 @@ -27,7 +29,7 @@ Chrome extension that adds a live "fair value" layer on Polymarket market pages 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 badge appears when the server is running and the slug is supported. +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) @@ -40,12 +42,12 @@ Chrome extension that adds a live "fair value" layer on Polymarket market pages 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 **badge** is appended to the page (near the first element with class containing "market" or "outcome", or `main`, or the top of the body). + - 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:** - - **Badge** = inline pill (e.g. "YES Edge +2.1%" or "Fair +0.3%") with strength (Strong/Moderate). - - **Click the badge** → toggles the **detail card** (Now 1h / By close 24h, confidence bar, optional "No trade" row). - - **Click "Details ▶"** in the card → opens the **slide-out panel** (explanation, invalidation, full confidence). + - **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. diff --git a/tools/synth-overlay/extension/content.css b/tools/synth-overlay/extension/content.css index 78aef3b..68db081 100644 --- a/tools/synth-overlay/extension/content.css +++ b/tools/synth-overlay/extension/content.css @@ -1,75 +1,28 @@ -.synth-overlay-root { +/* Side panel tab (persistent trigger) */ +.synth-overlay-tab { font-family: system-ui, -apple-system, sans-serif; - font-size: 12px; - z-index: 9999; + font-size: 11px; + font-weight: 600; + z-index: 9998; position: fixed; - top: 72px; - right: 24px; -} - -.synth-overlay-badge { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 4px 10px; - border-radius: 6px; + 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; - font-weight: 600; - margin: 4px 0; - transition: box-shadow 0.15s; -} -.synth-overlay-badge:hover { - box-shadow: 0 1px 4px rgba(0, 0, 0, 0.18); -} - -.synth-overlay-underpriced { - background: rgba(0, 128, 0, 0.15); - color: #0a6b0a; - border: 1px solid rgba(0, 128, 0, 0.4); -} -.synth-overlay-overpriced { - background: rgba(180, 0, 0, 0.12); - color: #b00; - border: 1px solid rgba(180, 0, 0, 0.4); -} -.synth-overlay-fair { - background: rgba(100, 100, 100, 0.12); - color: #444; - border: 1px solid rgba(100, 100, 100, 0.3); -} - -.synth-overlay-strength { - font-size: 10px; - text-transform: uppercase; - opacity: 0.85; -} - -.synth-overlay-detail { - padding: 8px 12px; - margin-top: 4px; - border-radius: 6px; - background: #fff; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12); - border: 1px solid #e0e0e0; - max-width: 280px; + box-shadow: -2px 0 8px rgba(0, 0, 0, 0.15); + transition: right 0.25s ease, background 0.2s; } -.synth-overlay-detail-row { - margin-bottom: 4px; -} -.synth-overlay-detail-meta { - margin-top: 6px; - font-size: 10px; - color: #888; -} -.synth-overlay-detail-expand { - margin-top: 6px; - font-size: 11px; - color: #2563eb; - cursor: pointer; - font-weight: 500; +.synth-overlay-tab:hover { + background: #1d4ed8; } -.synth-overlay-detail-expand:hover { - text-decoration: underline; +.synth-overlay-tab.synth-overlay-tab-hidden { + right: -50px; } .synth-overlay-no-trade { @@ -100,9 +53,18 @@ .synth-overlay-conf-fill { height: 100%; border-radius: 3px; - background: linear-gradient(90deg, #ef4444 0%, #f59e0b 40%, #22c55e 100%); 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 { @@ -174,3 +136,33 @@ 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 index ea34a2a..410f935 100644 --- a/tools/synth-overlay/extension/content.js +++ b/tools/synth-overlay/extension/content.js @@ -31,6 +31,17 @@ 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()); @@ -53,68 +64,10 @@ return div.innerHTML; } - function createBadge(data) { - var edge = data.edge_pct; - var signal = data.signal; - var strength = data.strength; - var label = formatLabel(signal, edge); - - var hasDual = data.edge_1h_pct != null && data.edge_24h_pct != null; - var now1h = hasDual ? formatLabel(data.signal_1h, data.edge_1h_pct) : label; - var byClose24h = hasDual ? formatLabel(data.signal_24h, data.edge_24h_pct) : label; - - var confScore = data.confidence_score != null ? data.confidence_score : 0.5; - var confText = confidenceLabel(confScore); - var barWidth = confidenceBarWidth(confScore); - - var noTradeRow = data.no_trade_warning - ? '
' + - "No trade \u2014 uncertainty high or signals conflict.
" - : ""; - - var root = document.createElement("div"); - root.className = "synth-overlay-root"; - root.setAttribute("data-synth-overlay", "badge"); - - root.innerHTML = - '
' + - '' + escapeHtml(label) + "" + - '' + escapeHtml(strength) + "" + - "
" + - '"; - - var badge = root.querySelector(".synth-overlay-badge"); - var detail = root.querySelector(".synth-overlay-detail"); - var expandBtn = root.querySelector(".synth-overlay-detail-expand"); - - if (badge) { - badge.addEventListener("click", function (e) { - e.stopPropagation(); - if (detail) detail.hidden = !detail.hidden; - }); - } - - if (expandBtn) { - expandBtn.addEventListener("click", function (e) { - e.stopPropagation(); - showPanel(data); - }); - } - - return root; - } - 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"); @@ -128,12 +81,24 @@ 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) + "
" + @@ -143,15 +108,11 @@ '
' + '
Confidence
' + '
' + - '
' + + '
' + "
" + '
' + escapeHtml(confidenceLabel(confScore)) + " (" + Math.round(confScore * 100) + "%)
" + "
" + - '
' + - '
Why this signal exists
' + - '
' + escapeHtml(explanation) + "
" + - "
" + (invalidation ? '
' + '
What would invalidate it
' + @@ -172,6 +133,8 @@ 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); @@ -183,17 +146,126 @@ 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(); - var badge = createBadge(data); - container.appendChild(badge); + 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 badges = document.querySelectorAll("[data-synth-overlay=badge]"); - for (var i = 0; i < badges.length; i++) badges[i].remove(); + var tabs = document.querySelectorAll("[data-synth-overlay=tab]"); + for (var i = 0; i < tabs.length; i++) tabs[i].remove(); + removeInlineOverlays(); closePanel(); } @@ -254,7 +326,7 @@ var observer = new MutationObserver(function () { var slug = slugFromPage(); - if (slug === currentSlug && document.querySelector("[data-synth-overlay=badge]")) { + if (slug === currentSlug && document.querySelector("[data-synth-overlay=tab]")) { return; } runDebounced(); diff --git a/tools/synth-overlay/server.py b/tools/synth-overlay/server.py index c21aa7b..fc3fdf3 100644 --- a/tools/synth-overlay/server.py +++ b/tools/synth-overlay/server.py @@ -61,13 +61,6 @@ def edge(): if market_type in ("daily", "hourly"): daily_data = client.get_polymarket_daily() hourly_data = client.get_polymarket_hourly() - expected_daily_slug = (daily_data.get("slug") or "").strip().lower() - expected_hourly_slug = (hourly_data.get("slug") or "").strip().lower() - request_slug = slug.strip().lower() - if market_type == "daily" and request_slug != expected_daily_slug: - return jsonify({"error": "Unsupported market", "slug": slug}), 404 - if market_type == "hourly" and request_slug != expected_hourly_slug: - return jsonify({"error": "Unsupported market", "slug": slug}), 404 pct_1h = None pct_24h = None try: @@ -80,7 +73,9 @@ def edge(): result = analyzer.analyze(primary_horizon=primary_horizon) primary_data = daily_data if market_type == "daily" else hourly_data return jsonify({ - "slug": primary_data.get("slug"), + # 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, diff --git a/tools/synth-overlay/tests/test_server.py b/tools/synth-overlay/tests/test_server.py index 4524689..b0aa2ad 100644 --- a/tools/synth-overlay/tests/test_server.py +++ b/tools/synth-overlay/tests/test_server.py @@ -70,9 +70,11 @@ def test_edge_unsupported_slug(client): assert resp.status_code == 404 -def test_edge_pattern_matched_but_unavailable_slug_404(client): +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 == 404 + assert resp.status_code == 200 + data = resp.get_json() + assert data["slug"] == "btc-up-or-down-on-march-1" def test_edge_range(client):