From 7a5fe8b571ba188341ac6f13c6826cbc07adc2c7 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Fri, 3 Jul 2026 23:52:03 +0800 Subject: [PATCH] Add ops low-price market opportunities --- .../app/api/ops/market-opportunities/route.ts | 47 ++ .../app/ops/market-opportunities/page.tsx | 10 + .../__tests__/opsMarketOpportunities.test.ts | 60 ++ .../components/ops/layout/AdminSidebar.tsx | 2 + .../MarketOpportunitiesPageClient.tsx | 308 ++++++++++ frontend/lib/ops-api.ts | 23 + tests/test_ops_market_opportunities.py | 182 ++++++ web/routers/ops.py | 24 + web/services/ops/market_opportunities.py | 532 ++++++++++++++++++ web/services/ops_api.py | 7 + 10 files changed, 1195 insertions(+) create mode 100644 frontend/app/api/ops/market-opportunities/route.ts create mode 100644 frontend/app/ops/market-opportunities/page.tsx create mode 100644 frontend/components/ops/__tests__/opsMarketOpportunities.test.ts create mode 100644 frontend/components/ops/market-opportunities/MarketOpportunitiesPageClient.tsx create mode 100644 tests/test_ops_market_opportunities.py create mode 100644 web/services/ops/market_opportunities.py diff --git a/frontend/app/api/ops/market-opportunities/route.ts b/frontend/app/api/ops/market-opportunities/route.ts new file mode 100644 index 00000000..7213313d --- /dev/null +++ b/frontend/app/api/ops/market-opportunities/route.ts @@ -0,0 +1,47 @@ +import { NextRequest, NextResponse } from "next/server"; +import { buildProxyExceptionResponse } from "@/lib/api-proxy"; +import { + applyAuthResponseCookies, + buildBackendRequestHeaders, +} from "@/lib/backend-auth"; +import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth"; + +const API_BASE = process.env.POLYWEATHER_API_BASE_URL; + +export async function GET(req: NextRequest) { + if (!API_BASE) { + return NextResponse.json( + { error: "POLYWEATHER_API_BASE_URL is not configured" }, + { status: 500 }, + ); + } + + try { + const auth = await buildBackendRequestHeaders(req); + const authError = requireOpsProxyAuth(req, auth); + if (authError) return authError; + + const upstream = new URL(`${API_BASE}/api/ops/market-opportunities`); + req.nextUrl.searchParams.forEach((value, key) => { + upstream.searchParams.set(key, value); + }); + + const res = await fetch(upstream.toString(), { + cache: "no-store", + headers: auth.headers, + }); + const raw = await res.text(); + const response = new NextResponse(raw, { + headers: { + "Cache-Control": "no-store", + "Content-Type": res.headers.get("content-type") || "application/json", + }, + status: res.status, + }); + return applyAuthResponseCookies(response, auth.response); + } catch (error) { + return buildProxyExceptionResponse(error, { + publicMessage: "Failed to fetch market opportunities", + }); + } +} diff --git a/frontend/app/ops/market-opportunities/page.tsx b/frontend/app/ops/market-opportunities/page.tsx new file mode 100644 index 00000000..af2186a4 --- /dev/null +++ b/frontend/app/ops/market-opportunities/page.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from "next"; +import { requireOpsAdmin } from "@/lib/ops-admin"; +import { MarketOpportunitiesPageClient } from "@/components/ops/market-opportunities/MarketOpportunitiesPageClient"; + +export const metadata: Metadata = { title: "市场机会 — PolyWeather Ops" }; + +export default async function MarketOpportunitiesPage() { + await requireOpsAdmin("/ops/market-opportunities"); + return ; +} diff --git a/frontend/components/ops/__tests__/opsMarketOpportunities.test.ts b/frontend/components/ops/__tests__/opsMarketOpportunities.test.ts new file mode 100644 index 00000000..766e27be --- /dev/null +++ b/frontend/components/ops/__tests__/opsMarketOpportunities.test.ts @@ -0,0 +1,60 @@ +import fs from "node:fs"; +import path from "node:path"; + +function assert(condition: unknown, message: string) { + if (!condition) throw new Error(message); +} + +export function runTests() { + const projectRoot = process.cwd(); + const sidebar = fs.readFileSync( + path.join(projectRoot, "components", "ops", "layout", "AdminSidebar.tsx"), + "utf8", + ); + const opsApi = fs.readFileSync(path.join(projectRoot, "lib", "ops-api.ts"), "utf8"); + const pagePath = path.join(projectRoot, "app", "ops", "market-opportunities", "page.tsx"); + const proxyPath = path.join(projectRoot, "app", "api", "ops", "market-opportunities", "route.ts"); + const clientPath = path.join( + projectRoot, + "components", + "ops", + "market-opportunities", + "MarketOpportunitiesPageClient.tsx", + ); + + assert( + sidebar.includes("/ops/market-opportunities") && sidebar.includes("市场机会"), + "ops sidebar must expose the internal market opportunities page", + ); + assert( + fs.existsSync(pagePath) && fs.readFileSync(pagePath, "utf8").includes("requireOpsAdmin"), + "market opportunities page must exist and require ops admin access", + ); + assert( + fs.existsSync(proxyPath) && + fs.readFileSync(proxyPath, "utf8").includes("requireOpsProxyAuth") && + fs.readFileSync(proxyPath, "utf8").includes("/api/ops/market-opportunities"), + "market opportunities proxy must stay ops-admin protected", + ); + assert( + opsApi.includes("marketOpportunities") && + opsApi.includes("/api/ops/market-opportunities"), + "ops API client must expose market opportunities", + ); + const client = fs.existsSync(clientPath) ? fs.readFileSync(clientPath, "utf8") : ""; + for (const column of [ + "城市", + "选项", + "方向", + "买入价", + "模型概率", + "Edge", + "市场链接", + ]) { + assert(client.includes(column), `market opportunities table must include ${column}`); + } + assert( + client.includes("positive_edge_only") && client.includes("显示全部低价"), + "market opportunities page must default to positive edge and allow showing every low-price option", + ); +} diff --git a/frontend/components/ops/layout/AdminSidebar.tsx b/frontend/components/ops/layout/AdminSidebar.tsx index dccc00bb..80f175d9 100644 --- a/frontend/components/ops/layout/AdminSidebar.tsx +++ b/frontend/components/ops/layout/AdminSidebar.tsx @@ -10,6 +10,7 @@ import { Users, UserCheck, BarChart3, + TrendingUp, Settings, FileText, ScrollText, @@ -27,6 +28,7 @@ const navGroups = [ { href: "/ops/system", icon: Cpu, label: "系统状态" }, { href: "/ops/training", icon: Database, label: "训练数据" }, { href: "/ops/analytics", icon: BarChart3, label: "转化分析" }, + { href: "/ops/market-opportunities", icon: TrendingUp, label: "市场机会" }, ], }, { diff --git a/frontend/components/ops/market-opportunities/MarketOpportunitiesPageClient.tsx b/frontend/components/ops/market-opportunities/MarketOpportunitiesPageClient.tsx new file mode 100644 index 00000000..003ed816 --- /dev/null +++ b/frontend/components/ops/market-opportunities/MarketOpportunitiesPageClient.tsx @@ -0,0 +1,308 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { ExternalLink, RefreshCcw, Search, TrendingUp } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { opsApi } from "@/lib/ops-api"; + +type MarketOpportunityRow = { + id?: string; + city?: string; + display_name?: string; + target_date?: string; + bucket_label?: string; + side?: string; + ask_price?: number; + model_probability?: number; + edge?: number; + liquidity?: number | null; + volume?: number | null; + market_url?: string; + current_max_so_far?: number | null; + deb_prediction?: number | null; + model_median?: number | null; + model_spread?: number | null; + local_time?: string; + region?: string; +}; + +type MarketOpportunitiesPayload = { + generated_at?: string; + summary?: { + opportunity_count?: number; + positive_edge_count?: number; + min_price?: number | null; + max_edge?: number | null; + quote_status?: string; + scanned_city_count?: number; + matched_event_count?: number; + error?: string | null; + }; + rows?: MarketOpportunityRow[]; +}; + +function percent(value?: number | null) { + if (typeof value !== "number" || !Number.isFinite(value)) return "—"; + return `${Math.round(value * 100)}%`; +} + +function cents(value?: number | null) { + if (typeof value !== "number" || !Number.isFinite(value)) return "—"; + return `${(value * 100).toFixed(value * 100 < 1 ? 1 : 0)}¢`; +} + +function numberLabel(value?: number | null) { + if (typeof value !== "number" || !Number.isFinite(value)) return "—"; + if (Math.abs(value) >= 1000) return value.toLocaleString(undefined, { maximumFractionDigits: 0 }); + return value.toFixed(1); +} + +function tempLabel(value?: number | null) { + if (typeof value !== "number" || !Number.isFinite(value)) return "—"; + return value.toFixed(1); +} + +function generatedLabel(value?: string) { + if (!value) return "—"; + return value.slice(0, 19).replace("T", " "); +} + +function sideTone(side?: string) { + return String(side).toLowerCase() === "no" + ? "border-red-200 bg-red-50 text-red-700" + : "border-emerald-200 bg-emerald-50 text-emerald-700"; +} + +function Stat({ + label, + value, + sub, +}: { + label: string; + value: string | number; + sub: string; +}) { + return ( +
+
{label}
+
{value}
+
{sub}
+
+ ); +} + +export function MarketOpportunitiesPageClient() { + const [payload, setPayload] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [query, setQuery] = useState(""); + const [side, setSide] = useState("both"); + const [maxPrice, setMaxPrice] = useState("0.20"); + const [minEdge, setMinEdge] = useState("0"); + const [showAllLowPrice, setShowAllLowPrice] = useState(false); + + const positive_edge_only = !showAllLowPrice; + + const load = async () => { + setLoading(true); + setError(""); + try { + const data = await opsApi.marketOpportunities({ + q: query.trim(), + side, + max_price: maxPrice, + min_edge: minEdge, + positive_edge_only, + limit: 200, + }); + setPayload(data as MarketOpportunitiesPayload); + } catch (err) { + setError(err instanceof Error ? err.message : "加载市场机会失败"); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + void load(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [side, showAllLowPrice]); + + const rows = useMemo(() => payload?.rows ?? [], [payload]); + const summary = payload?.summary ?? {}; + const quoteStatus = summary.quote_status || "unknown"; + + return ( +
+
+
+

+ + 市场机会 +

+

+ 仅 Ops 内部使用,扫描低于 20¢ 的 Yes/No 选项,并按模型概率与市场价格差排序。 +

+
+ +
+ +
+ + + + + +
+ + + + 低价选项扫描 +
+ + + setMaxPrice(event.target.value)} + className="h-9 rounded-md border border-slate-300 bg-white px-2 text-sm font-semibold text-slate-700" + inputMode="decimal" + aria-label="最高买入价" + /> + setMinEdge(event.target.value)} + className="h-9 rounded-md border border-slate-300 bg-white px-2 text-sm font-semibold text-slate-700" + inputMode="decimal" + aria-label="最小 Edge" + /> + +
+ +
+ + {error ? ( +
+ {error} +
+ ) : null} + {summary.error ? ( +
+ 报价不可用:{summary.error} +
+ ) : null} +
+ + + + + + + + + + + + + + + + + + + + {loading ? ( + + + + ) : rows.length ? ( + rows.map((row) => ( + + + + + + + + + + + + + + + + )) + ) : ( + + + + )} + +
城市选项方向买入价模型概率Edge当前最高DEB模型中位数分歧流动性成交量市场链接
+ 加载中... +
+
{row.display_name || row.city || "—"}
+
+ {row.local_time || "—"} · {row.target_date || "—"} +
+
{row.bucket_label || "—"} + + {row.side || "—"} + + {cents(row.ask_price)}{percent(row.model_probability)}{percent(row.edge)}{tempLabel(row.current_max_so_far)}{tempLabel(row.deb_prediction)}{tempLabel(row.model_median)}{tempLabel(row.model_spread)}{numberLabel(row.liquidity)}{numberLabel(row.volume)} + {row.market_url ? ( + + 打开 + + ) : ( + + )} +
+ 当前筛选下没有低价市场机会。 +
+
+
+ 生成时间:{generatedLabel(payload?.generated_at)} +
+
+
+
+ ); +} diff --git a/frontend/lib/ops-api.ts b/frontend/lib/ops-api.ts index 7a9a8085..653f6944 100644 --- a/frontend/lib/ops-api.ts +++ b/frontend/lib/ops-api.ts @@ -293,6 +293,29 @@ export const opsApi = { }; }>("/api/ops/training/accuracy"); }, + marketOpportunities(params: Record = {}) { + const qs = new URLSearchParams(); + Object.entries(params).forEach(([key, value]) => { + if (value === undefined || value === "") return; + qs.set(key, String(value)); + }); + const suffix = qs.toString() ? `?${qs.toString()}` : ""; + return opsFetch<{ + generated_at?: string; + filters?: Record; + summary?: { + opportunity_count?: number; + positive_edge_count?: number; + min_price?: number | null; + max_edge?: number | null; + quote_status?: string; + scanned_city_count?: number; + matched_event_count?: number; + error?: string | null; + }; + rows?: Array>; + }>(`/api/ops/market-opportunities${suffix}`); + }, telegramAudit() { return opsFetch<{ anomalies: Array<{ diff --git a/tests/test_ops_market_opportunities.py b/tests/test_ops_market_opportunities.py new file mode 100644 index 00000000..bbd5f685 --- /dev/null +++ b/tests/test_ops_market_opportunities.py @@ -0,0 +1,182 @@ +import pytest +from fastapi import HTTPException + +import web.services.ops.market_opportunities as market_opportunities +from web.services.ops.market_opportunities import ( + build_market_opportunity_rows, + get_ops_market_opportunities, + parse_market_option_from_question, +) + + +def _row(city="shanghai", symbol="°C"): + return { + "city": city, + "city_display_name": city.title(), + "local_date": "2026-07-03", + "local_time": "18:10", + "temp_symbol": symbol, + "current_max_so_far": 31.0, + "deb_prediction": 32.2, + "model_cluster_sources": {"ECMWF": 32.0, "GFS": 33.0, "ICON": 31.0}, + "distribution_full": [ + {"value": 31, "probability": 0.10}, + {"value": 32, "probability": 0.41}, + {"value": 33, "probability": 0.46}, + {"value": 34, "probability": 0.03}, + ], + "trading_region_label_zh": "东亚", + } + + +def test_parse_market_option_supports_celsius_single_temperature(): + option = parse_market_option_from_question( + "Will the highest temperature in Shanghai be 33°C on July 3?", + "°C", + ) + + assert option["label"] == "33°C" + assert option["lower"] == 33 + assert option["upper"] == 33 + + +def test_parse_market_option_supports_fahrenheit_two_degree_range(): + option = parse_market_option_from_question( + "Will the highest temperature in Houston be between 94-95°F on July 3?", + "°F", + ) + + assert option["label"] == "94-95°F" + assert option["lower"] == 94 + assert option["upper"] == 95 + + +def test_build_market_opportunities_scans_yes_and_no_low_price_edges(): + event = { + "slug": "highest-temperature-in-shanghai-on-july-3-2026", + "title": "Highest temperature in Shanghai on July 3?", + "markets": [ + { + "question": "Will the highest temperature in Shanghai be 33°C on July 3?", + "slug": "highest-temperature-in-shanghai-on-july-3-2026-33c", + "active": True, + "closed": False, + "enableOrderBook": True, + "liquidity": "47000", + "volume": "13000", + "outcomes": '["Yes", "No"]', + "clobTokenIds": '["yes-33", "no-33"]', + }, + { + "question": "Will the highest temperature in Shanghai be 31°C on July 3?", + "slug": "highest-temperature-in-shanghai-on-july-3-2026-31c", + "active": True, + "closed": False, + "enableOrderBook": True, + "liquidity": "27000", + "volume": "6000", + "outcomes": '["Yes", "No"]', + "clobTokenIds": '["yes-31", "no-31"]', + }, + ], + } + + rows = build_market_opportunity_rows( + [_row()], + {"shanghai": event}, + { + "yes-33": 0.18, + "no-33": 0.62, + "yes-31": 0.12, + "no-31": 0.18, + }, + max_price=0.20, + side="both", + positive_edge_only=True, + min_edge=0.0, + limit=20, + ) + + yes = next(row for row in rows if row["bucket_label"] == "33°C" and row["side"] == "yes") + no = next(row for row in rows if row["bucket_label"] == "31°C" and row["side"] == "no") + + assert yes["model_probability"] == 0.46 + assert yes["ask_price"] == 0.18 + assert round(yes["edge"], 2) == 0.28 + assert no["model_probability"] == 0.10 + assert no["ask_price"] == 0.18 + assert round(no["edge"], 2) == 0.72 + assert all(row["ask_price"] < 0.20 for row in rows) + assert rows[0]["edge"] >= rows[-1]["edge"] + + +def test_build_market_opportunities_aggregates_fahrenheit_distribution(): + event = { + "slug": "highest-temperature-in-houston-on-july-3-2026", + "markets": [ + { + "question": "Will the highest temperature in Houston be between 94-95°F on July 3?", + "slug": "highest-temperature-in-houston-on-july-3-2026-94-95f", + "active": True, + "closed": False, + "enableOrderBook": True, + "liquidity": "10000", + "volume": "2000", + "outcomes": '["Yes", "No"]', + "clobTokenIds": '["yes-94", "no-94"]', + } + ], + } + row = _row("houston", "°F") + row["distribution_full"] = [ + {"value": 94, "probability": 0.40}, + {"value": 95, "probability": 0.25}, + {"value": 96, "probability": 0.10}, + ] + + rows = build_market_opportunity_rows( + [row], + {"houston": event}, + {"yes-94": 0.19, "no-94": 0.81}, + max_price=0.20, + side="yes", + positive_edge_only=True, + min_edge=0.0, + limit=20, + ) + + assert rows[0]["bucket_label"] == "94-95°F" + assert rows[0]["model_probability"] == 0.65 + assert round(rows[0]["edge"], 2) == 0.46 + + +def test_ops_market_opportunities_requires_ops_admin(monkeypatch): + def deny(_request): + raise HTTPException(status_code=403, detail="ops only") + + monkeypatch.setattr(market_opportunities, "_require_ops", deny) + + with pytest.raises(HTTPException) as exc: + get_ops_market_opportunities(object()) + + assert exc.value.status_code == 403 + + +def test_ops_market_opportunities_degrades_when_quotes_fail(monkeypatch): + monkeypatch.setattr(market_opportunities, "_require_ops", lambda _request: {"email": "ops@example.com"}) + monkeypatch.setattr( + market_opportunities, + "build_scan_terminal_payload", + lambda *_args, **_kwargs: {"rows": [_row()]}, + ) + + def fail_quotes(*_args, **_kwargs): + raise RuntimeError("polymarket down") + + monkeypatch.setattr(market_opportunities, "_collect_events_and_prices", fail_quotes) + + payload = get_ops_market_opportunities(object()) + + assert payload["summary"]["quote_status"] == "unavailable" + assert payload["summary"]["error"] == "polymarket down" + assert payload["rows"] == [] diff --git a/web/routers/ops.py b/web/routers/ops.py index 97d5f4ca..ca71334f 100644 --- a/web/routers/ops.py +++ b/web/routers/ops.py @@ -12,6 +12,7 @@ from web.services.ops_api import ( get_ops_sensitive_config, get_ops_memberships_growth, get_ops_memberships_overview, + get_ops_market_opportunities, get_ops_health_check, get_ops_logs, get_ops_observation_collector_status, @@ -355,3 +356,26 @@ async def ops_telegram_audit(request: Request): return get_ops_telegram_audit(request) +@router.get("/api/ops/market-opportunities") +async def ops_market_opportunities( + request: Request, + max_price: float = 0.20, + side: str = "both", + positive_edge_only: bool = True, + min_edge: float = 0.0, + limit: int = 200, + q: str = "", + region: str = "", +): + return get_ops_market_opportunities( + request, + max_price=max_price, + side=side, + positive_edge_only=positive_edge_only, + min_edge=min_edge, + limit=limit, + query=q, + region=region, + ) + + diff --git a/web/services/ops/market_opportunities.py b/web/services/ops/market_opportunities.py new file mode 100644 index 00000000..734d0fae --- /dev/null +++ b/web/services/ops/market_opportunities.py @@ -0,0 +1,532 @@ +from __future__ import annotations + +import json +import math +import re +import threading +import time +import unicodedata +from datetime import datetime +from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple + +import requests +from fastapi import Request + +from web.scan_terminal_service import build_scan_terminal_payload + +GAMMA_API_BASE = "https://gamma-api.polymarket.com" +CLOB_API_BASE = "https://clob.polymarket.com" +_QUOTE_CACHE_TTL_SEC = 60 +_EVENT_CACHE_TTL_SEC = 180 + +_CACHE_LOCK = threading.Lock() +_EVENT_CACHE: Dict[str, Tuple[float, Optional[Dict[str, Any]]]] = {} +_PRICE_CACHE: Dict[str, Tuple[float, Optional[float]]] = {} + + +def _require_ops(request: Request) -> Dict[str, Any] | None: + from web.services.ops_api import _require_ops as _real + + return _real(request) + + +def _finite_number(value: Any) -> Optional[float]: + if value is None: + return None + try: + number = float(value) + except (TypeError, ValueError): + return None + if not math.isfinite(number): + return None + return number + + +def _json_list(value: Any) -> List[Any]: + if isinstance(value, list): + return value + if isinstance(value, str) and value.strip(): + try: + parsed = json.loads(value) + except json.JSONDecodeError: + return [] + return parsed if isinstance(parsed, list) else [] + return [] + + +def _normalize_key(value: Any) -> str: + return re.sub(r"\s+", " ", str(value or "").strip().lower()) + + +def _slugify(value: str) -> str: + normalized = unicodedata.normalize("NFKD", value) + ascii_text = normalized.encode("ascii", "ignore").decode("ascii") + ascii_text = ascii_text.lower().replace("&", " and ") + return re.sub(r"-+", "-", re.sub(r"[^a-z0-9]+", "-", ascii_text)).strip("-") + + +def _city_slug(row: Mapping[str, Any]) -> str: + city = _normalize_key(row.get("city_display_name") or row.get("display_name") or row.get("city")) + if city in {"new york", "new york city"}: + return "nyc" + return _slugify(city) + + +def _date_slug(value: Any) -> Optional[str]: + text = str(value or "").strip() + if not text: + return None + try: + parsed = datetime.fromisoformat(text[:10]) + except ValueError: + return None + return f"{parsed.strftime('%B').lower()}-{parsed.day}-{parsed.year}" + + +def _event_slug_for_row(row: Mapping[str, Any]) -> Optional[str]: + date_key = row.get("selected_date") or row.get("local_date") + date_slug = _date_slug(date_key) + city_slug = _city_slug(row) + if not date_slug or not city_slug: + return None + return f"highest-temperature-in-{city_slug}-on-{date_slug}" + + +def _market_url(slug: str) -> str: + return f"https://polymarket.com/event/{slug}" + + +def _round_probability(value: float) -> float: + return round(value + 0.0000000001, 4) + + +def _round_price(value: float) -> float: + return round(value + 0.0000000001, 4) + + +def _probability_from_bucket(bucket: Mapping[str, Any]) -> Optional[float]: + raw = _finite_number(bucket.get("probability") or bucket.get("model_probability")) + if raw is None: + return None + return raw / 100.0 if raw > 1 else raw + + +def _distribution_points(row: Mapping[str, Any]) -> List[Tuple[int, float]]: + raw = row.get("distribution_full") or row.get("distribution_preview") or [] + if not isinstance(raw, list): + return [] + points: List[Tuple[int, float]] = [] + for bucket in raw: + if not isinstance(bucket, Mapping): + continue + value = _finite_number(bucket.get("value") or bucket.get("temp") or bucket.get("temperature")) + probability = _probability_from_bucket(bucket) + if value is None or probability is None or probability <= 0: + continue + points.append((int(round(value)), float(probability))) + return points + + +def parse_market_option_from_question(question: str, unit: str) -> Dict[str, Any]: + text = str(question or "").strip() + unit_text = "°F" if "f" in str(unit or "").lower() else "°C" + + between = re.search( + r"between\s+(-?\d+)\s*-\s*(-?\d+)\s*°?\s*([CF])", + text, + flags=re.IGNORECASE, + ) + if between: + lower = int(between.group(1)) + upper = int(between.group(2)) + parsed_unit = f"°{between.group(3).upper()}" + return { + "label": f"{lower}-{upper}{parsed_unit}", + "lower": min(lower, upper), + "upper": max(lower, upper), + "unit": parsed_unit, + } + + below = re.search(r"(-?\d+)\s*°?\s*([CF])\s+or\s+below", text, flags=re.IGNORECASE) + if below: + upper = int(below.group(1)) + parsed_unit = f"°{below.group(2).upper()}" + return { + "label": f"{upper}{parsed_unit} or below", + "lower": None, + "upper": upper, + "unit": parsed_unit, + } + + higher = re.search(r"(-?\d+)\s*°?\s*([CF])\s+or\s+higher", text, flags=re.IGNORECASE) + if higher: + lower = int(higher.group(1)) + parsed_unit = f"°{higher.group(2).upper()}" + return { + "label": f"{lower}{parsed_unit} or higher", + "lower": lower, + "upper": None, + "unit": parsed_unit, + } + + exact = re.search(r"\bbe\s+(-?\d+)\s*°?\s*([CF])\b", text, flags=re.IGNORECASE) + if exact: + value = int(exact.group(1)) + parsed_unit = f"°{exact.group(2).upper()}" + return { + "label": f"{value}{parsed_unit}", + "lower": value, + "upper": value, + "unit": parsed_unit, + } + + value_match = re.search(r"(-?\d+)\s*°?\s*([CF])", text, flags=re.IGNORECASE) + if value_match: + value = int(value_match.group(1)) + parsed_unit = f"°{value_match.group(2).upper()}" + return { + "label": f"{value}{parsed_unit}", + "lower": value, + "upper": value, + "unit": parsed_unit, + } + + return {"label": text or "—", "lower": None, "upper": None, "unit": unit_text} + + +def _bucket_probability(row: Mapping[str, Any], option: Mapping[str, Any]) -> Optional[float]: + points = _distribution_points(row) + if not points: + return None + lower = option.get("lower") + upper = option.get("upper") + lower_number = int(lower) if lower is not None else None + upper_number = int(upper) if upper is not None else None + probability = 0.0 + for settled_value, point_probability in points: + if lower_number is not None and settled_value < lower_number: + continue + if upper_number is not None and settled_value > upper_number: + continue + probability += point_probability + return _round_probability(probability) + + +def _model_stats(row: Mapping[str, Any]) -> Tuple[Optional[float], Optional[float]]: + sources = row.get("model_cluster_sources") or {} + if not isinstance(sources, Mapping): + return None, None + values = sorted( + number + for number in (_finite_number(value) for value in sources.values()) + if number is not None + ) + if not values: + return None, None + mid = len(values) // 2 + median = values[mid] if len(values) % 2 else (values[mid - 1] + values[mid]) / 2 + return round(median, 1), round(max(values) - min(values), 1) + + +def _market_tokens(market: Mapping[str, Any]) -> Dict[str, Optional[str]]: + outcomes = [str(item).strip().lower() for item in _json_list(market.get("outcomes"))] + token_ids = [str(item).strip() for item in _json_list(market.get("clobTokenIds"))] + result = {"yes": None, "no": None} + for index, outcome in enumerate(outcomes): + if index >= len(token_ids) or not token_ids[index]: + continue + if outcome in result: + result[outcome] = token_ids[index] + return result + + +def _market_hint_prices(market: Mapping[str, Any]) -> Dict[str, Optional[float]]: + outcomes = [str(item).strip().lower() for item in _json_list(market.get("outcomes"))] + prices = _json_list(market.get("outcomePrices")) + result: Dict[str, Optional[float]] = {"yes": None, "no": None} + for index, outcome in enumerate(outcomes): + if index >= len(prices) or outcome not in result: + continue + result[outcome] = _finite_number(prices[index]) + return result + + +def _iter_market_sides(side: str) -> Iterable[str]: + normalized = str(side or "both").strip().lower() + if normalized in {"yes", "no"}: + yield normalized + return + yield "yes" + yield "no" + + +def build_market_opportunity_rows( + scan_rows: List[Dict[str, Any]], + events_by_city: Mapping[str, Optional[Dict[str, Any]]], + ask_prices_by_token: Mapping[str, Optional[float]], + *, + max_price: float = 0.20, + side: str = "both", + positive_edge_only: bool = True, + min_edge: float = 0.0, + limit: int = 200, + query: str = "", + region: str = "", +) -> List[Dict[str, Any]]: + opportunities: List[Dict[str, Any]] = [] + query_text = _normalize_key(query) + region_text = _normalize_key(region) + + for scan_row in scan_rows: + city_key = _normalize_key(scan_row.get("city")) + display_name = str(scan_row.get("city_display_name") or scan_row.get("display_name") or scan_row.get("city") or "—") + if query_text and query_text not in _normalize_key(display_name) and query_text not in city_key: + continue + row_region = str(scan_row.get("trading_region_label_zh") or scan_row.get("trading_region_label") or "") + if region_text and region_text not in {"all", ""} and region_text not in _normalize_key(row_region): + continue + + event = events_by_city.get(city_key) + if not isinstance(event, Mapping): + continue + market_url = _market_url(str(event.get("slug") or "")) + model_median, model_spread = _model_stats(scan_row) + for market in event.get("markets") or []: + if not isinstance(market, Mapping): + continue + if market.get("active") is False or market.get("closed") is True: + continue + if market.get("enableOrderBook") is False: + continue + option = parse_market_option_from_question( + str(market.get("question") or ""), + str(scan_row.get("temp_symbol") or "°C"), + ) + model_probability = _bucket_probability(scan_row, option) + if model_probability is None: + continue + tokens = _market_tokens(market) + for option_side in _iter_market_sides(side): + token_id = tokens.get(option_side) + if not token_id: + continue + ask = ask_prices_by_token.get(token_id) + ask_number = _finite_number(ask) + if ask_number is None or ask_number <= 0 or ask_number >= float(max_price): + continue + target_probability = ( + model_probability + if option_side == "yes" + else _round_probability(1.0 - model_probability) + ) + edge = _round_probability(target_probability - ask_number) + if positive_edge_only and edge <= float(min_edge): + continue + if not positive_edge_only and edge < float(min_edge): + continue + market_slug = str(market.get("slug") or "") + opportunities.append( + { + "id": f"{city_key}:{market_slug}:{option_side}", + "city": city_key, + "display_name": display_name, + "target_date": scan_row.get("selected_date") or scan_row.get("local_date"), + "bucket_label": option["label"], + "side": option_side, + "ask_price": _round_price(ask_number), + "model_probability": model_probability, + "edge": edge, + "liquidity": _finite_number(market.get("liquidity")), + "volume": _finite_number(market.get("volume")), + "market_url": market_url, + "market_slug": market_slug, + "question": market.get("question"), + "current_max_so_far": _finite_number(scan_row.get("current_max_so_far")), + "deb_prediction": _finite_number(scan_row.get("deb_prediction")), + "model_median": model_median, + "model_spread": model_spread, + "local_time": scan_row.get("local_time"), + "region": row_region, + } + ) + + opportunities.sort( + key=lambda row: ( + float(row.get("edge") or 0), + -float(row.get("ask_price") or 0), + float(row.get("liquidity") or 0), + ), + reverse=True, + ) + safe_limit = max(1, min(int(limit or 200), 500)) + return opportunities[:safe_limit] + + +class PolymarketQuoteScanner: + def __init__(self, session: Optional[requests.Session] = None) -> None: + self.session = session or requests.Session() + + def fetch_event(self, slug: str) -> Optional[Dict[str, Any]]: + now = time.time() + with _CACHE_LOCK: + cached = _EVENT_CACHE.get(slug) + if cached and now - cached[0] < _EVENT_CACHE_TTL_SEC: + return cached[1] + response = self.session.get( + f"{GAMMA_API_BASE}/events", + params={"slug": slug}, + timeout=12, + ) + response.raise_for_status() + payload = response.json() + event = payload[0] if isinstance(payload, list) and payload else None + with _CACHE_LOCK: + _EVENT_CACHE[slug] = (now, event if isinstance(event, dict) else None) + return event if isinstance(event, dict) else None + + def fetch_ask_price(self, token_id: str) -> Optional[float]: + now = time.time() + with _CACHE_LOCK: + cached = _PRICE_CACHE.get(token_id) + if cached and now - cached[0] < _QUOTE_CACHE_TTL_SEC: + return cached[1] + response = self.session.get( + f"{CLOB_API_BASE}/price", + params={"token_id": token_id, "side": "SELL"}, + timeout=8, + ) + response.raise_for_status() + price = _finite_number((response.json() or {}).get("price")) + with _CACHE_LOCK: + _PRICE_CACHE[token_id] = (now, price) + return price + + +def _collect_events_and_prices( + rows: List[Dict[str, Any]], + scanner: PolymarketQuoteScanner, +) -> Tuple[Dict[str, Optional[Dict[str, Any]]], Dict[str, Optional[float]], str]: + events_by_city: Dict[str, Optional[Dict[str, Any]]] = {} + prices: Dict[str, Optional[float]] = {} + status = "ready" + for row in rows: + city_key = _normalize_key(row.get("city")) + if not city_key or city_key in events_by_city: + continue + slug = _event_slug_for_row(row) + if not slug: + events_by_city[city_key] = None + continue + try: + event = scanner.fetch_event(slug) + except Exception: + status = "partial" + event = None + events_by_city[city_key] = event + if not isinstance(event, Mapping): + continue + for market in event.get("markets") or []: + if not isinstance(market, Mapping): + continue + hint_prices = _market_hint_prices(market) + for market_side, token_id in _market_tokens(market).items(): + if not token_id or token_id in prices: + continue + hint_price = hint_prices.get(market_side) + if hint_price is not None and hint_price > 0.30: + prices[token_id] = hint_price + continue + try: + prices[token_id] = scanner.fetch_ask_price(token_id) + except Exception: + status = "partial" + prices[token_id] = hint_price + return events_by_city, prices, status + + +def get_ops_market_opportunities( + request: Request, + *, + max_price: float = 0.20, + side: str = "both", + positive_edge_only: bool = True, + min_edge: float = 0.0, + limit: int = 200, + query: str = "", + region: str = "", +) -> Dict[str, Any]: + _require_ops(request) + filters = { + "scan_mode": "tradable", + "min_price": 0.05, + "max_price": 0.95, + "min_edge_pct": 0, + "min_liquidity": 0, + "market_type": "maxtemp", + "time_range": "today", + "limit": 180, + } + scan_payload = build_scan_terminal_payload(filters, force_refresh=False) + scan_rows = scan_payload.get("rows") if isinstance(scan_payload, dict) else [] + if not isinstance(scan_rows, list): + scan_rows = [] + + quote_status = "ready" + try: + events_by_city, ask_prices, quote_status = _collect_events_and_prices( + scan_rows, + PolymarketQuoteScanner(), + ) + except Exception as exc: + events_by_city = {} + ask_prices = {} + quote_status = "unavailable" + error = str(exc) + else: + error = None + + rows = build_market_opportunity_rows( + scan_rows, + events_by_city, + ask_prices, + max_price=float(max_price), + side=side, + positive_edge_only=positive_edge_only, + min_edge=float(min_edge), + limit=limit, + query=query, + region=region, + ) + prices = [float(row["ask_price"]) for row in rows if _finite_number(row.get("ask_price")) is not None] + edges = [float(row["edge"]) for row in rows if _finite_number(row.get("edge")) is not None] + return { + "generated_at": datetime.utcnow().isoformat() + "Z", + "filters": { + "max_price": float(max_price), + "side": side, + "positive_edge_only": bool(positive_edge_only), + "min_edge": float(min_edge), + "limit": int(limit), + "query": query, + "region": region, + }, + "summary": { + "opportunity_count": len(rows), + "positive_edge_count": sum(1 for row in rows if float(row.get("edge") or 0) > 0), + "min_price": min(prices) if prices else None, + "max_edge": max(edges) if edges else None, + "quote_status": quote_status, + "scanned_city_count": len(scan_rows), + "matched_event_count": sum(1 for event in events_by_city.values() if isinstance(event, Mapping)), + "error": error, + }, + "rows": rows, + } + + +__all__ = [ + "PolymarketQuoteScanner", + "build_market_opportunity_rows", + "get_ops_market_opportunities", + "parse_market_option_from_question", +] diff --git a/web/services/ops_api.py b/web/services/ops_api.py index 4aeb4605..a5921700 100644 --- a/web/services/ops_api.py +++ b/web/services/ops_api.py @@ -70,6 +70,13 @@ from web.services.ops.health import ( # noqa: E402, F401 get_ops_truth_history, ) +# --------------------------------------------------------------------------- +# Internal market opportunities +# --------------------------------------------------------------------------- +from web.services.ops.market_opportunities import ( # noqa: E402, F401 + get_ops_market_opportunities, +) + # --------------------------------------------------------------------------- # Config / Subscriptions / Logs / Telegram # ---------------------------------------------------------------------------