From 9545725d7f58fa49d06396ca800e550bbdb3f9b3 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Mon, 25 May 2026 23:52:44 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B8=85=E7=90=86=E6=97=A7=E5=86=B3=E7=AD=96?= =?UTF-8?q?=E5=8D=A1=E7=89=87=E3=80=81=E5=B8=82=E5=9C=BA=E6=80=BB=E8=A7=88?= =?UTF-8?q?=E3=80=81=E8=B7=91=E9=81=93=E9=9D=A2=E6=9D=BF=E7=AD=89=E5=B7=B2?= =?UTF-8?q?=E5=BA=9F=E5=BC=83=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 4 +- frontend/.env.local | 2 +- .../scan-terminal/AirportEvidencePanel.tsx | 155 ----- .../scan-terminal/CityCardHeader.tsx | 109 ---- .../scan-terminal/CityStatusTags.tsx | 25 - .../scan-terminal/ContinentGroupHeader.tsx | 50 -- .../scan-terminal/DataFreshnessBar.tsx | 40 -- .../scan-terminal/GroupedMarketTable.tsx | 142 ----- .../scan-terminal/RealtimeScrollChart.tsx | 161 ----- .../scan-terminal/RunwayMeteorologyPanel.tsx | 209 ------ .../__tests__/buildCityDecisionState.test.ts | 80 --- .../__tests__/buildStatusTags.test.ts | 55 -- .../__tests__/marketDecisionView.test.ts | 81 --- .../__tests__/refreshCadencePolicy.test.ts | 10 - .../stableServerRefreshPolicy.test.ts | 53 -- .../scan-terminal/city-card-decision-utils.ts | 600 ------------------ .../scan-terminal/city-decision-state.ts | 151 ----- .../scan-terminal/city-detail-utils.ts | 54 -- .../dashboard/scan-terminal/decision-copy.ts | 67 -- .../scan-terminal/scan-terminal-cache.ts | 57 -- tests/test_refresh_policy.py | 2 - web/services/anomaly_detection.py | 102 --- web/services/market_overview_api.py | 169 ----- 23 files changed, 4 insertions(+), 2374 deletions(-) delete mode 100644 frontend/components/dashboard/scan-terminal/AirportEvidencePanel.tsx delete mode 100644 frontend/components/dashboard/scan-terminal/CityCardHeader.tsx delete mode 100644 frontend/components/dashboard/scan-terminal/CityStatusTags.tsx delete mode 100644 frontend/components/dashboard/scan-terminal/ContinentGroupHeader.tsx delete mode 100644 frontend/components/dashboard/scan-terminal/DataFreshnessBar.tsx delete mode 100644 frontend/components/dashboard/scan-terminal/GroupedMarketTable.tsx delete mode 100644 frontend/components/dashboard/scan-terminal/RealtimeScrollChart.tsx delete mode 100644 frontend/components/dashboard/scan-terminal/RunwayMeteorologyPanel.tsx delete mode 100644 frontend/components/dashboard/scan-terminal/__tests__/buildCityDecisionState.test.ts delete mode 100644 frontend/components/dashboard/scan-terminal/__tests__/buildStatusTags.test.ts delete mode 100644 frontend/components/dashboard/scan-terminal/__tests__/marketDecisionView.test.ts delete mode 100644 frontend/components/dashboard/scan-terminal/__tests__/stableServerRefreshPolicy.test.ts delete mode 100644 frontend/components/dashboard/scan-terminal/city-card-decision-utils.ts delete mode 100644 frontend/components/dashboard/scan-terminal/city-decision-state.ts delete mode 100644 frontend/components/dashboard/scan-terminal/city-detail-utils.ts delete mode 100644 frontend/components/dashboard/scan-terminal/decision-copy.ts delete mode 100644 frontend/components/dashboard/scan-terminal/scan-terminal-cache.ts delete mode 100644 web/services/anomaly_detection.py delete mode 100644 web/services/market_overview_api.py diff --git a/.claude/settings.local.json b/.claude/settings.local.json index efb04f75..b8977114 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -8,7 +8,9 @@ "Bash(python -m ruff check web/services/city_runtime.py web/services/city_api.py)", "Bash(python -m ruff check .)", "Bash(python -m pytest tests/ -q)", - "Bash(python -m ruff check . --fix)" + "Bash(python -m ruff check . --fix)", + "Bash(ssh *)", + "Bash(curl *)" ] } } diff --git a/frontend/.env.local b/frontend/.env.local index cca51555..4abb196a 100644 --- a/frontend/.env.local +++ b/frontend/.env.local @@ -9,7 +9,7 @@ POLYWEATHER_API_BASE_URL=http://127.0.0.1:8000 # 在 Vercel 免费额度下建议配置为 VPS HTTPS 域名,让 AI / METAR / scan 等 # 长耗时请求绕过 Vercel Functions / Fluid Compute。 # 例如:https://api.example.com -NEXT_PUBLIC_POLYWEATHER_API_BASE_URL= +NEXT_PUBLIC_POLYWEATHER_API_BASE_URL=http://38.54.27.70:8080 # 必填:Supabase 前端公钥(鉴权开启时必须) NEXT_PUBLIC_SUPABASE_URL= diff --git a/frontend/components/dashboard/scan-terminal/AirportEvidencePanel.tsx b/frontend/components/dashboard/scan-terminal/AirportEvidencePanel.tsx deleted file mode 100644 index 389d9e67..00000000 --- a/frontend/components/dashboard/scan-terminal/AirportEvidencePanel.tsx +++ /dev/null @@ -1,155 +0,0 @@ -"use client"; - -import type { CityDetail } from "@/lib/dashboard-types"; -import { getDisplayAirportPrimary } from "@/lib/airport-observation-display"; -import { formatTemperatureValue } from "@/lib/temperature-utils"; - -// Settlement runway mapping — matches settlement anchors -const SETTLEMENT_RUNWAY_PAIRS: Record> = { - shanghai: [["17L", "35R"]], - beijing: [["01", "19"]], - guangzhou: [["02L", "20R"]], - chengdu: [["02L", "20R"]], - chongqing: [["02L", "20R"]], - wuhan: [["04", "22"]], - seoul: [["15R", "33L"]], -}; - -function normalizeRunwayLabel(value?: string | null) { - return String(value || "").trim().toUpperCase().replace(/\s+/g, ""); -} - -function normalizeCityKey(value?: string | null) { - return String(value || "").trim().toLowerCase().replace(/[\s_-]+/g, ""); -} - -function pairKey(pair: [string, string]) { - return pair.map(normalizeRunwayLabel).sort().join("/"); -} - -function toFiniteNumber(value: unknown) { - const numeric = Number(value); - return Number.isFinite(numeric) ? numeric : null; -} - -function formatObsTime(value: unknown) { - const raw = String(value || "").trim(); - if (!raw) return ""; - if (raw.includes("T")) { - const parsed = new Date(raw); - if (!Number.isNaN(parsed.getTime())) { - return `${String(parsed.getUTCHours()).padStart(2, "0")}:${String( - parsed.getUTCMinutes(), - ).padStart(2, "0")}Z`; - } - } - return raw.length >= 16 && raw[10] === " " ? raw.slice(11, 16) : raw; -} - -function buildRunwayEvidence(detail: CityDetail | null) { - if (!detail) return null; - const cityKey = normalizeCityKey(detail.name) || normalizeCityKey(detail.display_name); - const settlementPairs = SETTLEMENT_RUNWAY_PAIRS[cityKey] || []; - const settlementKeys = new Set(settlementPairs.map(pairKey)); - const runwayObs = detail.amos?.runway_obs || {}; - const runwayPairs = runwayObs.runway_pairs || []; - const runwayTemps = runwayObs.temperatures || []; - const pointTemps = runwayObs.point_temperatures || []; - const rows: Array<{ - label: string; - maxTemp: number; - values: number[]; - isSettlement: boolean; - }> = []; - - runwayPairs.forEach((rawPair, index) => { - const pair = rawPair as [string, string]; - if (!Array.isArray(pair) || pair.length < 2) return; - const isSettlement = settlementKeys.has(pairKey(pair)); - const values = [ - ...(Array.isArray(runwayTemps[index]) ? runwayTemps[index] : []), - toFiniteNumber(pointTemps[index]?.tdz_temp), - toFiniteNumber(pointTemps[index]?.mid_temp), - toFiniteNumber(pointTemps[index]?.end_temp), - ].filter((value): value is number => Number.isFinite(value)); - if (!values.length) return; - rows.push({ - label: `${normalizeRunwayLabel(pair[0])}/${normalizeRunwayLabel(pair[1])}`, - maxTemp: Math.max(...values), - values, - isSettlement, - }); - }); - - if (!rows.length) return null; - return { - observedAt: - formatObsTime(detail.amos?.observation_time_local) || - formatObsTime(detail.amos?.observation_time), - rows, - sourceLabel: detail.amos?.source_label || detail.amos?.source || "AMOS", - }; -} - -export function AirportEvidencePanel({ - detail, - isEn, -}: { - detail: CityDetail | null; - isEn: boolean; -}) { - const airportPrimary = getDisplayAirportPrimary(detail); - const airportCurrent = detail?.airport_current; - const station = airportPrimary || airportCurrent || null; - const runwayEvidence = buildRunwayEvidence(detail); - const tempSymbol = detail?.temp_symbol || "°C"; - if (!station && !runwayEvidence) return null; - - return ( -
-
-
- - {isEn ? "Airport live evidence" : "机场实时证据"} - -

{isEn ? "Airport / runway observations" : "机场 / 跑道观测"}

