From a2da66ca5e536c48756aa1deff9b5ec83eb7f725 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Mon, 25 May 2026 05:18:26 +0800 Subject: [PATCH] =?UTF-8?q?=E7=A7=BB=E9=99=A4=20AI=20prompt=20=E4=B8=AD=20?= =?UTF-8?q?EMOS/CRPS=20=E7=A6=81=E4=BB=A4=E6=96=87=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EMOS/CRPS 从未在项目中实现,仅存在于 AI prompt 的禁止列表和历史文档中。 --- .../dashboard/ScanTerminalDashboard.tsx | 15 +- .../scan-terminal/GroupedMarketTable.tsx | 6 +- .../LiveTemperatureThresholdChart.tsx | 2 +- .../scan-terminal/MarketOverviewView.tsx | 361 ++++++++++++++++ .../scan-terminal/RunwayMeteorologyPanel.tsx | 6 +- .../scan-terminal/TrainingDashboard.tsx | 398 +++++++++--------- .../scan-terminal/continent-grouping.ts | 100 ++++- frontend/lib/dashboard-types.ts | 2 + web/scan_terminal_city_row.py | 2 + web/scan_terminal_service.py | 2 - 10 files changed, 693 insertions(+), 201 deletions(-) create mode 100644 frontend/components/dashboard/scan-terminal/MarketOverviewView.tsx diff --git a/frontend/components/dashboard/ScanTerminalDashboard.tsx b/frontend/components/dashboard/ScanTerminalDashboard.tsx index 9e1cb453..1a801118 100644 --- a/frontend/components/dashboard/ScanTerminalDashboard.tsx +++ b/frontend/components/dashboard/ScanTerminalDashboard.tsx @@ -28,6 +28,7 @@ import { getGapColor, getSignalLabel, getSignalState, + resolveTradingRegionKey, TRADING_REGIONS, } from "@/components/dashboard/scan-terminal/continent-grouping"; import { MobileCityCard } from "@/components/dashboard/scan-terminal/MobileCityCard"; @@ -44,6 +45,7 @@ import { Panel } from "@/components/dashboard/scan-terminal/Panel"; import { GroupedMarketTable } from "@/components/dashboard/scan-terminal/GroupedMarketTable"; import { TrainingDashboard } from "@/components/dashboard/scan-terminal/TrainingDashboard"; import { LiveTemperatureThresholdChart } from "@/components/dashboard/scan-terminal/LiveTemperatureThresholdChart"; +import { MarketOverviewView } from "@/components/dashboard/scan-terminal/MarketOverviewView"; import { rowName, pct, money, temp, edgeClass } from "@/components/dashboard/scan-terminal/utils"; function createEmptyAccess(loading = true): ProAccessState { @@ -340,7 +342,7 @@ function PolyWeatherTerminal({ const filteredRegionRows = useMemo(() => { return rows.filter( (row) => - String(row.trading_region).toLowerCase() === selectedRegionKey && + resolveTradingRegionKey(row) === selectedRegionKey && row.is_primary_signal !== false, ); }, [rows, selectedRegionKey]); @@ -543,6 +545,17 @@ function PolyWeatherTerminal({
{activeNavKey === "training" ? ( + ) : activeNavKey === "markets" ? ( + { + const regionKey = resolveTradingRegionKey(row); + if (regionKey) setSelectedRegionKey(regionKey); + setSelectedRow(row); + setActiveNavKey("contracts"); + }} + /> ) : ( <> {/* Region tabs */} diff --git a/frontend/components/dashboard/scan-terminal/GroupedMarketTable.tsx b/frontend/components/dashboard/scan-terminal/GroupedMarketTable.tsx index 307ca0dd..02b40dcc 100644 --- a/frontend/components/dashboard/scan-terminal/GroupedMarketTable.tsx +++ b/frontend/components/dashboard/scan-terminal/GroupedMarketTable.tsx @@ -53,7 +53,7 @@ export function GroupedMarketTable({
- + @@ -144,12 +144,12 @@ export function GroupedMarketTable({ -
City Obs High {pct(row.edge_percent)} + {formatSpreadLiquidity(row.spread, row.book_liquidity ?? row.market_liquidity)} {item.label} -
+
now: {temp(item.latest)} max: {temp(item.high)} 15m: {item.delta15 === null ? "--" : `${item.delta15 >= 0 ? "+" : ""}${item.delta15.toFixed(1)}°`} diff --git a/frontend/components/dashboard/scan-terminal/MarketOverviewView.tsx b/frontend/components/dashboard/scan-terminal/MarketOverviewView.tsx new file mode 100644 index 00000000..09331fc7 --- /dev/null +++ b/frontend/components/dashboard/scan-terminal/MarketOverviewView.tsx @@ -0,0 +1,361 @@ +"use client"; + +import clsx from "clsx"; +import { useEffect, useMemo, useState } from "react"; +import type { ScanOpportunityRow, ScanTerminalResponse } from "@/lib/dashboard-types"; +import { + resolveTradingRegionKey, + TRADING_REGIONS, +} from "@/components/dashboard/scan-terminal/continent-grouping"; +import { Panel } from "@/components/dashboard/scan-terminal/Panel"; +import { edgeClass, money, pct, rowName } from "@/components/dashboard/scan-terminal/utils"; + +type Holder = { + amount?: number; + name?: string; + outcomeIndex?: number; + proxyWallet?: string; + pseudonym?: string; +}; + +type HolderInfo = { + holders: Holder[] | null; + loading: boolean; +}; + +const MARKET_OVERVIEW_REFRESH_MS = 10 * 60_000; + +function numeric(value: unknown) { + return typeof value === "number" && Number.isFinite(value) ? value : 0; +} + +function rowLiquidity(row: ScanOpportunityRow) { + return numeric(row.book_liquidity || row.market_liquidity || row.volume); +} + +function rowOpportunityScore(row: ScanOpportunityRow) { + return ( + numeric(row.final_score) * 0.5 + + numeric(row.edge_percent ?? row.signed_gap ?? row.gap) * 2 + + Math.log10(rowLiquidity(row) + 1) + ); +} + +function rowSpread(row: ScanOpportunityRow) { + return numeric(row.spread); +} + +function isStale(row: ScanOpportunityRow) { + return Boolean(row.metar_context?.stale_for_today || row.metar_status?.stale_for_today); +} + +function isWatch(row: ScanOpportunityRow) { + const value = String(row.ai_decision || row.v4_metar_decision || row.signal_status || row.action || "").toLowerCase(); + return value.includes("watch") || !row.tradable; +} + +function holderLabel(holder: Holder) { + return ( + holder.name || + holder.pseudonym || + (holder.proxyWallet ? `${String(holder.proxyWallet).slice(0, 6)}...${String(holder.proxyWallet).slice(-4)}` : "--") + ); +} + +function CompactRowsTable({ + empty, + isEn, + onSelectRow, + rows, + showSpread = false, +}: { + empty: string; + isEn: boolean; + onSelectRow: (row: ScanOpportunityRow) => void; + rows: ScanOpportunityRow[]; + showSpread?: boolean; +}) { + if (!rows.length) { + return
{empty}
; + } + return ( + + + + + + + + + + + + {rows.map((row) => { + const edge = numeric(row.edge_percent ?? row.signed_gap ?? row.gap); + return ( + onSelectRow(row)} + className="cursor-pointer border-b border-slate-100 hover:bg-blue-50" + > + + + + + + + ); + })} + +
{isEn ? "City / Contract" : "城市 / 合约"}{isEn ? "Model" : "模型"}{isEn ? "Market" : "市场"}{showSpread ? (isEn ? "Spread" : "价差") : "Edge"}{isEn ? "Liq" : "流动性"}
+
{rowName(row)}
+
{row.target_label || row.market_question || "--"}
+
{pct(row.model_probability ?? row.model_event_probability)}{pct(row.market_probability ?? row.market_event_probability)} + {showSpread ? pct(row.spread) : pct(edge)} + {money(rowLiquidity(row))}
+ ); +} + +export function MarketOverviewView({ + isEn, + onSelectRow, + rows, +}: { + isEn: boolean; + onSelectRow: (row: ScanOpportunityRow) => void; + rows: ScanOpportunityRow[]; +}) { + const [overviewRows, setOverviewRows] = useState(rows); + const [lastScanAt, setLastScanAt] = useState(null); + + useEffect(() => { + setOverviewRows(rows); + }, [rows]); + + useEffect(() => { + let cancelled = false; + let controller: AbortController | null = null; + + const refreshOverview = async () => { + if (typeof fetch !== "function" || typeof AbortController === "undefined") return; + controller?.abort(); + controller = new AbortController(); + try { + const response = await fetch("/api/scan/terminal?force_refresh=false", { + cache: "no-store", + headers: { Accept: "application/json" }, + signal: controller.signal, + }); + if (!response.ok) return; + const payload = await response.json() as ScanTerminalResponse; + if (!cancelled && Array.isArray(payload.rows)) { + setOverviewRows(payload.rows); + setLastScanAt(new Date()); + } + } catch (error) { + if ((error as { name?: string })?.name !== "AbortError") { + // Keep the existing snapshot; overview refresh should never blank the terminal. + } + } + }; + + const intervalId = window.setInterval(() => { + void refreshOverview(); + }, MARKET_OVERVIEW_REFRESH_MS); + + return () => { + cancelled = true; + controller?.abort(); + window.clearInterval(intervalId); + }; + }, []); + + const primaryRows = useMemo( + () => overviewRows.filter((row) => row.is_primary_signal !== false), + [overviewRows], + ); + const regionStats = useMemo( + () => + TRADING_REGIONS.map((region) => { + const list = primaryRows.filter((row) => resolveTradingRegionKey(row) === region.key); + const avgEdge = list.reduce((sum, row) => sum + numeric(row.edge_percent ?? row.signed_gap ?? row.gap), 0) / Math.max(list.length, 1); + return { + key: region.key, + label: isEn ? region.labelEn : region.labelZh, + contracts: list.length, + tradable: list.filter((row) => row.tradable).length, + heat: list.filter((row) => row.risk_level === "high" || numeric(row.current_temp) >= 30).length, + avgEdge, + liquidity: list.reduce((sum, row) => sum + rowLiquidity(row), 0), + }; + }).filter((item) => item.contracts > 0), + [isEn, primaryRows], + ); + + const topOpportunities = useMemo( + () => [...primaryRows].sort((a, b) => rowOpportunityScore(b) - rowOpportunityScore(a)).slice(0, 12), + [primaryRows], + ); + const riskRows = useMemo( + () => + primaryRows + .filter((row) => isWatch(row) || numeric(row.edge_percent ?? row.signed_gap ?? row.gap) < 0 || row.closed || isStale(row)) + .sort((a, b) => numeric(a.edge_percent ?? a.signed_gap ?? a.gap) - numeric(b.edge_percent ?? b.signed_gap ?? b.gap)) + .slice(0, 12), + [primaryRows], + ); + const liquidityRows = useMemo( + () => [...primaryRows].sort((a, b) => rowLiquidity(b) - rowLiquidity(a)).slice(0, 8), + [primaryRows], + ); + const tightSpreadRows = useMemo( + () => + [...primaryRows] + .filter((row) => rowSpread(row) > 0) + .sort((a, b) => rowSpread(a) - rowSpread(b)) + .slice(0, 8), + [primaryRows], + ); + const whaleRows = useMemo( + () => liquidityRows.slice(0, 6), + [liquidityRows], + ); + + const [holderMap, setHolderMap] = useState>({}); + useEffect(() => { + whaleRows.forEach((row) => { + const city = String(row.city || "").toLowerCase(); + if (!city || holderMap[city]?.loading || holderMap[city]?.holders) return; + setHolderMap((prev) => ({ ...prev, [city]: { holders: null, loading: true } })); + fetch(`/api/city/${encodeURIComponent(city)}/holders?limit=6`, { + cache: "no-store", + headers: { Accept: "application/json" }, + }) + .then(async (res) => { + const json = await res.json() as { holders?: Holder[] }; + setHolderMap((prev) => ({ ...prev, [city]: { holders: json.holders || [], loading: false } })); + }) + .catch(() => setHolderMap((prev) => ({ ...prev, [city]: { holders: null, loading: false } }))); + }); + }, [holderMap, whaleRows]); + + return ( +
+ + {isEn ? "Scan: 10m" : "扫描:10分钟"} + {lastScanAt ? ` · ${lastScanAt.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}` : ""} + + } + > +
+ {regionStats.map((item) => ( + + ))} +
+
+ + + + + + + + + + + + + +
+ + + + +
+ {whaleRows.map((row) => { + const city = String(row.city || "").toLowerCase(); + const info = holderMap[city]; + return ( + + ); + })} +
+
+
+
+ ); +} diff --git a/frontend/components/dashboard/scan-terminal/RunwayMeteorologyPanel.tsx b/frontend/components/dashboard/scan-terminal/RunwayMeteorologyPanel.tsx index 46606052..80e92809 100644 --- a/frontend/components/dashboard/scan-terminal/RunwayMeteorologyPanel.tsx +++ b/frontend/components/dashboard/scan-terminal/RunwayMeteorologyPanel.tsx @@ -110,7 +110,7 @@ export function RunwayMeteorologyPanel({
- + @@ -131,14 +131,14 @@ export function RunwayMeteorologyPanel({ diff --git a/frontend/components/dashboard/scan-terminal/TrainingDashboard.tsx b/frontend/components/dashboard/scan-terminal/TrainingDashboard.tsx index 743c6dc7..b9d1f512 100644 --- a/frontend/components/dashboard/scan-terminal/TrainingDashboard.tsx +++ b/frontend/components/dashboard/scan-terminal/TrainingDashboard.tsx @@ -10,26 +10,39 @@ import { XAxis, YAxis, } from "recharts"; -import { TrendingUp, Target, Thermometer, Hash } from "lucide-react"; +import { TrendingUp, Target, Thermometer, Hash, BarChart3, Crosshair } from "lucide-react"; + +type MetricPayload = { + hit_rate: number; + mae: number; + total_days: number; + brier_score?: number; +}; type TrainingCity = { city_id: string; name: string; - deb?: { hit_rate: number; mae: number; total_days: number } | null; + deb?: MetricPayload; + mu?: MetricPayload; }; -const CHART_COLORS = { - high: "#059669", - mid: "#d97706", - low: "#dc2626", - blue: "#2563eb", - purple: "#7c3aed", +const STAT_CARD_CLASSES: Record = { + blue: "bg-blue-50 border-blue-200", + emerald: "bg-emerald-50 border-emerald-200", + amber: "bg-amber-50 border-amber-200", + purple: "bg-purple-50 border-purple-200", +}; +const STAT_ICON_CLASSES: Record = { + blue: "text-blue-600", + emerald: "text-emerald-600", + amber: "text-amber-600", + purple: "text-purple-600", }; function barColor(hr: number) { - if (hr >= 65) return CHART_COLORS.high; - if (hr >= 45) return CHART_COLORS.mid; - return CHART_COLORS.low; + if (hr >= 65) return "#059669"; + if (hr >= 45) return "#d97706"; + return "#dc2626"; } export function TrainingDashboard({ isEn }: { isEn: boolean }) { @@ -37,241 +50,246 @@ export function TrainingDashboard({ isEn }: { isEn: boolean }) { useEffect(() => { let cancelled = false; - fetch("/api/ops/training/accuracy", { - cache: "no-store", - headers: { Accept: "application/json" }, - }) + fetch("/api/ops/training/accuracy", { cache: "no-store", headers: { Accept: "application/json" } }) .then(async (res) => { if (!res.ok) return null; return res.json() as Promise<{ accuracy: TrainingCity[] }>; }) .then((payload) => { if (cancelled || !payload?.accuracy) return; - setData(payload.accuracy.filter((c) => c.deb && c.deb.total_days >= 5)); + setData(payload.accuracy.filter((c) => (c.deb || c.mu) && ((c.deb?.total_days ?? 0) + (c.mu?.total_days ?? 0)) >= 5)); }) .catch(() => {}); return () => { cancelled = true; }; }, []); - const debSorted = useMemo( - () => (data || []).sort((a, b) => (b.deb?.hit_rate ?? 0) - (a.deb?.hit_rate ?? 0)), - [data], - ); + const debSorted = useMemo(() => (data || []).filter((c) => c.deb).sort((a, b) => (b.deb?.hit_rate ?? 0) - (a.deb?.hit_rate ?? 0)), [data]); + const muSorted = useMemo(() => (data || []).filter((c) => c.mu).sort((a, b) => (b.mu?.hit_rate ?? 0) - (a.mu?.hit_rate ?? 0)), [data]); - const stats = useMemo(() => { + const debStats = useMemo(() => { if (!debSorted.length) return null; const avgHit = debSorted.reduce((s, c) => s + (c.deb?.hit_rate ?? 0), 0) / debSorted.length; const avgMae = debSorted.reduce((s, c) => s + (c.deb?.mae ?? 0), 0) / debSorted.length; const totalDays = debSorted.reduce((s, c) => s + (c.deb?.total_days ?? 0), 0); - const high = debSorted.filter((c) => (c.deb?.hit_rate ?? 0) >= 65).length; - const mid = debSorted.filter((c) => { - const hr = c.deb?.hit_rate ?? 0; - return hr >= 45 && hr < 65; - }).length; - const low = debSorted.filter((c) => (c.deb?.hit_rate ?? 0) < 45).length; - return { avgHit, avgMae, totalDays, cities: debSorted.length, high, mid, low }; + return { avgHit, avgMae, totalDays, cities: debSorted.length }; }, [debSorted]); - const chartData = useMemo( - () => - debSorted.slice(0, 18).map((c) => ({ - name: c.name, - hit: Number((c.deb?.hit_rate ?? 0).toFixed(1)), - mae: Number((c.deb?.mae ?? 0).toFixed(2)), - fill: barColor(c.deb?.hit_rate ?? 0), - })), + const muStats = useMemo(() => { + if (!muSorted.length) return null; + const avgHit = muSorted.reduce((s, c) => s + (c.mu?.hit_rate ?? 0), 0) / muSorted.length; + const avgMae = muSorted.reduce((s, c) => s + (c.mu?.mae ?? 0), 0) / muSorted.length; + const avgBrier = muSorted.reduce((s, c) => s + (c.mu?.brier_score ?? 0), 0) / muSorted.length; + const totalDays = muSorted.reduce((s, c) => s + (c.mu?.total_days ?? 0), 0); + return { avgHit, avgMae, avgBrier, totalDays, cities: muSorted.length }; + }, [muSorted]); + + const debHitChart = useMemo( + () => debSorted.slice(0, 18).map((c) => ({ name: c.name, value: Number((c.deb?.hit_rate ?? 0).toFixed(1)) })), [debSorted], ); - - const maeChartData = useMemo( - () => - [...debSorted] - .sort((a, b) => (a.deb?.mae ?? 99) - (b.deb?.mae ?? 99)) - .slice(0, 18) - .map((c) => ({ - name: c.name, - mae: Number((c.deb?.mae ?? 0).toFixed(2)), - })), + const debMaeChart = useMemo( + () => [...debSorted].sort((a, b) => (a.deb?.mae ?? 99) - (b.deb?.mae ?? 99)).slice(0, 18).map((c) => ({ name: c.name, value: Number((c.deb?.mae ?? 0).toFixed(2)) })), [debSorted], ); + const muHitChart = useMemo( + () => muSorted.slice(0, 18).map((c) => ({ name: c.name, value: Number((c.mu?.hit_rate ?? 0).toFixed(1)) })), + [muSorted], + ); + const muBrierChart = useMemo( + () => [...muSorted].sort((a, b) => (a.mu?.brier_score ?? 99) - (b.mu?.brier_score ?? 99)).slice(0, 18).map((c) => ({ name: c.name, value: Number((c.mu?.brier_score ?? 0).toFixed(3)) })), + [muSorted], + ); return (
- {/* Header */} -
-
-

- - {isEn ? "DEB Training Accuracy" : "DEB 训练数据准确率"} -

-

- {isEn - ? "DEB hit rate = forecast within settlement acceptance window" - : "DEB 命中率 = 预报落入结算接受窗口的比例"} -

-
- {stats && ( -
- {isEn ? "Updated" : "更新时间"}: {new Date().toLocaleDateString()} -
- )} -
+

+ + {isEn ? "Model Training Accuracy" : "模型训练准确率"} +

+

+ {isEn + ? "DEB temperature forecast vs. Probability Mu calibration — per-city backtesting metrics." + : "DEB 气温预报 与 概率 μ 校准 — 各城市回测指标。"} +

- {/* Stats cards */} - {stats && ( -
- {[ - { icon: Hash, label: isEn ? "Models" : "城市模型", value: stats.cities, color: "text-blue-600", bg: "bg-blue-50 border-blue-200" }, - { icon: Target, label: isEn ? "Avg Hit Rate" : "平均命中率", value: `${stats.avgHit.toFixed(1)}%`, color: "text-emerald-600", bg: "bg-emerald-50 border-emerald-200" }, - { icon: Thermometer, label: "Avg MAE", value: `${stats.avgMae.toFixed(1)}°`, color: "text-amber-600", bg: "bg-amber-50 border-amber-200" }, - { icon: TrendingUp, label: isEn ? "Total Days" : "总训练天数", value: stats.totalDays.toLocaleString(), color: "text-purple-600", bg: "bg-purple-50 border-purple-200" }, - ].map(({ icon: Icon, label, value, color, bg }) => ( -
- -
-
{label}
-
{String(value)}
+ {/* ── DEB Section ── */} + {debStats && ( + <> +

+ + {isEn ? "DEB Temperature Forecast" : "DEB 气温预报"} +

+
+ {[ + { icon: Hash, label: isEn ? "Cities" : "城市数", value: debStats.cities, tone: "blue" }, + { icon: Target, label: isEn ? "Avg Hit" : "平均命中", value: `${debStats.avgHit.toFixed(1)}%`, tone: "emerald" }, + { icon: Thermometer, label: isEn ? "Avg Error" : "平均误差", value: `${debStats.avgMae.toFixed(1)}°`, tone: "amber" }, + { icon: TrendingUp, label: isEn ? "Total Days" : "训练天数", value: debStats.totalDays.toLocaleString(), tone: "purple" }, + ].map(({ icon: Icon, label, value, tone }) => ( +
+ +
+
{label}
+
{String(value)}
+
-
- ))} -
- )} - - {/* Distribution summary */} - {stats && ( -
- {[ - { label: isEn ? "High (≥65%)" : "高 (≥65%)", count: stats.high, color: "emerald" }, - { label: isEn ? "Mid (45-64%)" : "中 (45-64%)", count: stats.mid, color: "amber" }, - { label: isEn ? "Low (<45%)" : "低 (<45%)", count: stats.low, color: "red" }, - ].map(({ label, count, color }) => ( -
-
{count}
-
{label}
-
- ))} -
- )} - - {/* Charts */} - {chartData.length > 0 && ( -
- {/* Hit rate bar chart */} -
-

- {isEn ? "Hit Rate by City" : "各城市命中率"} -

-
+ ))} +
+
+ - + - `${v}%`} /> - - [`${Number(value)}%`, isEn ? "Hit Rate" : "命中率"]} - /> - + `${v}%`} /> + + [`${Number(v)}%`, isEn ? "Hit Rate" : "命中率"]} /> + -
-
- - {/* MAE bar chart (lower is better) */} -
-

- {isEn ? "MAE by City (lower = better)" : "各城市 MAE (越低越好)"} -

-
+ + - + - `${v}°`} /> - - [`${Number(value)}°`, "MAE"]} - /> - + `${v}°`} /> + + [`${Number(v)}°`, isEn ? "Error" : "误差"]} /> + -
+
-
+ )} - {/* Table */} + {/* ── Mu Section ── */} + {muStats && ( + <> +

+ + {isEn ? "Probability Mu Calibration" : "概率 μ 校准"} +

+
+ {[ + { icon: Hash, label: isEn ? "Cities" : "城市数", value: muStats.cities, tone: "blue" }, + { icon: Target, label: isEn ? "Avg Hit" : "平均命中", value: `${muStats.avgHit.toFixed(1)}%`, tone: "emerald" }, + { icon: Thermometer, label: isEn ? "Avg Error" : "平均误差", value: `${muStats.avgMae.toFixed(2)}°`, tone: "amber" }, + { icon: Crosshair, label: isEn ? "Avg Brier" : "平均 Brier", value: muStats.avgBrier.toFixed(4), tone: "purple" }, + ].map(({ icon: Icon, label, value, tone }) => ( +
+ +
+
{label}
+
{String(value)}
+
+
+ ))} +
+
+ + + + + `${v}%`} /> + + [`${Number(v)}%`, isEn ? "Hit Rate" : "命中率"]} /> + + + + + + + + + `${Number(v).toFixed(2)}`} /> + + [`${Number(v).toFixed(4)}`, "Brier"]} /> + + + + +
+ + )} + + {/* ── Combined Table ── */}
{isEn ? "Runway" : "跑道 (Runway)"} TDZ MID
{r.isSettlement && } {r.name} - {r.isSettlement && {isEn ? "Settlement" : "结算"}} + {r.isSettlement && {isEn ? "Settlement" : "结算"}} {r.tdz}°C {r.mid}
- - - - - + + + + + + + - {debSorted.length ? debSorted.map((c, i) => { - const hr = c.deb?.hit_rate ?? 0; - const mae = c.deb?.mae ?? 0; - const color = hr >= 65 ? "emerald" : hr >= 45 ? "amber" : "red"; - return ( - - - - - - - - ); - }) : ( + {debSorted.length || muSorted.length ? ( + (() => { + const cities = new Map(); + for (const c of debSorted) cities.set(c.city_id, { deb: c.deb, mu: c.mu, name: c.name }); + for (const c of muSorted) { + const existing = cities.get(c.city_id); + if (existing) existing.mu = c.mu; + else cities.set(c.city_id, { deb: c.deb, mu: c.mu, name: c.name }); + } + const merged = [...cities.entries()] + .sort((a, b) => { + const aMax = Math.max(a[1].deb?.hit_rate ?? 0, a[1].mu?.hit_rate ?? 0); + const bMax = Math.max(b[1].deb?.hit_rate ?? 0, b[1].mu?.hit_rate ?? 0); + return bMax - aMax; + }) + .slice(0, 30); + return merged.map(([cityId, { deb, mu, name }], i) => { + const debHit = deb?.hit_rate ?? 0; + const muHit = mu?.hit_rate ?? 0; + const brier = mu?.brier_score; + return ( + + + + + + + + + + ); + }); + })() + ) : ( - )}
# - {isEn ? "City" : "城市"} - - {isEn ? "Hit Rate" : "命中率"} - MAE - {isEn ? "Days" : "训练天数"} - #{isEn ? "City" : "城市"}{isEn ? "DEB Hit" : "DEB 命中"}{isEn ? "DEB Error" : "DEB 误差"}{isEn ? "μ Hit" : "μ 命中"}Brier{isEn ? "Days" : "天数"}
{i + 1}{c.name} -
-
-
-
-
- - {hr.toFixed(0)}% - -
-
{mae.toFixed(1)}°{c.deb?.total_days ?? 0}
{i + 1}{name} + {deb ? `${debHit.toFixed(0)}%` : "--"} + {deb ? `${deb.mae.toFixed(1)}°` : "--"} + {mu ? `${muHit.toFixed(0)}%` : "--"} + {brier != null ? brier.toFixed(4) : "--"}{(deb?.total_days ?? 0) + (mu?.total_days ?? 0)}
- {data === null - ? (isEn ? "Loading training data..." : "加载训练数据中...") - : (isEn ? "No training data available" : "暂无训练数据")} + + {data === null ? (isEn ? "Loading..." : "加载中...") : (isEn ? "No training data" : "暂无训练数据")}
- - {/* Footer note */} -

+

{isEn - ? "Training data updated daily. Hit rate = DEB prediction within settlement acceptance range." - : "训练数据每日更新。命中率 = DEB 预报落入结算接受窗口的比例。"} + ? "DEB = temperature forecast accuracy. μ = probability calibration. Brier = lower is better. Updated daily." + : "DEB = 气温预报准确率。μ = 概率校准。Brier = 越低越好。每日更新。"}

); } + +function ChartCard({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

{title}

+
{children}
+
+ ); +} diff --git a/frontend/components/dashboard/scan-terminal/continent-grouping.ts b/frontend/components/dashboard/scan-terminal/continent-grouping.ts index 4b13d076..344ad803 100644 --- a/frontend/components/dashboard/scan-terminal/continent-grouping.ts +++ b/frontend/components/dashboard/scan-terminal/continent-grouping.ts @@ -12,6 +12,104 @@ export const TRADING_REGIONS = [ export type TradingRegionKey = (typeof TRADING_REGIONS)[number]["key"]; +const TRADING_REGION_KEYS = new Set(TRADING_REGIONS.map((region) => region.key)); + +const CITY_REGION_FALLBACK: Record = { + beijing: "east_asia", + busan: "east_asia", + chengdu: "east_asia", + chongqing: "east_asia", + guangzhou: "east_asia", + "hong kong": "east_asia", + "lau fau shan": "east_asia", + qingdao: "east_asia", + seoul: "east_asia", + shanghai: "east_asia", + shenzhen: "east_asia", + taipei: "east_asia", + tokyo: "east_asia", + wuhan: "east_asia", + jakarta: "southeast_asia", + "kuala lumpur": "southeast_asia", + manila: "southeast_asia", + singapore: "southeast_asia", + karachi: "central_asia", + lucknow: "central_asia", + ankara: "west_asia", + istanbul: "west_asia", + jeddah: "west_asia", + "tel aviv": "west_asia", + amsterdam: "europe_africa", + "cape town": "europe_africa", + helsinki: "europe_africa", + london: "europe_africa", + madrid: "europe_africa", + milan: "europe_africa", + moscow: "europe_africa", + munich: "europe_africa", + paris: "europe_africa", + warsaw: "europe_africa", + "buenos aires": "south_america", + "sao paulo": "south_america", + "mexico city": "north_america", + atlanta: "north_america", + austin: "north_america", + chicago: "north_america", + dallas: "north_america", + denver: "north_america", + houston: "north_america", + "los angeles": "north_america", + miami: "north_america", + "new york": "north_america", + "panama city": "north_america", + "san francisco": "north_america", + seattle: "north_america", + toronto: "north_america", +}; + +function normalizeRegionValue(value?: string | null) { + return String(value || "") + .trim() + .toLowerCase() + .replace(/[-\s]+/g, "_"); +} + +function normalizeCityValue(value?: string | null) { + return String(value || "") + .trim() + .toLowerCase() + .replace(/[_-]+/g, " ") + .replace(/\s+/g, " "); +} + +function finiteNumber(value: unknown) { + const numeric = Number(value); + return Number.isFinite(numeric) ? numeric : null; +} + +export function resolveTradingRegionKey(row: ScanOpportunityRow): TradingRegionKey | null { + const direct = normalizeRegionValue(row.trading_region); + if (TRADING_REGION_KEYS.has(direct)) return direct as TradingRegionKey; + + const cityKey = normalizeCityValue(row.city || row.city_display_name || row.display_name); + const cityRegion = CITY_REGION_FALLBACK[cityKey]; + if (cityRegion) return cityRegion; + + const offset = finiteNumber(row.tz_offset_seconds); + if (offset !== null) { + const hours = offset / 3600; + if (hours >= 8) return "east_asia"; + if (hours >= 7) return "southeast_asia"; + if (hours >= 5) return "central_asia"; + if (hours >= 3) return "west_asia"; + if (hours >= 0) return "europe_africa"; + if (hours >= -5) return "south_america"; + return "north_america"; + } + + return null; +} + export interface ContinentGroup { key: TradingRegionKey | "active_signals"; labelEn: string; @@ -117,7 +215,7 @@ export function buildContinentGroups(rows: ScanOpportunityRow[], isEn: boolean): const regionMap = new Map(); for (const row of rows) { - const region = String(row.trading_region || "unknown").toLowerCase(); + const region = resolveTradingRegionKey(row) || "unknown"; if (!regionMap.has(region)) regionMap.set(region, []); regionMap.get(region)!.push(row); } diff --git a/frontend/lib/dashboard-types.ts b/frontend/lib/dashboard-types.ts index 259ecb53..8af86c60 100644 --- a/frontend/lib/dashboard-types.ts +++ b/frontend/lib/dashboard-types.ts @@ -937,6 +937,8 @@ export interface CityDetail { market_scan?: MarketScan; intraday_meteorology?: IntradayMeteorology; amos?: AmosData | null; + top_buckets?: MarketTopBucket[] | null; + all_buckets?: MarketTopBucket[] | null; } export interface AmosData { diff --git a/web/scan_terminal_city_row.py b/web/scan_terminal_city_row.py index 0a321896..4c5d6f0e 100644 --- a/web/scan_terminal_city_row.py +++ b/web/scan_terminal_city_row.py @@ -127,6 +127,8 @@ def _build_terminal_row( "final_score": final_score, "volume": volume, "amos": data.get("amos") or None, + "top_buckets": scan.get("top_buckets") or [], + "all_buckets": scan.get("all_buckets") or [], } diff --git a/web/scan_terminal_service.py b/web/scan_terminal_service.py index d9ec916e..1add20b5 100644 --- a/web/scan_terminal_service.py +++ b/web/scan_terminal_service.py @@ -247,7 +247,6 @@ def _call_deepseek_scan_ai(ai_input: Dict[str, Any]) -> Dict[str, Any]: "多个天气模型预测值 model_cluster.sources、METAR 实测序列、机场原始报文和候选合约。" "你的首要任务不是分析套利,也不是推荐 BUY YES/NO,而是预测该城市今日最终最高温是多少。" "必须输出城市级最高温点估计、置信区间、置信度、峰值窗口状态、机场报文解读和一句预测理由。" - "V4 禁止使用 EMOS、EMOS peak、EMOS probability、edge 或 Kelly 作为交易依据;" "最高温预测必须直接参考该城市全部 model_cluster.sources、DEB、峰值窗口和 METAR/机场报文。" "如果天气模型之间分歧大,必须放宽置信区间并降低 confidence;如果 METAR 与模型路径冲突,必须解释修正方向。" "必须先判断 peak_window_label、minutes_until_peak_start/end 和 window_phase:峰值窗口尚未到来时," @@ -267,7 +266,6 @@ def _call_deepseek_scan_ai(ai_input: Dict[str, Any]) -> Dict[str, Any]: "contract_notes items are optional and require row_id, forecast_match, reason_zh, reason_en; " "forecast_match must be one of core, edge, outside, watch. " "Focus on final max temperature prediction; do not output recommendations/vetoed/downgraded unless needed for backward compatibility. " - "Do not mention EMOS, edge, Kelly, arbitrage, position size, or trading recommendation. " "Keep every city forecast concise: one sentence for METAR read and one sentence for reasoning." ), "snapshot": model_snapshot,