清理旧决策卡片、市场总览、跑道面板等已废弃组件
This commit is contained in:
@@ -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
@@ -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",
|
||||
);
|
||||
}
|
||||
|
||||
-53
@@ -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 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<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 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" : "移除",
|
||||
};
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user