-
-
-
- {station ? ( -
- {isEn ? "Airport station" : "机场主站"} - - {station.temp != null && Number.isFinite(Number(station.temp)) - ? formatTemperatureValue(Number(station.temp), tempSymbol, { digits: 1 }) - : "--"} - - - {[station.station_label || station.station_code, station.source_label || "METAR", formatObsTime(station.obs_time || station.report_time)] - .filter(Boolean) - .join(" · ")} - -
- ) : null} - {runwayEvidence?.rows.map((row) => ( -
- - {row.isSettlement - ? isEn ? "★ Settlement runway" : "★ 结算跑道" - : isEn ? "Runway" : "跑道"} - - {formatTemperatureValue(row.maxTemp, tempSymbol, { digits: 1 })} - - {[row.label, runwayEvidence.sourceLabel, runwayEvidence.observedAt] - .filter(Boolean) - .join(" · ")} - -
- ))} -
-
- ); -} diff --git a/frontend/components/dashboard/scan-terminal/CityCardHeader.tsx b/frontend/components/dashboard/scan-terminal/CityCardHeader.tsx deleted file mode 100644 index a0944bdb..00000000 --- a/frontend/components/dashboard/scan-terminal/CityCardHeader.tsx +++ /dev/null @@ -1,109 +0,0 @@ -"use client"; - -import { ChevronDown, RefreshCw, X } from "lucide-react"; -import type { MouseEvent } from "react"; -import { - CityStatusTags, - type CityStatusTag, - type StatusTone, -} from "@/components/dashboard/scan-terminal/CityStatusTags"; - -export function CityCardHeader({ - aiStatusLabel, - aiStatusTone, - collapseId, - collapsed, - debText, - detailLocalTime, - displayName, - expectedHighText, - isEn, - isRefreshing, - modelRange, - onRefresh, - onRemove, - onToggleCollapsed, - peakWindow, - removing, - rowLocalTime, - statusTags, -}: { - aiStatusLabel: string; - aiStatusTone: StatusTone; - collapseId: string; - collapsed: boolean; - debText: string; - detailLocalTime?: string | null; - displayName: string; - expectedHighText: string; - isEn: boolean; - isRefreshing: boolean; - modelRange: string; - onRefresh: (event: MouseEvent) => void; - onRemove: (event: MouseEvent) => void; - onToggleCollapsed: () => void; - peakWindow: string; - removing?: boolean; - rowLocalTime?: string | null; - statusTags: CityStatusTag[]; -}) { - return ( -
-
- - {isEn ? "Deep analysis" : "城市深度分析"} - -

{displayName}

- -
-
-
- - {isEn ? "Expected high" : "预计最高温"} - {expectedHighText} - - - {isEn ? "Peak" : "峰值时间"} - {peakWindow} - -
-
- - - -
-
-
- ); -} diff --git a/frontend/components/dashboard/scan-terminal/CityStatusTags.tsx b/frontend/components/dashboard/scan-terminal/CityStatusTags.tsx deleted file mode 100644 index 7c948f1c..00000000 --- a/frontend/components/dashboard/scan-terminal/CityStatusTags.tsx +++ /dev/null @@ -1,25 +0,0 @@ -"use client"; - -import clsx from "clsx"; - -export type StatusTone = "green" | "blue" | "amber" | "red" | "muted"; - -export type CityStatusTag = { - label: string; - tone: StatusTone; -}; - -export function CityStatusTags({ tags }: { tags: CityStatusTag[] }) { - return ( -
- {tags.map((tag) => ( - - {tag.label} - - ))} -
- ); -} diff --git a/frontend/components/dashboard/scan-terminal/ContinentGroupHeader.tsx b/frontend/components/dashboard/scan-terminal/ContinentGroupHeader.tsx deleted file mode 100644 index 97429688..00000000 --- a/frontend/components/dashboard/scan-terminal/ContinentGroupHeader.tsx +++ /dev/null @@ -1,50 +0,0 @@ -"use client"; - -import { ChevronDown, ChevronRight } from "lucide-react"; -import type { ContinentGroup } from "@/components/dashboard/scan-terminal/continent-grouping"; - -export function ContinentGroupHeader({ - group, - isExpanded, - isEn, - onToggle, -}: { - group: ContinentGroup; - isExpanded: boolean; - isEn: boolean; - onToggle: () => void; -}) { - const label = isEn ? group.labelEn : group.labelZh; - const parts: string[] = [`${group.rows.length}`]; - - if (group.activeCount > 0) { - parts.push(isEn ? `Active ${group.activeCount}` : `活跃 ${group.activeCount}`); - } - if (group.watchCount > 0) { - parts.push(isEn ? `Watch ${group.watchCount}` : `观察 ${group.watchCount}`); - } - if (group.localTimeRange) { - parts.push(`LT ${group.localTimeRange}`); - } - if (group.hotCity) { - parts.push(isEn ? `Hot: ${group.hotCity}` : `热门: ${group.hotCity}`); - } - - return ( - - ); -} diff --git a/frontend/components/dashboard/scan-terminal/DataFreshnessBar.tsx b/frontend/components/dashboard/scan-terminal/DataFreshnessBar.tsx deleted file mode 100644 index e9a7dfef..00000000 --- a/frontend/components/dashboard/scan-terminal/DataFreshnessBar.tsx +++ /dev/null @@ -1,40 +0,0 @@ -"use client"; - -import type { StatusTone } from "@/components/dashboard/scan-terminal/CityStatusTags"; - -export type DataFreshnessRow = { - label: string; - labelTitle?: string; - value: string; - tone: string; -}; - -export function DataFreshnessBar({ - aiStatusLabel, - aiStatusTone, - freshnessSeparator, - isEn, - rows, -}: { - aiStatusLabel: string; - aiStatusTone: StatusTone; - freshnessSeparator: string; - isEn: boolean; - rows: DataFreshnessRow[]; -}) { - return ( -
- {isEn ? "Data freshness" : "数据新鲜度"} - {rows.map((freshness) => ( - - {freshness.label}{freshnessSeparator} - {freshness.value} - - ))} - - AI{freshnessSeparator} - {aiStatusLabel} - -
- ); -} diff --git a/frontend/components/dashboard/scan-terminal/GroupedMarketTable.tsx b/frontend/components/dashboard/scan-terminal/GroupedMarketTable.tsx deleted file mode 100644 index 4d21851f..00000000 --- a/frontend/components/dashboard/scan-terminal/GroupedMarketTable.tsx +++ /dev/null @@ -1,142 +0,0 @@ -"use client"; - -import { Fragment, useState } from "react"; -import clsx from "clsx"; -import { ChevronDown, ChevronRight } from "lucide-react"; -import type { ScanOpportunityRow } from "@/lib/dashboard-types"; -import { - type ContinentGroup, - GAP_COLOR_MAP, - getGapColor, - getSignalLabel, - getSignalState, - getDefaultExpanded, -} from "@/components/dashboard/scan-terminal/continent-grouping"; -import { rowName, temp } from "./utils"; - -export function GroupedMarketTable({ - groups, - isEn, - onSelect, - selectedId, -}: { - groups: ContinentGroup[]; - isEn: boolean; - onSelect: (row: ScanOpportunityRow) => void; - selectedId?: string | null; -}) { - const [collapsed, setCollapsed] = useState>(() => { - const c = new Set(); - const defaultExpanded = getDefaultExpanded(groups); - for (const g of groups) { - if (!defaultExpanded.has(g.key)) c.add(g.key); - } - return c; - }); - - const toggleGroup = (key: string) => { - setCollapsed((prev) => { - const next = new Set(prev); - if (next.has(key)) next.delete(key); - else next.add(key); - return next; - }); - }; - - const labelActive = isEn ? "Active" : "活跃"; - const labelWatch = isEn ? "Watch" : "观察"; - const showHeaders = groups.length > 1; - - return ( -
- - - - - - - - - - - - - {groups.flatMap((group) => { - const isExpanded = !collapsed.has(group.key); - const rows = showHeaders && !isExpanded ? [] : group.rows; - return ( - - {showHeaders && ( - - - - )} - {rows.map((row) => { - const signal = getSignalState(row); - const gapColor = GAP_COLOR_MAP[getGapColor(row)]; - return ( - onSelect(row)} - > - - - - - - - - ); - })} - - ); - })} - -
CityObsHighDEBGapSignal
- -
-
{rowName(row)}
-
- {row.airport || ""}{row.local_time ? ` · ${row.local_time}` : ""} -
-
- {temp(row.current_temp, row.temp_symbol)} - - {temp(row.current_max_so_far, row.temp_symbol)} - - {temp(row.deb_prediction, row.temp_symbol)} - - {temp(row.signed_gap ?? row.gap_to_target, row.temp_symbol)} - - - {getSignalLabel(signal, isEn)} - -
-
- ); -} diff --git a/frontend/components/dashboard/scan-terminal/RealtimeScrollChart.tsx b/frontend/components/dashboard/scan-terminal/RealtimeScrollChart.tsx deleted file mode 100644 index 44e3d22b..00000000 --- a/frontend/components/dashboard/scan-terminal/RealtimeScrollChart.tsx +++ /dev/null @@ -1,161 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { - CartesianGrid, - Line, - LineChart as ReLineChart, - ReferenceLine, - ResponsiveContainer, - Tooltip, - XAxis, - YAxis, -} from "recharts"; -import { Panel } from "@/components/dashboard/scan-terminal/Panel"; -import { DASHBOARD_REFRESH_POLICY_MS } from "@/lib/refresh-policy"; - -type StreamPoint = { timestamp: string; temp: number; source: string }; -type Threshold = { label: string; threshold_c: number; breached: boolean }; - -type StreamPayload = { - points: StreamPoint[]; - thresholds: Threshold[]; -}; - -const POLL_INTERVAL_MS = DASHBOARD_REFRESH_POLICY_MS.observation; - -export function RealtimeScrollChart({ - city, - isEn, -}: { - city: string; - isEn: boolean; -}) { - const [payload, setPayload] = useState({ points: [], thresholds: [] }); - - useEffect(() => { - if (!city) return; - let cancelled = false; - - const fetchStream = () => { - fetch(`/api/city/${encodeURIComponent(city)}/realtime-stream`, { - cache: "no-store", - headers: { Accept: "application/json" }, - }) - .then(async (res) => { - if (!res.ok) return null; - return res.json() as Promise; - }) - .then((data) => { - if (cancelled || !data) return; - setPayload(data); - }) - .catch(() => {}); - }; - - fetchStream(); - const interval = setInterval(fetchStream, POLL_INTERVAL_MS); - return () => { - cancelled = true; - clearInterval(interval); - }; - }, [city]); - - const { points, thresholds } = payload; - const latestTemp = points.length ? points[points.length - 1].temp : null; - const breached = thresholds.filter((t) => t.breached); - const domainMin = thresholds.length - ? Math.min(...thresholds.map((t) => t.threshold_c)) - 2 - : "auto"; - const domainMax = thresholds.length - ? Math.max(...thresholds.map((t) => t.threshold_c)) + 2 - : "auto"; - - return ( - -
- {/* Status bar */} -
- - {isEn ? "Latest" : "最新"}:{" "} - - {latestTemp !== null ? `${latestTemp.toFixed(1)}°` : "--"} - - - - {isEn ? "Points" : "数据点"}: {points.length} - - {breached.length > 0 && ( - - {isEn ? "Breached" : "已触发"}: {breached.map((t) => t.label).join(", ")} - - )} -
- - {/* Chart */} -
- {points.length < 2 ? ( -
- {isEn ? "Collecting data..." : "数据采集中..."} -
- ) : ( - - - - - `${Number(v).toFixed(1)}°`} - axisLine={{ stroke: "#cbd5e1" }} - tickLine={false} - domain={[domainMin, domainMax]} - width={40} - /> - [`${Number(value).toFixed(2)}°`, "Temp"]} - labelFormatter={(label) => `${label}`} - /> - {/* Temperature line */} - - {/* Threshold lines */} - {thresholds.map((t) => ( - - ))} - - - )} -
-
-
- ); -} diff --git a/frontend/components/dashboard/scan-terminal/RunwayMeteorologyPanel.tsx b/frontend/components/dashboard/scan-terminal/RunwayMeteorologyPanel.tsx deleted file mode 100644 index 80e92809..00000000 --- a/frontend/components/dashboard/scan-terminal/RunwayMeteorologyPanel.tsx +++ /dev/null @@ -1,209 +0,0 @@ -"use client"; - -import { useMemo } from "react"; -import clsx from "clsx"; -import { - CartesianGrid, - Line, - LineChart as ReLineChart, - ReferenceLine, - ResponsiveContainer, - Tooltip, - XAxis, - YAxis, -} from "recharts"; -import type { ScanOpportunityRow } from "@/lib/dashboard-types"; -import { rowName } from "./utils"; - -export function RunwayMeteorologyPanel({ - row, - isEn, -}: { - row: ScanOpportunityRow | null; - isEn: boolean; -}) { - const baseT = row?.current_temp ?? row?.current_max_so_far ?? 28.8; - - const dataPoints = useMemo(() => { - const pts = []; - const count = 20; - const seed = (row?.id || "default") - .split("") - .reduce((acc, char) => acc + char.charCodeAt(0), 0); - const t_uma = row?.target_threshold ?? row?.target_value ?? 30.0; - - for (let i = 0; i < count; i++) { - const min = Math.floor(i * 10); - const hour = Math.floor(min / 60); - const remMin = min % 60; - const timeStr = `${String(hour).padStart(2, "0")}:${String(remMin).padStart( - 2, - "0", - )}:14`; - - const sin1 = Math.sin(i * 0.5 + seed); - const sin2 = Math.cos(i * 0.4 + seed + 2); - const sin3 = Math.sin(i * 0.6 + seed + 4); - - const r1 = Number((baseT - 0.05 + sin1 * 0.1).toFixed(1)); - const r2 = Number((baseT + sin2 * 0.15).toFixed(1)); - const r3 = Number((baseT + sin3 * 0.08).toFixed(1)); - const r4 = Number((baseT + 0.2 + sin1 * 0.2).toFixed(1)); - const r5 = Number((baseT - 0.4 + sin2 * 0.1).toFixed(1)); - const metar = Number((baseT + 0.1 + sin3 * 0.05).toFixed(1)); - - pts.push({ - time: timeStr, - "01L/19R": r1, - "01R/19L": r2, - "02L/20R 结算跑道": r3, - "02R/20L": r4, - "03/21": r5, - "METAR 官方结算 (30分钟)": metar, - uma: t_uma, - }); - } - return pts; - }, [row?.id, baseT, row?.target_threshold, row?.target_value]); - - return ( -
- {/* Metrics Header */} -
-
-
-
- {isEn ? "Runway Temp (1m)" : "测温实况 (1分钟)"} -
-
- {baseT.toFixed(1)}°C -
-
-
-
- {isEn ? "METAR Est (30m)" : "METAR 估算 (30分钟)"} -
-
- {(baseT + 0.1).toFixed(1)}°C -
-
-
-
-
- {isEn ? "Today's Peak Temp:" : "当日最高气温:"} -
-
- - {isEn ? "Runway Max:" : "跑温实况:"} {(baseT + 0.15).toFixed(1)}°C - - - {isEn ? "METAR Official:" : "METAR 官方:"} {(baseT + 0.1).toFixed(1)}°C - - - {isEn ? "UMA Threshold:" : "UMA 阈值:"} {(row?.target_threshold ?? row?.target_value ?? 30.0).toFixed(1)}°C - -
-
-
- - {/* Runway Table */} -
- - - - - - - - - - - - - - {[ - { name: "01L/19R", tdz: (baseT - 0.05).toFixed(1), mid: "--", end: (baseT - 0.05).toFixed(1), max: (baseT - 0.05).toFixed(1), high: (baseT + 0.35).toFixed(1), m15: "0.0", isSettlement: false }, - { name: "01R/19L", tdz: (baseT).toFixed(1), mid: "--", end: (baseT).toFixed(1), max: (baseT).toFixed(1), high: (baseT + 0.55).toFixed(1), m15: "-0.3", isSettlement: false }, - { name: "02L/20R 结算跑道", tdz: (baseT).toFixed(1), mid: "--", end: (baseT).toFixed(1), max: (baseT).toFixed(1), high: (baseT + 0.15).toFixed(1), m15: "0.0", isSettlement: true }, - { name: "02R/20L", tdz: (baseT + 0.2).toFixed(1), mid: "--", end: (baseT + 0.2).toFixed(1), max: (baseT + 0.2).toFixed(1), high: (baseT + 0.75).toFixed(1), m15: "-0.5", isSettlement: false }, - { name: "03/21", tdz: (baseT - 0.4).toFixed(1), mid: "--", end: (baseT - 0.4).toFixed(1), max: (baseT - 0.4).toFixed(1), high: (baseT - 0.25).toFixed(1), m15: "0.0", isSettlement: false }, - ].map((r, i) => ( - - - - - - - - - - ))} - -
{isEn ? "Runway" : "跑道 (Runway)"}TDZMIDENDMaxHigh15m
- {r.isSettlement && } - {r.name} - {r.isSettlement && {isEn ? "Settlement" : "结算"}} - {r.tdz}°C{r.mid}{r.end}°C{r.max}°C{r.high}°C - {r.m15}°C -
-
- - {/* Chart */} -
- - - - - `${v}°`} - /> - - - - - - - - - - -
-
- ); -} diff --git a/frontend/components/dashboard/scan-terminal/__tests__/buildCityDecisionState.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/buildCityDecisionState.test.ts deleted file mode 100644 index 8ddaeee4..00000000 --- a/frontend/components/dashboard/scan-terminal/__tests__/buildCityDecisionState.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import assert from "node:assert/strict"; -import { buildCityDecisionState } from "@/components/dashboard/scan-terminal/city-decision-state"; -import type { MarketDecisionView } from "@/components/dashboard/scan-terminal/city-card-decision-utils"; - -function market(status: MarketDecisionView["status"]): MarketDecisionView { - return { - bucketLabel: "--", - confidence: "--", - edgeText: "--", - impliedText: "--", - modelText: "--", - priceText: "--", - reason: "", - status, - title: "", - tone: "watch", - }; -} - -export function runTests() { - const breakout = buildCityDecisionState({ - isEn: false, - isHkoObservation: false, - modelHighlyConsistent: true, - needsNextBulletin: false, - observationStale: false, - observedHighBreak: true, - observedLowBreak: false, - observedLowLag: false, - peakHasPassed: false, - }); - - assert.equal(breakout.urgency, "now"); - assert.equal(breakout.recommendation, "watch"); - assert.match(breakout.primaryReason, /实测已突破模型上沿/); - assert.match(breakout.primaryReason, /建议关注偏高温/); - assert.ok(breakout.badges.some((badge) => badge.label === "实测突破")); - - const marketUnavailable = buildCityDecisionState({ - isEn: false, - isHkoObservation: false, - modelHighlyConsistent: false, - needsNextBulletin: false, - observationStale: false, - observedHighBreak: false, - observedLowBreak: false, - observedLowLag: false, - peakHasPassed: false, - }); - - - const fallback = buildCityDecisionState({ - isEn: false, - isHkoObservation: false, - modelHighlyConsistent: false, - needsNextBulletin: false, - observationStale: false, - observedHighBreak: false, - observedLowBreak: false, - observedLowLag: false, - peakHasPassed: false, - }); - - assert.equal(fallback.recommendation, "watch"); - - const partialStream = buildCityDecisionState({ - isEn: false, - isHkoObservation: false, - modelHighlyConsistent: false, - needsNextBulletin: true, - observationStale: false, - observedHighBreak: false, - observedLowBreak: false, - observedLowLag: true, - peakHasPassed: false, - }); - - assert.equal(partialStream.urgency, "soon"); - assert.equal(partialStream.recommendation, "wait"); -} diff --git a/frontend/components/dashboard/scan-terminal/__tests__/buildStatusTags.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/buildStatusTags.test.ts deleted file mode 100644 index 5059638a..00000000 --- a/frontend/components/dashboard/scan-terminal/__tests__/buildStatusTags.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import assert from "node:assert/strict"; -import { buildCityDecisionState } from "@/components/dashboard/scan-terminal/city-decision-state"; -import type { MarketDecisionView } from "@/components/dashboard/scan-terminal/city-card-decision-utils"; - -const readyMarket: MarketDecisionView = { - bucketLabel: "--", - confidence: "--", - edgeText: "--", - impliedText: "--", - modelText: "--", - priceText: "--", - reason: "", - status: "ready", - title: "", - tone: "neutral", -}; - -export function runTests() { - const peakPassed = buildCityDecisionState({ - isEn: false, - isHkoObservation: false, - modelHighlyConsistent: true, - needsNextBulletin: false, - observationStale: false, - observedHighBreak: false, - observedLowBreak: false, - observedLowLag: false, - peakHasPassed: true, - }); - - assert.equal(peakPassed.urgency, "past"); - assert.equal(peakPassed.recommendation, "avoid"); - assert.match(peakPassed.primaryReason, /峰值窗口已过/); - assert.doesNotMatch(peakPassed.primaryReason, /值得关注/); - assert.deepEqual( - peakPassed.badges.map((badge) => badge.label), - ["峰值窗口已过", "模型高度一致"], - ); - - const staleMetar = buildCityDecisionState({ - isEn: false, - isHkoObservation: false, - modelHighlyConsistent: false, - needsNextBulletin: false, - observationStale: true, - observedHighBreak: false, - observedLowBreak: false, - observedLowLag: false, - peakHasPassed: false, - }); - - assert.equal(staleMetar.evidenceQuality, "stale"); - assert.equal(staleMetar.recommendation, "background"); - assert.ok(staleMetar.badges.some((badge) => badge.label === "METAR 过旧")); -} diff --git a/frontend/components/dashboard/scan-terminal/__tests__/marketDecisionView.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/marketDecisionView.test.ts deleted file mode 100644 index ade2f53d..00000000 --- a/frontend/components/dashboard/scan-terminal/__tests__/marketDecisionView.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import assert from "node:assert/strict"; -import { - buildMarketDecisionView, - pickMarketBucketForWeatherCenter, -} from "@/components/dashboard/scan-terminal/city-card-decision-utils"; -import type { MarketScan } from "@/lib/dashboard-types"; - -export function runTests() { - const unavailable = buildMarketDecisionView({ - expectedHigh: 24, - isEn: false, - marketScan: { available: false }, - marketStatus: "ready", - tempSymbol: "°C", - }); - - assert.equal(unavailable.status, "unavailable"); - assert.equal(unavailable.title, "市场价格暂不可用"); - assert.match(unavailable.reason, /暂无可交易价格/); - assert.match(unavailable.reason, /天气判断仍可参考/); - assert.doesNotMatch(unavailable.reason, /未接入|系统缺失|系统坏/); - - const mismatchedScan: MarketScan = { - available: true, - all_buckets: [ - { - label: "40°C", - temp: 40, - unit: "C", - model_probability: 0.2, - market_price: 0.3, - yes_buy: 0.31, - }, - ], - market_price: 0.3, - model_probability: 0.55, - yes_buy: 0.31, - }; - const mismatched = buildMarketDecisionView({ - expectedHigh: 24, - isEn: false, - marketScan: mismatchedScan, - marketStatus: "ready", - tempSymbol: "°C", - }); - - assert.equal(pickMarketBucketForWeatherCenter(mismatchedScan, 24, "°C"), null); - assert.equal(mismatched.status, "ready"); - assert.equal(mismatched.title, "市场温度桶需重新匹配"); - assert.equal(mismatched.edgeText, "--"); - assert.match(mismatched.reason, /温度桶与今日预计高点不够匹配/); - - const matched = buildMarketDecisionView({ - expectedHigh: 24.3, - isEn: false, - marketScan: { - available: true, - all_buckets: [ - { - label: "24°C", - temp: 24, - unit: "C", - model_probability: 0.64, - market_price: 0.41, - yes_buy: 0.42, - slug: "tokyo-high-24c", - }, - ], - market_price: 0.41, - model_probability: 0.64, - yes_buy: 0.42, - }, - marketStatus: "ready", - tempSymbol: "°C", - }); - - assert.equal(matched.status, "ready"); - assert.equal(matched.bucketLabel, "24°C"); - assert.equal(matched.priceText, "42¢"); - assert.match(matched.reason, /模型概率 64\.0%/); -} diff --git a/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts index 6e3ac2e4..ab9ca71a 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts @@ -27,10 +27,6 @@ export function runTests() { path.join(projectRoot, "components", "dashboard", "scan-terminal", "LiveTemperatureThresholdChart.tsx"), "utf8", ); - const overviewApiSource = fs.readFileSync( - path.join(projectRoot, "..", "web", "services", "market_overview_api.py"), - "utf8", - ); assert( querySource.includes("DASHBOARD_REFRESH_POLICY_MS.scanRows"), @@ -42,10 +38,4 @@ export function runTests() { !chartSource.includes("window.setInterval"), "selected city detail chart should be on-demand and use model-layer cache instead of 60-second polling", ); - assert( - !overviewApiSource.includes("/chat/completions") && - !overviewApiSource.includes("SCAN_CITY_AI_MODEL") && - !overviewApiSource.includes("_scan_ai_api_key"), - "market overview must be deterministic and must not call AI providers", - ); } diff --git a/frontend/components/dashboard/scan-terminal/__tests__/stableServerRefreshPolicy.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/stableServerRefreshPolicy.test.ts deleted file mode 100644 index e60b00e9..00000000 --- a/frontend/components/dashboard/scan-terminal/__tests__/stableServerRefreshPolicy.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -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 queryPath = path.join( - projectRoot, - "components", - "dashboard", - "scan-terminal", - "use-scan-terminal-query.ts", - ); - const dashboardPath = path.join( - projectRoot, - "components", - "dashboard", - "ScanTerminalDashboard.tsx", - ); - const airportEvidencePath = path.join( - projectRoot, - "components", - "dashboard", - "scan-terminal", - "AirportEvidencePanel.tsx", - ); - - const querySource = fs.readFileSync(queryPath, "utf8"); - const dashboardSource = fs.readFileSync(dashboardPath, "utf8"); - const airportEvidenceSource = fs.readFileSync(airportEvidencePath, "utf8"); - - assert( - querySource.includes("fetchScanTerminal") && - querySource.includes("showLoading: false"), - "web auto refresh must read cached scan data instead of forcing a full server scan", - ); - assert( - dashboardSource.includes("CityRegionList") && - dashboardSource.includes("Panel") && - dashboardSource.includes("decisionLabel"), - "scan terminal must use new institutional terminal layout with CityRegionList + decisionLabel", - ); - assert( - airportEvidenceSource.includes("SETTLEMENT_RUNWAY_PAIRS") && - airportEvidenceSource.includes("chongqing") && - airportEvidenceSource.includes("seoul") && - !airportEvidenceSource.includes("busan:"), - "settlement runway mapping must cover all active settlement cities without mixing in non-settlement airports", - ); -} diff --git a/frontend/components/dashboard/scan-terminal/city-card-decision-utils.ts b/frontend/components/dashboard/scan-terminal/city-card-decision-utils.ts deleted file mode 100644 index dabf8503..00000000 --- a/frontend/components/dashboard/scan-terminal/city-card-decision-utils.ts +++ /dev/null @@ -1,600 +0,0 @@ -import type { MarketScan, MarketTopBucket } from "@/lib/dashboard-types"; -import { getTodayPaceView } from "@/lib/pace-utils"; -import { - formatTemperatureValue, - normalizeTemperatureLabel, -} from "@/lib/temperature-utils"; - -export type WeatherDecisionView = { - action: string; - confidence: string; - expectedHigh: string; - kicker: string; - reasons: string[]; - risk: string; - targetRange: string; - tone: "cold" | "neutral" | "warm" | "watch"; -}; - -export type MarketDecisionView = { - bucketLabel: string; - confidence: string; - edgeText: string; - impliedText: string; - marketUrl?: string | null; - modelText: string; - priceText: string; - reason: string; - status: "loading" | "ready" | "unavailable"; - title: string; - tone: "cold" | "neutral" | "warm" | "watch"; -}; - -export function normalizeMarketProbability(value: unknown) { - const numeric = Number(value); - if (!Number.isFinite(numeric)) return null; - if (numeric > 1) return numeric / 100; - if (numeric < 0) return null; - return numeric; -} - -function normalizeQuotePrice(value: unknown) { - const normalized = normalizeMarketProbability(value); - if (normalized == null || normalized <= 0) return null; - return normalized; -} - -function toFiniteMarketNumber(value: unknown) { - if (value == null || value === "") return null; - const numeric = Number(value); - return Number.isFinite(numeric) ? numeric : null; -} - -function hasReasonableTemperatureValue(value: number | null): value is number { - return value != null && Number.isFinite(value) && value > -130 && value < 150; -} - -function isPlausibleExpectedHigh( - value: number | null, - references: Array, -) { - if (!hasReasonableTemperatureValue(value)) return false; - const usableReferences = references.filter(hasReasonableTemperatureValue); - if (!usableReferences.length) return true; - const referenceMin = Math.min(...usableReferences); - const referenceMax = Math.max(...usableReferences); - return value >= referenceMin - 6 && value <= referenceMax + 6; -} - -export function resolveExpectedHighCandidate({ - aiPredictedMax, - currentTemp, - deb, - modelMax, - modelMin, - paceAdjustedHigh, -}: { - aiPredictedMax?: unknown; - currentTemp?: number | null; - deb?: number | null; - modelMax?: number | null; - modelMin?: number | null; - paceAdjustedHigh?: number | null; -}) { - const ai = toFiniteMarketNumber(aiPredictedMax); - const modelCenter = - modelMin != null && modelMax != null - ? (modelMin + modelMax) / 2 - : null; - const references = [deb ?? null, modelMin ?? null, modelMax ?? null, currentTemp ?? null]; - const candidates = [ai, deb ?? null, paceAdjustedHigh ?? null, modelCenter, currentTemp ?? null]; - for (const candidate of candidates) { - if (isPlausibleExpectedHigh(candidate, references)) { - return candidate; - } - } - return null; -} - -export function formatMarketPercent(value: number | null, digits = 1) { - if (value == null || !Number.isFinite(value)) return "--"; - return `${(value * 100).toFixed(digits)}%`; -} - -export function formatMarketCents(value: number | null) { - if (value == null || !Number.isFinite(value)) return "--"; - if (value > 0 && value < 0.01) return "<1¢"; - return `${Math.round(value * 100)}¢`; -} - -export function formatSignedMarketPercent(value: number | null) { - if (value == null || !Number.isFinite(value)) return "--"; - const sign = value > 0 ? "+" : ""; - return `${sign}${(value * 100).toFixed(1)}%`; -} - -export function normalizeMarketComparableTemp( - displayTemp: number | null, - tempSymbol: string, - bucket?: MarketTopBucket | null, -) { - if (displayTemp == null || !Number.isFinite(displayTemp)) return null; - const bucketUnit = String(bucket?.unit || "").trim().toUpperCase(); - const isDisplayF = String(tempSymbol || "").toUpperCase().includes("F"); - if (bucketUnit === "F" && !isDisplayF) return displayTemp * 1.8 + 32; - if (bucketUnit === "C" && isDisplayF) return (displayTemp - 32) / 1.8; - return displayTemp; -} - -export function getMarketBucketLabel(bucket?: MarketTopBucket | null, tempSymbol = "°C") { - const direct = String(bucket?.label || "").trim(); - if (direct && /[°]?[CF]\b|\d+\s*[+-]?$/i.test(direct) && !/[�。紊]/.test(direct)) { - const labelSymbol = /°?\s*F\b/i.test(direct) - ? "°F" - : /°?\s*C\b/i.test(direct) - ? "°C" - : tempSymbol; - return normalizeTemperatureLabel(direct, labelSymbol).replace(/\s+°([CF])\b/g, "°$1"); - } - const numeric = toFiniteMarketNumber(bucket?.temp ?? bucket?.value ?? bucket?.lower); - if (numeric != null) { - const unit = bucket?.unit - ? `°${String(bucket.unit).replace(/^°/, "").toUpperCase()}` - : tempSymbol; - return `${numeric.toFixed(0)}${unit}`; - } - return "--"; -} - -function getBucketAnchor(bucket: MarketTopBucket) { - return toFiniteMarketNumber(bucket.temp ?? bucket.value ?? bucket.lower); -} - -function marketBucketLabelText(bucket?: MarketTopBucket | null) { - return String(`${bucket?.label || ""} ${bucket?.slug || ""} ${bucket?.question || ""}`) - .trim() - .toLowerCase(); -} - -function isMarketBucketAbove(bucket?: MarketTopBucket | null) { - return /\b(or[-\s]?higher|or[-\s]?above|above|higher|at least|greater than)\b/i.test( - marketBucketLabelText(bucket), - ); -} - -function isMarketBucketBelow(bucket?: MarketTopBucket | null) { - return /\b(or[-\s]?lower|or[-\s]?below|below|lower|at most|less than)\b/i.test( - marketBucketLabelText(bucket), - ); -} - -function getRoundedWeatherBucketValue( - expectedHigh: number | null, - tempSymbol: string, - bucket: MarketTopBucket, -) { - const comparable = normalizeMarketComparableTemp(expectedHigh, tempSymbol, bucket); - if (comparable == null || !Number.isFinite(comparable)) return null; - return Math.round(comparable); -} - -function getBucketModelProbability(bucket?: MarketTopBucket | null) { - const model = normalizeMarketProbability(bucket?.model_probability); - const probability = normalizeMarketProbability(bucket?.probability); - const market = normalizeMarketProbability(bucket?.market_price); - // Some persisted market_scan payloads from older builds overwrote bucket - // probability with the market price. Treat an exact price clone as missing - // model probability so the caller can fall back to scan.model_probability. - if ( - model != null && - market != null && - Math.abs(model - market) <= 0.000_001 - ) { - return null; - } - if ( - probability != null && - market != null && - Math.abs(probability - market) <= 0.000_001 - ) { - return null; - } - return model ?? probability; -} - -function getBucketDisplayUnit(bucket: MarketTopBucket, tempSymbol: string) { - return bucket.unit - ? `°${String(bucket.unit).replace(/^°/, "").toUpperCase()}` - : tempSymbol; -} - -function buildBucketMappingExplanation({ - bucket, - expectedHigh, - isEn, - tempSymbol, -}: { - bucket: MarketTopBucket; - expectedHigh: number | null; - isEn: boolean; - tempSymbol: string; -}) { - const comparable = normalizeMarketComparableTemp(expectedHigh, tempSymbol, bucket); - const rounded = getRoundedWeatherBucketValue(expectedHigh, tempSymbol, bucket); - if (comparable == null || rounded == null) return ""; - const unit = getBucketDisplayUnit(bucket, tempSymbol); - const bucketLabel = getMarketBucketLabel(bucket, tempSymbol); - const expectedText = formatTemperatureValue(comparable, unit, { digits: 1 }); - const hasRounding = Math.abs(comparable - rounded) >= 0.05; - if (isEn) { - const mapping = hasRounding - ? `Expected high ${expectedText} maps to the ${bucketLabel} settlement bucket after rounding.` - : `Expected high ${expectedText} maps to the ${bucketLabel} bucket.`; - return `${mapping} Model probability is still the bucket-distribution probability, not 100% just because the center maps there.`; - } - const mapping = hasRounding - ? `预计高点 ${expectedText} 按结算四舍五入映射到 ${bucketLabel} 桶。` - : `预计高点 ${expectedText} 对应 ${bucketLabel} 桶。`; - return `${mapping} 模型概率仍按温度分布计算,不等于把该桶视为 100%。`; -} - -function getMarketSelectedBucket(scan: MarketScan | null | undefined): MarketTopBucket | null { - const selected = scan?.temperature_bucket; - if (!selected) return null; - const value = Number(selected.value); - return { - label: selected.label || selected.bucket || selected.range || null, - value: Number.isFinite(value) ? value : null, - temp: Number.isFinite(value) ? value : null, - unit: selected.unit || null, - probability: selected.probability ?? scan?.model_probability ?? null, - model_probability: selected.probability ?? scan?.model_probability ?? null, - market_price: scan?.market_price ?? null, - yes_buy: scan?.yes_buy ?? null, - yes_sell: scan?.yes_sell ?? null, - slug: scan?.selected_slug ?? scan?.primary_market?.slug ?? null, - }; -} - -export function pickMarketBucketForWeatherCenter( - scan: MarketScan | null | undefined, - expectedHigh: number | null, - tempSymbol: string, -) { - const buckets = ( - Array.isArray(scan?.all_buckets) - ? scan?.all_buckets - : Array.isArray(scan?.top_buckets) - ? scan?.top_buckets - : [] - ) as MarketTopBucket[]; - const selectedBucket = getMarketSelectedBucket(scan); - const isReasonableFallback = (bucket: MarketTopBucket | null) => { - if (!bucket) return false; - const comparable = normalizeMarketComparableTemp(expectedHigh, tempSymbol, bucket); - const anchor = getBucketAnchor(bucket); - if (comparable == null || anchor == null) return false; - const roundedTarget = Math.round(comparable); - const roundedAnchor = Math.round(anchor); - const lower = bucket.lower != null ? Number(bucket.lower) : anchor; - const upper = bucket.upper != null ? Number(bucket.upper) : null; - if (upper != null && Number.isFinite(lower) && Number.isFinite(upper)) { - return roundedTarget >= lower - 0.01 && roundedTarget <= upper + 0.01; - } - if (isMarketBucketAbove(bucket)) return roundedTarget >= roundedAnchor; - if (isMarketBucketBelow(bucket)) return roundedTarget <= roundedAnchor; - return roundedAnchor === roundedTarget; - }; - if (!buckets.length || expectedHigh == null || !Number.isFinite(expectedHigh)) { - return isReasonableFallback(selectedBucket) ? selectedBucket : null; - } - - let roundedMatch: MarketTopBucket | null = null; - let nearest: MarketTopBucket | null = null; - let nearestDelta = Number.POSITIVE_INFINITY; - for (const bucket of buckets) { - const comparable = normalizeMarketComparableTemp(expectedHigh, tempSymbol, bucket); - if (comparable == null) continue; - const roundedTarget = getRoundedWeatherBucketValue(expectedHigh, tempSymbol, bucket); - const lower = bucket.lower != null ? Number(bucket.lower) : null; - const upper = bucket.upper != null ? Number(bucket.upper) : null; - const anchor = getBucketAnchor(bucket); - if (anchor != null && roundedTarget != null && Math.round(anchor) === roundedTarget) { - roundedMatch = bucket; - break; - } - if ( - roundedMatch == null && - lower != null && - upper != null && - Number.isFinite(lower) && - Number.isFinite(upper) && - roundedTarget != null && - roundedTarget >= lower - 0.01 && - roundedTarget <= upper + 0.01 - ) { - roundedMatch = bucket; - break; - } - if (anchor == null) continue; - const delta = Math.abs(anchor - comparable); - if (delta < nearestDelta) { - nearest = bucket; - nearestDelta = delta; - } - } - if (roundedMatch) return roundedMatch; - if (!nearest) return isReasonableFallback(selectedBucket) ? selectedBucket : null; - if (Number.isFinite(nearestDelta) && isReasonableFallback(nearest)) { - return nearest; - } - return isReasonableFallback(selectedBucket) ? selectedBucket : null; -} - -export function buildMarketDecisionView({ - expectedHigh, - isEn, - marketScan, - marketStatus, - tempSymbol, -}: { - expectedHigh: number | null; - isEn: boolean; - marketScan: MarketScan | null; - marketStatus: "idle" | "loading" | "ready" | "failed"; - tempSymbol: string; -}): MarketDecisionView { - if (marketStatus === "loading") { - return { - bucketLabel: "--", - confidence: "--", - edgeText: "--", - impliedText: "--", - modelText: "--", - priceText: "--", - reason: isEn - ? "Fetching the existing Polymarket quote layer for this city." - : "正在读取项目内已有的 Polymarket 价格层。", - status: "loading", - title: isEn ? "Syncing market price" : "正在同步市场价格", - tone: "watch", - }; - } - if (!marketScan?.available) { - return { - bucketLabel: "--", - confidence: "--", - edgeText: "--", - impliedText: "--", - modelText: "--", - priceText: "--", - reason: isEn - ? "No tradable quote is available yet; weather evidence is still shown." - : "暂无可交易价格,仅展示天气证据。天气判断仍可参考。", - status: "unavailable", - title: isEn ? "Market price temporarily unavailable" : "市场价格暂不可用", - tone: "watch", - }; - } - - const bucket = pickMarketBucketForWeatherCenter(marketScan, expectedHigh, tempSymbol); - if (!bucket) { - return { - bucketLabel: "--", - confidence: marketScan.confidence || "--", - edgeText: "--", - impliedText: formatMarketPercent( - normalizeMarketProbability(marketScan.market_price) ?? - normalizeMarketProbability(marketScan.midpoint) ?? - normalizeMarketProbability(marketScan.yes_midpoint), - ), - marketUrl: marketScan.market_url || marketScan.primary_market_url || null, - modelText: formatMarketPercent(normalizeMarketProbability(marketScan.model_probability)), - priceText: formatMarketCents(normalizeQuotePrice(marketScan.yes_buy)), - reason: isEn - ? "A market was found, but its temperature bucket does not match today’s expected high closely enough, so edge is withheld." - : "已找到市场,但温度桶与今日预计高点不够匹配,暂不计算概率差。", - status: "ready", - title: isEn ? "Market bucket needs rematch" : "市场温度桶需重新匹配", - tone: "watch", - }; - } - const bucketLabel = getMarketBucketLabel(bucket, tempSymbol); - const bucketMappingExplanation = buildBucketMappingExplanation({ - bucket, - expectedHigh, - isEn, - tempSymbol, - }); - const bucketProbability = getBucketModelProbability(bucket); - const scanProbability = normalizeMarketProbability(marketScan.model_probability); - const modelProbability = bucketProbability ?? scanProbability; - const yesBuy = - normalizeQuotePrice(bucket?.yes_buy) ?? - normalizeQuotePrice(marketScan.yes_buy); - const yesSell = - normalizeQuotePrice(bucket?.yes_sell) ?? - normalizeQuotePrice(marketScan.yes_sell); - const marketMid = - normalizeMarketProbability(bucket?.market_price) ?? - normalizeMarketProbability(marketScan.market_price) ?? - normalizeMarketProbability(marketScan.midpoint) ?? - normalizeMarketProbability(marketScan.yes_midpoint); - const implied = marketMid ?? yesBuy ?? yesSell ?? null; - const edge = - modelProbability != null && implied != null ? modelProbability - implied : null; - const tone = - edge == null - ? "neutral" - : edge >= 0.08 - ? "warm" - : edge <= -0.08 - ? "cold" - : "neutral"; - const title = - edge == null - ? isEn - ? "Market quote matched" - : "已匹配市场报价" - : edge >= 0.08 - ? isEn - ? "Weather probability above market" - : "天气概率高于市场报价" - : edge <= -0.08 - ? isEn - ? "Market already prices this in" - : "市场价格已偏充分" - : isEn - ? "Price near weather probability" - : "价格接近天气概率"; - - return { - bucketLabel, - confidence: marketScan.confidence || "--", - edgeText: formatSignedMarketPercent(edge), - impliedText: formatMarketPercent(implied), - marketUrl: - bucket?.market_url || - (bucket?.slug - ? `https://polymarket.com/market/${bucket.slug}` - : marketScan.market_url || marketScan.primary_market_url || null), - modelText: formatMarketPercent(modelProbability), - priceText: formatMarketCents(yesBuy), - reason: - edge == null - ? isEn - ? "Quote is available, but model probability or YES price is incomplete." - : "已获取报价,但模型概率或 YES 价格不完整。" - : isEn - ? `Model probability is ${formatMarketPercent(modelProbability)} versus signal-implied ${formatMarketPercent(implied)}.${bucketMappingExplanation ? ` ${bucketMappingExplanation}` : ""}` - : `模型概率 ${formatMarketPercent(modelProbability)},信号隐含约 ${formatMarketPercent(implied)}。${bucketMappingExplanation ? ` ${bucketMappingExplanation}` : ""}`, - status: "ready", - title, - tone, - }; -} - -export function buildWeatherDecisionView({ - currentTemp, - deb, - isEn, - localModelSupportNote, - modelEntries, - modelMax, - modelMin, - paceTone, - paceView, - peakWindow, - tempSymbol, -}: { - currentTemp: number | null; - deb: number | null; - isEn: boolean; - localModelSupportNote: string; - modelEntries: Array; - modelMax: number | null; - modelMin: number | null; - paceTone: string; - paceView: ReturnType | null; - peakWindow: string; - tempSymbol: string; -}): WeatherDecisionView { - const center = resolveExpectedHighCandidate({ - currentTemp, - deb, - modelMax, - modelMin, - paceAdjustedHigh: paceView?.paceAdjustedHigh ?? null, - }); - const low = modelMin != null - ? modelMin - : center != null - ? center - 1 - : null; - const high = modelMax != null - ? modelMax - : center != null - ? center + 1 - : null; - const spread = modelMax != null && modelMin != null ? modelMax - modelMin : null; - const modelCount = modelEntries.length; - const confidence = - modelCount >= 4 && spread != null && spread <= 2 - ? isEn - ? "High" - : "高" - : modelCount >= 2 - ? isEn - ? "Medium" - : "中" - : isEn - ? "Low" - : "低"; - const tone = - modelCount <= 1 - ? "watch" - : paceTone === "warm" || paceTone === "cold" || paceTone === "neutral" - ? paceTone - : "neutral"; - const action = - modelCount <= 1 - ? isEn - ? "Wait for model cluster" - : "等待模型补齐" - : paceTone === "warm" - ? isEn - ? "Revise upward" - : "预计最高温上修" - : paceTone === "cold" - ? isEn - ? "Revise downward" - : "预计最高温下修" - : isEn - ? "Stay with model base" - : "维持模型基准"; - const expectedHigh = - center != null && Number.isFinite(Number(center)) - ? formatTemperatureValue(Number(center), tempSymbol, { digits: 1 }) - : "--"; - const targetRange = - low != null && high != null && Number.isFinite(Number(low)) && Number.isFinite(Number(high)) - ? `${formatTemperatureValue(Number(low), tempSymbol, { digits: 1 })} ~ ${formatTemperatureValue(Number(high), tempSymbol, { digits: 1 })}` - : expectedHigh; - const reasons = [ - localModelSupportNote, - paceView?.summary || "", - currentTemp != null - ? isEn - ? `Latest observed anchor is ${formatTemperatureValue(currentTemp, tempSymbol, { digits: 1 })}.` - : `最新实测锚点为 ${formatTemperatureValue(currentTemp, tempSymbol, { digits: 1 })}。` - : "", - ] - .filter(Boolean) - .slice(0, 3); - const risk = - paceTone === "warm" - ? isEn - ? "Risk trigger: if later METAR cools back toward the curve before the peak window, downgrade the hotter read." - : "风险触发:如果后续 METAR 在峰值窗口前回落到曲线附近,需要下调偏高温判断。" - : paceTone === "cold" - ? isEn - ? "Risk trigger: only restore higher buckets if observations recover before the peak window." - : "风险触发:只有实测在峰值窗口前修复,才重新考虑更高温区间。" - : isEn - ? "Risk trigger: a clear METAR/path break before the peak window should decide direction." - : "风险触发:峰值窗口前若 METAR 或路径明显偏离,再决定方向。"; - - return { - action, - confidence, - expectedHigh, - kicker: isEn - ? "Weather-first read · market price shown separately" - : "天气优先判断 · 市场价格另列", - reasons, - risk, - targetRange, - tone, - }; -} - diff --git a/frontend/components/dashboard/scan-terminal/city-decision-state.ts b/frontend/components/dashboard/scan-terminal/city-decision-state.ts deleted file mode 100644 index d4edbc68..00000000 --- a/frontend/components/dashboard/scan-terminal/city-decision-state.ts +++ /dev/null @@ -1,151 +0,0 @@ -export type CityDecisionUrgency = "now" | "soon" | "later" | "past"; -export type CityDecisionRecommendation = "watch" | "wait" | "avoid" | "background"; -export type CityDecisionEvidenceQuality = "fresh" | "mixed" | "stale"; -export type CityDecisionAiStatus = - | "fast-ready" - | "ready"; -export type StatusBadgeTone = "green" | "blue" | "amber" | "red" | "muted"; - -export type StatusBadge = { - label: string; - tone: StatusBadgeTone; -}; - -export type CityDecisionState = { - urgency: CityDecisionUrgency; - recommendation: CityDecisionRecommendation; - evidenceQuality: CityDecisionEvidenceQuality; - aiStatus: CityDecisionAiStatus; - aiStatusLabel: string; - aiStatusTone: StatusBadgeTone; - badges: StatusBadge[]; - primaryReason: string; -}; - -function uniqueStatusBadges(badges: Array) { - const seen = new Set(); - return badges.filter((badge): badge is StatusBadge => { - if (!badge?.label || seen.has(badge.label)) return false; - seen.add(badge.label); - return true; - }); -} - -export function buildCityDecisionState({ - isEn, - isHkoObservation, - modelHighlyConsistent, - needsNextBulletin, - observationStale, - observedHighBreak, - observedLowBreak, - observedLowLag, - peakHasPassed, -}: { - isEn: boolean; - isHkoObservation: boolean; - modelHighlyConsistent: boolean; - needsNextBulletin: boolean; - observationStale: boolean; - observedHighBreak: boolean; - observedLowBreak: boolean; - observedLowLag: boolean; - peakHasPassed: boolean; -}): CityDecisionState { - const evidenceQuality: CityDecisionEvidenceQuality = observationStale - ? "stale" - : "fresh"; - const urgency: CityDecisionUrgency = peakHasPassed - ? "past" - : observedHighBreak - ? "now" - : needsNextBulletin - ? "soon" - : "later"; - const recommendation: CityDecisionRecommendation = peakHasPassed - ? "avoid" - : observationStale - ? "background" - : needsNextBulletin - ? "wait" - : "watch"; - const primaryReason = observedHighBreak - ? isEn - ? "Observation has broken above the model range. Consider the upside scenario; wait for the next bulletin to confirm the breakout." - : "实测已突破模型上沿。建议关注偏高温区间,等待下一报文确认突破是否持续。" - : peakHasPassed - ? isEn - ? "Peak window has passed; confirm whether a new high can still form. Avoid chasing if no new high prints." - : "峰值窗口已过,确认是否还会出现新高。若无新高,建议避免追高。" - : observationStale - ? isEn - ? "Observation is stale and needs the next report. Use only as background reference until fresh data arrives." - : "观测已过旧,需要下一报文确认。当前数据仅作背景参考,建议等待新报文后再做判断。" - : modelHighlyConsistent - ? isEn - ? "Models are aligned; wait for observation confirmation. A clear direction should emerge after the next report." - : "模型高度一致,等待实测确认。下一报文后方向会更明确。" - : needsNextBulletin - ? isEn - ? "The next bulletin is more likely to decide direction. Hold until the picture clears." - : "下一报文更可能决定方向。建议等待信号明确后再做决策。" - : isEn - ? "Compare new observations with the expected high through the peak window." - : "在峰值窗口内继续对照实测与预计高点。"; - - const badges = uniqueStatusBadges([ - observedHighBreak - ? { - label: isEn ? "Observed breakout" : "实测突破", - tone: "red", - } - : null, - peakHasPassed - ? { - label: isEn ? "Peak window passed" : "峰值窗口已过", - tone: "muted", - } - : null, - observationStale - ? { - label: isEn - ? isHkoObservation - ? "HKO stale" - : "METAR stale" - : isHkoObservation - ? "观测过旧" - : "METAR 过旧", - tone: "amber", - } - : null, - observedLowBreak - ? { - label: isEn ? "Peak revised down" : "峰值下修", - tone: "blue", - } - : null, - modelHighlyConsistent - ? { - label: isEn ? "Models agree" : "模型高度一致", - tone: "green", - } - : null, - observedLowLag || needsNextBulletin - ? { - label: isEn ? "Wait next report" : "需要等待下一报文", - tone: "amber", - } - : null, - ]).slice(0, 3); - - return { - urgency, - recommendation, - evidenceQuality, - aiStatus: "ready", - aiStatusLabel: isEn ? "AI ready" : "AI 就绪", - aiStatusTone: "blue", - badges, - primaryReason, - }; -} diff --git a/frontend/components/dashboard/scan-terminal/city-detail-utils.ts b/frontend/components/dashboard/scan-terminal/city-detail-utils.ts deleted file mode 100644 index a9cee629..00000000 --- a/frontend/components/dashboard/scan-terminal/city-detail-utils.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { CityDetail } from "@/lib/dashboard-types"; -import { normalizeCityKey } from "@/components/dashboard/scan-terminal/decision-utils"; - -export function findDetailForCity( - detailsByName: Record, - cityName?: string | null, -) { - const target = normalizeCityKey(cityName); - if (!target) return null; - return ( - Object.values(detailsByName).find((detail) => - [detail?.name, detail?.display_name].some( - (value) => normalizeCityKey(value) === target, - ), - ) || null - ); -} - -export function countDetailModels(detail?: CityDetail | null, targetDate?: string | null) { - if (!detail) return 0; - const date = String(targetDate || detail.local_date || "").trim(); - const dailyModels = date ? detail.multi_model_daily?.[date]?.models : null; - const models = - dailyModels && typeof dailyModels === "object" - ? dailyModels - : detail.multi_model || {}; - return Object.values(models).filter((value) => - Number.isFinite(Number(value)), - ).length; -} - -export function countDetailForecastDays(detail?: CityDetail | null) { - const daily = detail?.forecast?.daily; - return Array.isArray(daily) ? daily.length : 0; -} - -export function isFullEnoughForDeepAnalysis(detail?: CityDetail | null) { - if (!detail) return false; - if (detail.detail_depth && detail.detail_depth !== "full") return false; - const hourlyTimes = Array.isArray(detail.hourly?.times) - ? detail.hourly?.times || [] - : []; - const hourlyTemps = Array.isArray(detail.hourly?.temps) - ? detail.hourly?.temps || [] - : []; - if (!detail.local_time || hourlyTimes.length === 0 || hourlyTemps.length === 0) { - return false; - } - return ( - countDetailModels(detail, detail.local_date) >= 1 && - countDetailForecastDays(detail) >= 1 - ); -} - diff --git a/frontend/components/dashboard/scan-terminal/decision-copy.ts b/frontend/components/dashboard/scan-terminal/decision-copy.ts deleted file mode 100644 index 3645082b..00000000 --- a/frontend/components/dashboard/scan-terminal/decision-copy.ts +++ /dev/null @@ -1,67 +0,0 @@ -export type DecisionCopyLocale = "zh-CN" | "en-US"; - -function isEnglishLocale(localeOrIsEn: DecisionCopyLocale | string | boolean) { - return localeOrIsEn === true || localeOrIsEn === "en-US"; -} - -export function getAiReadCopy({ - isEn, - isHkoObservation, -}: { - isEn: boolean; - isHkoObservation: boolean; -}) { - return { - complete: isEn - ? isHkoObservation - ? "AI HKO observation read is complete." - : "AI airport bulletin read is complete." - : isHkoObservation - ? "AI 香港天文台观测解读已完成" - : "AI 机场报文解读已完成", - inProgress: isEn - ? isHkoObservation - ? "Fast read is ready; AI is predicting today's high from the HKO observation..." - : "Fast read is ready; AI is predicting today's high from the airport bulletin..." - : isHkoObservation - ? "快速判断已完成,AI 正在基于最新观测预测今日最高温…" - : "快速判断已完成,AI 正在基于最新报文预测今日最高温…", - ruleEvidence: isEn - ? "AI read did not return completely; rule evidence is being used." - : "AI 解读未完整返回,当前使用规则证据", - }; -} - -export function getCityLoadingCopy({ - isEn, - isHkoObservation, -}: { - isEn: boolean; - isHkoObservation: boolean; -}) { - return { - description: isEn - ? isHkoObservation - ? "Hydrating today’s model stack, HKO observation context and market layer." - : "Hydrating today’s model stack, METAR context and market layer." - : isHkoObservation - ? "正在补全今日模型、香港天文台观测和市场价格层。" - : "正在补全今日模型、机场报文和市场价格层。", - title: isEn ? "Loading city decision data" : "正在加载城市决策数据", - }; -} - -export function getMobileDecisionCopy(localeOrIsEn: DecisionCopyLocale | string | boolean) { - const isEn = isEnglishLocale(localeOrIsEn); - return { - aiDetails: isEn ? "AI read" : "AI 解读", - chart: isEn ? "Light trend chart" : "轻量走势图", - currentTemp: isEn ? "Observed" : "当前温度", - expectedHigh: isEn ? "Expected high" : "预测高点", - marketPrice: isEn ? "Market price" : "市场价格", - modelEvidence: isEn ? "Model evidence" : "模型证据", - peakWindow: isEn ? "Peak window" : "峰值窗口", - refresh: isEn ? "Refresh" : "刷新", - remove: isEn ? "Remove" : "移除", - }; -} diff --git a/frontend/components/dashboard/scan-terminal/scan-terminal-cache.ts b/frontend/components/dashboard/scan-terminal/scan-terminal-cache.ts deleted file mode 100644 index 17c2bda8..00000000 --- a/frontend/components/dashboard/scan-terminal/scan-terminal-cache.ts +++ /dev/null @@ -1,57 +0,0 @@ -"use client"; - -function getStorage() { - if (typeof window === "undefined") return null; - try { - return window.localStorage; - } catch { - return null; - } -} - -export function buildStorageKey( - prefix: string, - parts: Array, -) { - return `${prefix}:${parts - .map((part) => encodeURIComponent(String(part || "").trim())) - .join(":")}`; -} - -export function readCachedPayload(key: string, ttlMs: number): T | null { - const storage = getStorage(); - if (!storage) return null; - try { - const raw = storage.getItem(key); - if (!raw) return null; - const parsed = JSON.parse(raw) as { cachedAt?: number; payload?: T }; - if (!parsed?.payload) return null; - if (Date.now() - Number(parsed.cachedAt || 0) > ttlMs) { - storage.removeItem(key); - return null; - } - return parsed.payload; - } catch { - return null; - } -} - -export function writeCachedPayload(key: string, payload: T) { - const storage = getStorage(); - if (!storage) return; - try { - storage.setItem(key, JSON.stringify({ cachedAt: Date.now(), payload })); - } catch { - // Ignore quota/privacy-mode failures; network fallbacks still work. - } -} - -export function removeCachedPayload(key: string) { - const storage = getStorage(); - if (!storage) return; - try { - storage.removeItem(key); - } catch { - // Ignore privacy-mode failures; the next network request can still proceed. - } -} diff --git a/tests/test_refresh_policy.py b/tests/test_refresh_policy.py index 8d481581..0fa6c3bd 100644 --- a/tests/test_refresh_policy.py +++ b/tests/test_refresh_policy.py @@ -18,14 +18,12 @@ def test_refresh_policy_cadences_are_layered(): def test_backend_defaults_use_refresh_policy(): import src.data_collection.weather_sources as weather_sources import web.services.city_runtime as city_runtime - import web.services.market_overview_api as market_overview_api import web.services.scan_ai_config as scan_ai_config assert scan_ai_config.SCAN_TERMINAL_PAYLOAD_TTL_SEC == SCAN_ROWS_REFRESH_SEC assert city_runtime.CITY_FULL_CACHE_TTL_SEC == OBSERVATION_REFRESH_SEC assert city_runtime.CITY_PANEL_CACHE_TTL_SEC == SCAN_ROWS_REFRESH_SEC assert city_runtime.CITY_MARKET_CACHE_TTL_SEC == SCAN_ROWS_REFRESH_SEC - assert market_overview_api.OVERVIEW_CACHE_TTL_SEC == MARKET_OVERVIEW_TTL_SEC source = weather_sources.WeatherDataCollector({}) assert source.metar_cache_ttl_sec == METAR_POLL_TTL_SEC diff --git a/web/services/anomaly_detection.py b/web/services/anomaly_detection.py deleted file mode 100644 index c96bd773..00000000 --- a/web/services/anomaly_detection.py +++ /dev/null @@ -1,102 +0,0 @@ -"""Anomaly detection — pure math, no AI call. - -Flags cities where current observations deviate from model predictions. -""" - -from __future__ import annotations - -from typing import Any, Dict, List, Optional - -from web.scan_city_ai_helpers import _safe_float - - -def _check_city_anomaly( - data: Dict[str, Any], - *, - high_temp_threshold: float = 2.0, -) -> Optional[Dict[str, Any]]: - """Return anomaly flag if current observation breaks model cluster bounds.""" - current = data.get("current") if isinstance(data.get("current"), dict) else {} - airport = data.get("airport_current") if isinstance(data.get("airport_current"), dict) else {} - multi = data.get("multi_model") if isinstance(data.get("multi_model"), dict) else {} - deb = data.get("deb") if isinstance(data.get("deb"), dict) else {} - - observed = _safe_float(current.get("temp") or airport.get("temp")) - if observed is None: - return None - - model_highs = [ - _safe_float(v) - for v in multi.values() - if _safe_float(v) is not None - ] - deb_pred = _safe_float(deb.get("prediction")) - if deb_pred is not None: - model_highs.append(deb_pred) - - if not model_highs: - return None - - model_max = max(model_highs) - model_min = min(model_highs) - model_median = sorted(model_highs)[len(model_highs) // 2] - - delta_above_max = observed - model_max - delta_below_min = model_min - observed - delta_from_median = observed - model_median - - anomaly: Optional[Dict[str, Any]] = None - - if delta_above_max > high_temp_threshold: - anomaly = { - "level": "breakout_above", - "observed": observed, - "model_max": model_max, - "delta": round(delta_above_max, 1), - "model_count": len(model_highs), - } - elif delta_below_min > high_temp_threshold: - anomaly = { - "level": "breakout_below", - "observed": observed, - "model_min": model_min, - "delta": round(delta_below_min, 1), - "model_count": len(model_highs), - } - elif abs(delta_from_median) > 1.5: - anomaly = { - "level": "deviation", - "observed": observed, - "model_median": model_median, - "delta": round(delta_from_median, 1), - "model_count": len(model_highs), - } - - if anomaly: - anomaly.update( - { - "city": data.get("name") or data.get("city"), - "local_date": data.get("local_date"), - "temp_unit": data.get("temp_symbol", "°C"), - "deb_prediction": deb_pred, - } - ) - return anomaly - - -def detect_scan_terminal_anomalies( - rows: List[Dict[str, Any]], - *, - high_temp_threshold: float = 2.0, -) -> List[Dict[str, Any]]: - """Scan all terminal rows and return anomaly flags.""" - anomalies = [] - for row in rows: - if not isinstance(row, dict): - continue - city_data = row.get("city_data") or row - flag = _check_city_anomaly(city_data, high_temp_threshold=high_temp_threshold) - if flag: - flag["row_id"] = row.get("row_id") or row.get("id") - anomalies.append(flag) - return anomalies diff --git a/web/services/market_overview_api.py b/web/services/market_overview_api.py deleted file mode 100644 index 2d07d713..00000000 --- a/web/services/market_overview_api.py +++ /dev/null @@ -1,169 +0,0 @@ -"""Deterministic market overview for scan terminal rows, cached 10 minutes.""" - -from __future__ import annotations - -import hashlib -import json -import threading -import time -from datetime import datetime -from typing import Any, Dict, List, Optional - -from src.utils.refresh_policy import MARKET_OVERVIEW_TTL_SEC - -_OVERVIEW_CACHE: Dict[str, Dict[str, Any]] = {} -_OVERVIEW_CACHE_LOCK = threading.Lock() -OVERVIEW_CACHE_TTL_SEC = MARKET_OVERVIEW_TTL_SEC - - -def _safe_float(value: Any) -> Optional[float]: - try: - if value is None or value == "": - return None - number = float(value) - except Exception: - return None - return number if number == number else None - - -def _row_city(row: Dict[str, Any]) -> str: - return str(row.get("display_name") or row.get("city") or row.get("name") or "").strip() - - -def _row_edge(row: Dict[str, Any]) -> Optional[float]: - return ( - _safe_float(row.get("edge_percent")) - or _safe_float(row.get("edge_pct")) - or _safe_float(row.get("edge")) - ) - - -def _row_score(row: Dict[str, Any]) -> float: - edge = _row_edge(row) or 0.0 - final_score = _safe_float(row.get("final_score")) or 0.0 - liquidity = _safe_float(row.get("liquidity")) or _safe_float(row.get("liquidity_num")) or 0.0 - return edge * 10.0 + final_score + min(liquidity / 1000.0, 25.0) - - -def _row_liquidity(row: Dict[str, Any]) -> float: - return _safe_float(row.get("liquidity")) or _safe_float(row.get("liquidity_num")) or 0.0 - - -def _row_prob_gap(row: Dict[str, Any]) -> Optional[float]: - model_prob = _safe_float(row.get("model_probability")) - market_prob = _safe_float(row.get("market_probability")) - if model_prob is None or market_prob is None: - return None - return model_prob - market_prob - - -def _cache_key(rows: List[Dict[str, Any]], locale: str) -> str: - finger = { - "locale": locale, - "rows": [ - { - "city": row.get("city") or row.get("name") or "", - "edge": _row_edge(row), - "score": _safe_float(row.get("final_score")), - "liquidity": _row_liquidity(row), - "status": row.get("status") or row.get("signal_status") or "", - } - for row in rows - if isinstance(row, dict) - ], - } - raw = json.dumps(finger, sort_keys=True, ensure_ascii=False, default=str) - return "overview:" + hashlib.sha256(raw.encode("utf-8")).hexdigest() - - -def _build_highlights(rows: List[Dict[str, Any]]) -> List[Dict[str, str]]: - ranked = sorted( - (row for row in rows if isinstance(row, dict) and _row_city(row)), - key=lambda item: (_row_score(item), _row_liquidity(item)), - reverse=True, - ) - highlights: List[Dict[str, str]] = [] - for row in ranked[:5]: - city = _row_city(row) - edge = _row_edge(row) - liquidity = _row_liquidity(row) - gap = _row_prob_gap(row) - edge_text = f"{edge:.1f}%" if edge is not None else "--" - gap_text = f"{gap * 100:.1f}pp" if gap is not None else "--" - liquidity_text = f"{liquidity:,.0f}" if liquidity else "--" - highlights.append( - { - "city": city, - "note_zh": f"edge {edge_text},模型/市场概率差 {gap_text},流动性 {liquidity_text}。", - "note_en": f"edge {edge_text}, model-market gap {gap_text}, liquidity {liquidity_text}.", - } - ) - return highlights - - -def _build_payload(rows: List[Dict[str, Any]]) -> Dict[str, Any]: - clean_rows = [row for row in rows if isinstance(row, dict)] - total = len(clean_rows) - tradable = sum(1 for row in clean_rows if not row.get("closed") and not row.get("stale_for_today")) - high_risk = sum( - 1 - for row in clean_rows - if str(row.get("risk_level") or row.get("risk") or "").lower() in {"high", "danger", "red"} - ) - avg_edge_values = [_row_edge(row) for row in clean_rows] - avg_edge_nums = [value for value in avg_edge_values if value is not None] - avg_edge = sum(avg_edge_nums) / len(avg_edge_nums) if avg_edge_nums else 0.0 - total_liquidity = sum(_row_liquidity(row) for row in clean_rows) - highlights = _build_highlights(clean_rows) - - overview_zh = ( - f"当前区域共有 {total} 个天气合约,{tradable} 个可交易;" - f"高风险 {high_risk} 个,平均 edge {avg_edge:.1f}%,总流动性 {total_liquidity:,.0f}。" - "优先查看 edge、final score 与流动性同时靠前的城市。" - ) - overview_en = ( - f"{total} weather contracts are in scope, {tradable} tradable; " - f"{high_risk} high-risk rows, average edge {avg_edge:.1f}%, total liquidity {total_liquidity:,.0f}. " - "Prioritize rows where edge, final score and liquidity align." - ) - - return { - "overview_zh": overview_zh, - "overview_en": overview_en, - "highlights": highlights, - "generated_at": datetime.utcnow().isoformat() + "Z", - "cache_ttl_sec": OVERVIEW_CACHE_TTL_SEC, - "source": "deterministic", - } - - -def build_market_overview_payload( - rows: List[Dict[str, Any]], - *, - locale: str = "zh-CN", - force_refresh: bool = False, -) -> Dict[str, Any]: - if not rows: - return { - "overview_zh": "", - "overview_en": "", - "highlights": [], - "generated_at": None, - "cache_ttl_sec": OVERVIEW_CACHE_TTL_SEC, - "source": "deterministic", - } - - key = _cache_key(rows, locale) - if not force_refresh: - with _OVERVIEW_CACHE_LOCK: - cached = _OVERVIEW_CACHE.get(key) - if cached and cached.get("expires_at", 0) >= time.time(): - return cached["payload"] - - payload = _build_payload(rows) - with _OVERVIEW_CACHE_LOCK: - _OVERVIEW_CACHE[key] = { - "expires_at": time.time() + OVERVIEW_CACHE_TTL_SEC, - "payload": payload, - } - return payload