Add ops low-price market opportunities

This commit is contained in:
2569718930@qq.com
2026-07-03 23:52:03 +08:00
parent 459909539c
commit 7a5fe8b571
10 changed files with 1195 additions and 0 deletions
@@ -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",
});
}
}
@@ -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 <MarketOpportunitiesPageClient />;
}
@@ -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",
);
}
@@ -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: "市场机会" },
],
},
{
@@ -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 (
<div className="rounded-lg border border-slate-200 bg-white px-4 py-3 shadow-sm">
<div className="text-xs font-bold uppercase tracking-wide text-slate-500">{label}</div>
<div className="mt-1 text-2xl font-black text-slate-950">{value}</div>
<div className="mt-1 text-xs text-slate-500">{sub}</div>
</div>
);
}
export function MarketOpportunitiesPageClient() {
const [payload, setPayload] = useState<MarketOpportunitiesPayload | null>(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 (
<div className="space-y-5">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<h1 className="flex items-center gap-2 text-2xl font-bold text-white">
<TrendingUp className="h-5 w-5 text-blue-300" />
</h1>
<p className="mt-1 text-sm text-slate-300">
Ops 使 20¢ Yes/No
</p>
</div>
<Button variant="outline" size="sm" onClick={load} className="gap-1.5">
<RefreshCcw className="h-3.5 w-3.5" />
</Button>
</div>
<div className="grid grid-cols-2 gap-3 md:grid-cols-5">
<Stat label="机会数" value={summary.opportunity_count ?? rows.length} sub="当前筛选结果" />
<Stat label="正边际" value={summary.positive_edge_count ?? 0} sub="模型概率高于买入价" />
<Stat label="最低价格" value={cents(summary.min_price)} sub="低价合约下限" />
<Stat label="最大 Edge" value={percent(summary.max_edge)} sub="模型概率 - 市场价" />
<Stat label="报价状态" value={quoteStatus} sub={`${summary.matched_event_count ?? 0}/${summary.scanned_city_count ?? 0} 城市匹配`} />
</div>
<Card>
<CardHeader className="gap-3">
<CardTitle></CardTitle>
<div className="grid gap-2 md:grid-cols-[minmax(180px,1fr)_120px_120px_120px_auto]">
<label className="relative block">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") void load();
}}
className="h-9 w-full rounded-md border border-slate-300 bg-white pl-8 pr-3 text-sm font-semibold text-slate-800 outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100"
placeholder="搜索城市或选项"
/>
</label>
<select
value={side}
onChange={(event) => setSide(event.target.value)}
className="h-9 rounded-md border border-slate-300 bg-white px-2 text-sm font-semibold text-slate-700"
>
<option value="both">Yes / No</option>
<option value="yes"> Yes</option>
<option value="no"> No</option>
</select>
<input
value={maxPrice}
onChange={(event) => 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="最高买入价"
/>
<input
value={minEdge}
onChange={(event) => 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"
/>
<Button variant="outline" size="sm" onClick={load}>
</Button>
</div>
<label className="flex w-fit items-center gap-2 text-sm font-semibold text-slate-600">
<input
type="checkbox"
checked={showAllLowPrice}
onChange={(event) => setShowAllLowPrice(event.target.checked)}
className="h-4 w-4 rounded border-slate-300"
/>
</label>
</CardHeader>
<CardContent>
{error ? (
<div className="mb-3 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm font-semibold text-red-700">
{error}
</div>
) : null}
{summary.error ? (
<div className="mb-3 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm font-semibold text-amber-700">
{summary.error}
</div>
) : null}
<div className="overflow-x-auto rounded-lg border border-slate-200">
<table className="min-w-[1320px] w-full border-collapse text-sm">
<thead className="bg-slate-50 text-left text-xs font-black uppercase tracking-wide text-slate-500">
<tr>
<th className="px-3 py-2"></th>
<th className="px-3 py-2"></th>
<th className="px-3 py-2"></th>
<th className="px-3 py-2 text-right"></th>
<th className="px-3 py-2 text-right"></th>
<th className="px-3 py-2 text-right">Edge</th>
<th className="px-3 py-2 text-right"></th>
<th className="px-3 py-2 text-right">DEB</th>
<th className="px-3 py-2 text-right"></th>
<th className="px-3 py-2 text-right"></th>
<th className="px-3 py-2 text-right"></th>
<th className="px-3 py-2 text-right"></th>
<th className="px-3 py-2"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 bg-white">
{loading ? (
<tr>
<td colSpan={13} className="px-3 py-10 text-center text-sm font-semibold text-slate-400">
...
</td>
</tr>
) : rows.length ? (
rows.map((row) => (
<tr key={row.id || `${row.city}-${row.bucket_label}-${row.side}`} className="hover:bg-blue-50/40">
<td className="px-3 py-2 font-black text-slate-900">
<div>{row.display_name || row.city || "—"}</div>
<div className="mt-0.5 text-[11px] font-semibold text-slate-400">
{row.local_time || "—"} · {row.target_date || "—"}
</div>
</td>
<td className="px-3 py-2 font-bold text-slate-800">{row.bucket_label || "—"}</td>
<td className="px-3 py-2">
<span className={`inline-flex min-w-10 justify-center rounded-md border px-2 py-1 text-xs font-black uppercase ${sideTone(row.side)}`}>
{row.side || "—"}
</span>
</td>
<td className="px-3 py-2 text-right font-black text-slate-950">{cents(row.ask_price)}</td>
<td className="px-3 py-2 text-right font-bold text-blue-700">{percent(row.model_probability)}</td>
<td className="px-3 py-2 text-right font-black text-emerald-700">{percent(row.edge)}</td>
<td className="px-3 py-2 text-right font-semibold text-slate-700">{tempLabel(row.current_max_so_far)}</td>
<td className="px-3 py-2 text-right font-semibold text-orange-700">{tempLabel(row.deb_prediction)}</td>
<td className="px-3 py-2 text-right font-semibold text-slate-700">{tempLabel(row.model_median)}</td>
<td className="px-3 py-2 text-right font-semibold text-slate-700">{tempLabel(row.model_spread)}</td>
<td className="px-3 py-2 text-right text-slate-600">{numberLabel(row.liquidity)}</td>
<td className="px-3 py-2 text-right text-slate-600">{numberLabel(row.volume)}</td>
<td className="px-3 py-2">
{row.market_url ? (
<a
href={row.market_url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-xs font-black text-blue-700 hover:text-blue-900"
>
<ExternalLink className="h-3 w-3" />
</a>
) : (
<span className="text-slate-400"></span>
)}
</td>
</tr>
))
) : (
<tr>
<td colSpan={13} className="px-3 py-10 text-center text-sm font-semibold text-slate-400">
</td>
</tr>
)}
</tbody>
</table>
</div>
<div className="mt-3 text-xs font-semibold text-slate-400">
{generatedLabel(payload?.generated_at)}
</div>
</CardContent>
</Card>
</div>
);
}
+23
View File
@@ -293,6 +293,29 @@ export const opsApi = {
};
}>("/api/ops/training/accuracy");
},
marketOpportunities(params: Record<string, string | number | boolean | undefined> = {}) {
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<string, unknown>;
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<Record<string, unknown>>;
}>(`/api/ops/market-opportunities${suffix}`);
},
telegramAudit() {
return opsFetch<{
anomalies: Array<{
+182
View File
@@ -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"] == []
+24
View File
@@ -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,
)
+532
View File
@@ -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",
]
+7
View File
@@ -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
# ---------------------------------------------------------------------------