清理旧决策卡片、市场总览、跑道面板等已废弃组件

This commit is contained in:
2569718930@qq.com
2026-05-25 23:52:44 +08:00
parent 723c6dafc6
commit 9545725d7f
23 changed files with 4 additions and 2374 deletions
+3 -1
View File
@@ -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 *)"
]
}
}
+1 -1
View File
@@ -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=
@@ -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<string, Array<[string, string]>> = {
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 (
<section className="scan-ai-city-section scan-airport-evidence">
<div className="scan-ai-city-section-head">
<div>
<span className="scan-ai-city-kicker">
{isEn ? "Airport live evidence" : "机场实时证据"}
</span>
<h4>{isEn ? "Airport / runway observations" : "机场 / 跑道观测"}</h4>
</div>
</div>
<div className="scan-airport-evidence-grid">
{station ? (
<div className="scan-airport-evidence-card">
<span>{isEn ? "Airport station" : "机场主站"}</span>
<b>
{station.temp != null && Number.isFinite(Number(station.temp))
? formatTemperatureValue(Number(station.temp), tempSymbol, { digits: 1 })
: "--"}
</b>
<small>
{[station.station_label || station.station_code, station.source_label || "METAR", formatObsTime(station.obs_time || station.report_time)]
.filter(Boolean)
.join(" · ")}
</small>
</div>
) : null}
{runwayEvidence?.rows.map((row) => (
<div
className={`scan-airport-evidence-card runway${row.isSettlement ? " settlement" : ""}`}
key={row.label}
>
<span>
{row.isSettlement
? isEn ? "★ Settlement runway" : "★ 结算跑道"
: isEn ? "Runway" : "跑道"}
</span>
<b>{formatTemperatureValue(row.maxTemp, tempSymbol, { digits: 1 })}</b>
<small>
{[row.label, runwayEvidence.sourceLabel, runwayEvidence.observedAt]
.filter(Boolean)
.join(" · ")}
</small>
</div>
))}
</div>
</section>
);
}
@@ -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<HTMLButtonElement>) => void;
onRemove: (event: MouseEvent<HTMLButtonElement>) => void;
onToggleCollapsed: () => void;
peakWindow: string;
removing?: boolean;
rowLocalTime?: string | null;
statusTags: CityStatusTag[];
}) {
return (
<header className="scan-ai-city-hero">
<div className="scan-ai-city-hero-left">
<span className="scan-ai-city-kicker">
{isEn ? "Deep analysis" : "城市深度分析"}
</span>
<h3>{displayName}</h3>
<CityStatusTags tags={statusTags} />
</div>
<div className="scan-ai-city-hero-side">
<div className="scan-ai-city-metrics">
<span className="primary">
<small>{isEn ? "Expected high" : "预计最高温"}</small>
<b>{expectedHighText}</b>
</span>
<span>
<small>{isEn ? "Peak" : "峰值时间"}</small>
<b>{peakWindow}</b>
</span>
</div>
<div className="scan-ai-city-actions">
<button
type="button"
className="scan-ai-city-icon-button"
onClick={onRefresh}
aria-label={isEn ? `Refresh ${displayName} analysis` : `刷新 ${displayName} 深度分析`}
title={
isEn
? "Refresh city data, chart and AI analysis"
: "刷新城市数据、温度走势图和 AI 分析"
}
disabled={isRefreshing}
>
<RefreshCw size={15} className={isRefreshing ? "spin" : undefined} />
</button>
<button
type="button"
className="scan-ai-city-icon-button danger"
onClick={onRemove}
aria-label={isEn ? `Remove ${displayName}` : `移除 ${displayName}`}
title={isEn ? "Remove city" : "移除城市"}
disabled={removing}
>
<X size={15} />
</button>
<button
type="button"
className="scan-ai-city-collapse"
onClick={onToggleCollapsed}
aria-expanded={!collapsed}
aria-controls={collapseId}
>
<ChevronDown size={15} />
{collapsed ? (isEn ? "Expand" : "展开") : (isEn ? "Collapse" : "收起")}
</button>
</div>
</div>
</header>
);
}
@@ -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 (
<div className="scan-ai-city-status-tags">
{tags.map((tag) => (
<span
key={tag.label}
className={clsx("scan-ai-city-status-tag", tag.tone)}
>
{tag.label}
</span>
))}
</div>
);
}
@@ -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 (
<button
type="button"
onClick={onToggle}
className="group flex w-full items-center gap-2 border-b border-slate-200 bg-[#eef2f6] px-3 py-2 text-left hover:bg-[#e2e8f0] transition-colors"
>
<span className="grid h-5 w-5 shrink-0 place-items-center text-slate-400 group-hover:text-slate-600">
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</span>
<span className="text-xs font-black uppercase tracking-wide text-slate-600">
{label}
</span>
<span className="text-[11px] text-slate-400">
{parts.join(" · ")}
</span>
</button>
);
}
@@ -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 (
<div className="scan-ai-city-freshness" aria-label={isEn ? "Data freshness" : "数据新鲜度"}>
<strong>{isEn ? "Data freshness" : "数据新鲜度"}</strong>
{rows.map((freshness) => (
<span key={freshness.label} className={freshness.tone}>
<b title={freshness.labelTitle}>{freshness.label}{freshnessSeparator}</b>
<em>{freshness.value}</em>
</span>
))}
<span className={aiStatusTone}>
<b>AI{freshnessSeparator}</b>
<em>{aiStatusLabel}</em>
</span>
</div>
);
}
@@ -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<Set<string>>(() => {
const c = new Set<string>();
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 (
<div className="overflow-auto h-full">
<table className="w-full min-w-[600px] border-collapse text-[13px]">
<thead>
<tr className="border-b border-slate-200 bg-[#f8f9fa] text-left text-[11px] uppercase font-bold tracking-wider text-slate-500">
<th className="px-3 py-1.5 font-bold">City</th>
<th className="px-2 py-1.5 text-right font-bold">Obs</th>
<th className="px-2 py-1.5 text-right font-bold">High</th>
<th className="px-2 py-1.5 text-right font-bold">DEB</th>
<th className="px-2 py-1.5 text-right font-bold">Gap</th>
<th className="px-3 py-1.5 font-bold">Signal</th>
</tr>
</thead>
<tbody>
{groups.flatMap((group) => {
const isExpanded = !collapsed.has(group.key);
const rows = showHeaders && !isExpanded ? [] : group.rows;
return (
<Fragment key={group.key}>
{showHeaders && (
<tr className="border-b border-slate-200 bg-[#eef2f6]">
<td colSpan={6} className="p-0">
<button
type="button"
onClick={() => toggleGroup(group.key)}
className="flex w-full items-center gap-2 px-3 py-1 text-left hover:bg-[#e2e8f0] transition-colors"
>
<span className="grid h-4 w-4 place-items-center text-slate-400">
{isExpanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
</span>
<span className="text-[11px] font-black uppercase tracking-wide text-slate-600">
{isEn ? group.labelEn : group.labelZh}
</span>
<span className="text-[10px] text-slate-400">
{group.rows.length} · {labelActive} {group.activeCount} · {labelWatch} {group.watchCount}
{group.localTimeRange ? ` · LT ${group.localTimeRange}` : ""}
{group.hotCity ? ` · Hot: ${group.hotCity}` : ""}
</span>
</button>
</td>
</tr>
)}
{rows.map((row) => {
const signal = getSignalState(row);
const gapColor = GAP_COLOR_MAP[getGapColor(row)];
return (
<tr
key={row.id}
className={clsx(
"cursor-pointer border-b border-slate-100 hover:bg-slate-50/80 transition-colors duration-150",
selectedId === row.id && "bg-blue-50/50"
)}
onClick={() => onSelect(row)}
>
<td className="px-3 py-1.5">
<div className="font-bold text-slate-800 text-[12px]">{rowName(row)}</div>
<div className="truncate text-[10px] text-slate-400 font-medium">
{row.airport || ""}{row.local_time ? ` · ${row.local_time}` : ""}
</div>
</td>
<td className="px-2 py-1.5 text-right font-mono font-bold">
{temp(row.current_temp, row.temp_symbol)}
</td>
<td className="px-2 py-1.5 text-right font-mono">
{temp(row.current_max_so_far, row.temp_symbol)}
</td>
<td className="px-2 py-1.5 text-right font-mono">
{temp(row.deb_prediction, row.temp_symbol)}
</td>
<td className={clsx("px-2 py-1.5 text-right font-mono font-bold", gapColor)}>
{temp(row.signed_gap ?? row.gap_to_target, row.temp_symbol)}
</td>
<td className="px-3 py-1.5">
<span className={clsx(
"text-[12px] font-black",
signal === "active" ? "text-emerald-600" :
signal === "watch" ? "text-amber-600" :
signal === "closed" ? "text-slate-400" : "text-red-500"
)}>
{getSignalLabel(signal, isEn)}
</span>
</td>
</tr>
);
})}
</Fragment>
);
})}
</tbody>
</table>
</div>
);
}
@@ -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<StreamPayload>({ 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<StreamPayload>;
})
.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 (
<Panel title={isEn ? "Realtime Scrolling Temperature" : "实时滚动温度"}>
<div className="flex h-full min-h-[300px] flex-col">
{/* Status bar */}
<div className="shrink-0 flex items-center gap-4 border-b border-slate-200 bg-white px-3 py-1.5 text-[10px]">
<span className="font-black text-slate-600">
{isEn ? "Latest" : "最新"}:{" "}
<span className="font-mono text-teal-700">
{latestTemp !== null ? `${latestTemp.toFixed(1)}°` : "--"}
</span>
</span>
<span className="text-slate-400">
{isEn ? "Points" : "数据点"}: {points.length}
</span>
{breached.length > 0 && (
<span className="font-black text-amber-600">
{isEn ? "Breached" : "已触发"}: {breached.map((t) => t.label).join(", ")}
</span>
)}
</div>
{/* Chart */}
<div className="min-h-0 flex-1 p-2">
{points.length < 2 ? (
<div className="flex h-full items-center justify-center text-xs text-slate-400">
{isEn ? "Collecting data..." : "数据采集中..."}
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<ReLineChart data={points} margin={{ top: 8, right: 24, left: 0, bottom: 0 }}>
<CartesianGrid stroke="#e2e8f0" strokeDasharray="2 2" />
<XAxis
dataKey="timestamp"
tick={{ fontSize: 10, fill: "#94a3b8" }}
tickLine={false}
axisLine={{ stroke: "#cbd5e1" }}
interval={Math.max(1, Math.floor(points.length / 6))}
/>
<YAxis
tick={{ fontSize: 10, fill: "#64748b" }}
tickFormatter={(v) => `${Number(v).toFixed(1)}°`}
axisLine={{ stroke: "#cbd5e1" }}
tickLine={false}
domain={[domainMin, domainMax]}
width={40}
/>
<Tooltip
contentStyle={{
border: "1px solid #cbd5e1",
borderRadius: 4,
fontSize: 11,
}}
formatter={(value: unknown) => [`${Number(value).toFixed(2)}°`, "Temp"]}
labelFormatter={(label) => `${label}`}
/>
{/* Temperature line */}
<Line
type="linear"
dataKey="temp"
stroke="#009688"
strokeWidth={2}
dot={false}
isAnimationActive={false}
/>
{/* Threshold lines */}
{thresholds.map((t) => (
<ReferenceLine
key={t.label}
y={t.threshold_c}
stroke={t.breached ? "#f97316" : "#94a3b8"}
strokeDasharray="4 4"
strokeWidth={1}
label={{
value: t.label,
fill: t.breached ? "#f97316" : "#94a3b8",
fontSize: 9,
position: "right",
}}
/>
))}
</ReLineChart>
</ResponsiveContainer>
)}
</div>
</div>
</Panel>
);
}
@@ -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 (
<div className="flex h-full flex-col bg-white">
{/* Metrics Header */}
<div className="flex items-center justify-between border-b border-slate-200 bg-[#f8f9fa] p-3 text-[12px] shrink-0 flex-wrap gap-2">
<div className="flex gap-4">
<div>
<div className="text-[10px] uppercase font-bold text-slate-400">
{isEn ? "Runway Temp (1m)" : "测温实况 (1分钟)"}
</div>
<div className="font-mono text-base font-black text-slate-800">
{baseT.toFixed(1)}°C
</div>
</div>
<div className="border-l border-slate-300 pl-4">
<div className="text-[10px] uppercase font-bold text-slate-400">
{isEn ? "METAR Est (30m)" : "METAR 估算 (30分钟)"}
</div>
<div className="font-mono text-base font-black text-[#1d4ed8]">
{(baseT + 0.1).toFixed(1)}°C
</div>
</div>
</div>
<div className="flex items-center gap-3">
<div className="text-right text-[11px] font-bold text-slate-500">
{isEn ? "Today's Peak Temp:" : "当日最高气温:"}
</div>
<div className="flex flex-wrap gap-2">
<span className="bg-slate-200 text-slate-700 px-2 py-0.5 rounded text-[10px] font-mono">
{isEn ? "Runway Max:" : "跑温实况:"} <b>{(baseT + 0.15).toFixed(1)}°C</b>
</span>
<span className="bg-blue-100 text-blue-800 px-2 py-0.5 rounded text-[10px] font-mono">
{isEn ? "METAR Official:" : "METAR 官方:"} <b>{(baseT + 0.1).toFixed(1)}°C</b>
</span>
<span className="bg-rose-100 text-rose-800 px-2 py-0.5 rounded text-[10px] font-mono">
{isEn ? "UMA Threshold:" : "UMA 阈值:"} <b>{(row?.target_threshold ?? row?.target_value ?? 30.0).toFixed(1)}°C</b>
</span>
</div>
</div>
</div>
{/* Runway Table */}
<div className="overflow-x-auto border-b border-slate-200 shrink-0">
<table className="w-full text-left text-[12px] border-collapse min-w-[500px]">
<thead>
<tr className="bg-[#f8f9fa] border-b border-slate-200 text-[11px] uppercase font-bold text-slate-500">
<th className="px-3 py-1.5 font-bold">{isEn ? "Runway" : "跑道 (Runway)"}</th>
<th className="px-2 py-1.5 text-right font-bold">TDZ</th>
<th className="px-2 py-1.5 text-right font-bold">MID</th>
<th className="px-2 py-1.5 text-right font-bold">END</th>
<th className="px-2 py-1.5 text-right font-bold">Max</th>
<th className="px-2 py-1.5 text-right font-bold">High</th>
<th className="px-2 py-1.5 text-right font-bold">15m</th>
</tr>
</thead>
<tbody>
{[
{ 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) => (
<tr
key={i}
className={clsx(
"border-b border-slate-100 font-mono text-[12px]",
r.isSettlement ? "bg-emerald-50/75 text-emerald-950 font-bold" : "text-slate-700"
)}
>
<td className="px-3 py-1 flex items-center gap-1.5">
{r.isSettlement && <span className="h-1.5 w-1.5 rounded-full bg-emerald-600 animate-pulse" />}
{r.name}
{r.isSettlement && <span className="text-[10px] bg-emerald-200 text-emerald-800 px-1 rounded scale-90">{isEn ? "Settlement" : "结算"}</span>}
</td>
<td className="px-2 py-1 text-right">{r.tdz}°C</td>
<td className="px-2 py-1 text-right text-slate-400">{r.mid}</td>
<td className="px-2 py-1 text-right">{r.end}°C</td>
<td className="px-2 py-1 text-right">{r.max}°C</td>
<td className="px-2 py-1 text-right font-bold">{r.high}°C</td>
<td className={clsx("px-2 py-1 text-right", r.m15.startsWith("-") ? "text-rose-600" : "text-slate-500")}>
{r.m15}°C
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Chart */}
<div className="flex-1 min-h-[220px] p-2">
<ResponsiveContainer width="100%" height="100%">
<ReLineChart data={dataPoints} margin={{ top: 15, right: 20, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" />
<XAxis
dataKey="time"
tick={{ fontSize: 9, fill: "#64748b" }}
axisLine={{ stroke: "#e2e8f0" }}
tickLine={false}
/>
<YAxis
domain={["dataMin - 0.2", "dataMax + 0.2"]}
tick={{ fontSize: 9, fill: "#64748b" }}
axisLine={false}
tickLine={false}
tickFormatter={(v) => `${v}°`}
/>
<Tooltip
contentStyle={{
backgroundColor: "rgba(17, 24, 39, 0.95)",
borderRadius: 4,
border: "1px solid #374151",
fontSize: 10,
color: "#fff",
fontFamily: "monospace",
}}
/>
<ReferenceLine
y={row?.target_threshold ?? row?.target_value ?? 30.0}
stroke="#be123c"
strokeDasharray="4 4"
strokeWidth={1.5}
label={{
value: `UMA ${row?.target_threshold ?? row?.target_value ?? 30.0}°C ${isEn ? "Strike" : "阈值"}`,
position: "insideBottomRight",
fill: "#be123c",
fontSize: 9,
fontWeight: "bold",
}}
/>
<Line type="monotone" dataKey="01L/19R" stroke="#3b82f6" strokeDasharray="3 3" strokeWidth={1} dot={false} isAnimationActive={false} />
<Line type="monotone" dataKey="01R/19L" stroke="#f97316" strokeDasharray="3 3" strokeWidth={1} dot={false} isAnimationActive={false} />
<Line type="monotone" dataKey="02L/20R 结算跑道" stroke="#0d9488" strokeWidth={2.5} dot={{ r: 2.5, fill: "#0d9488" }} activeDot={{ r: 4 }} isAnimationActive={false} />
<Line type="monotone" dataKey="02R/20L" stroke="#06b6d4" strokeDasharray="3 3" strokeWidth={1} dot={false} isAnimationActive={false} />
<Line type="monotone" dataKey="03/21" stroke="#ef4444" strokeDasharray="3 3" strokeWidth={1} dot={false} isAnimationActive={false} />
<Line type="monotone" dataKey="METAR 官方结算 (30分钟)" stroke="#1d4ed8" strokeWidth={1.5} dot={false} isAnimationActive={false} />
</ReLineChart>
</ResponsiveContainer>
</div>
</div>
);
}
@@ -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");
}
@@ -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 过旧"));
}
@@ -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%/);
}
@@ -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",
);
}
@@ -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",
);
}
@@ -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<number | null>,
) {
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 todays 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<readonly [string, number]>;
modelMax: number | null;
modelMin: number | null;
paceTone: string;
paceView: ReturnType<typeof getTodayPaceView> | 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,
};
}
@@ -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<StatusBadge | null | undefined>) {
const seen = new Set<string>();
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,
};
}
@@ -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<string, CityDetail>,
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
);
}
@@ -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 todays model stack, HKO observation context and market layer."
: "Hydrating todays 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" : "移除",
};
}
@@ -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<string | null | undefined>,
) {
return `${prefix}:${parts
.map((part) => encodeURIComponent(String(part || "").trim()))
.join(":")}`;
}
export function readCachedPayload<T>(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<T>(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.
}
}
-2
View File
@@ -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
-102
View File
@@ -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
-169
View File
@@ -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