"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: showAllLowPrice ? "-1" : 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 内部使用,按价格上限扫描 Yes/No 选项,并按模型概率与市场价格差排序。

低价选项扫描
{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)}
); }