Add ops low-price market opportunities
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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<{
|
||||
|
||||
@@ -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"] == []
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user