清理旧决策卡片、市场总览、跑道面板等已废弃组件
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 web/services/city_runtime.py web/services/city_api.py)",
|
||||||
"Bash(python -m ruff check .)",
|
"Bash(python -m ruff check .)",
|
||||||
"Bash(python -m pytest tests/ -q)",
|
"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 免费额度下建议配置为 VPS HTTPS 域名,让 AI / METAR / scan 等
|
||||||
# 长耗时请求绕过 Vercel Functions / Fluid Compute。
|
# 长耗时请求绕过 Vercel Functions / Fluid Compute。
|
||||||
# 例如:https://api.example.com
|
# 例如:https://api.example.com
|
||||||
NEXT_PUBLIC_POLYWEATHER_API_BASE_URL=
|
NEXT_PUBLIC_POLYWEATHER_API_BASE_URL=http://38.54.27.70:8080
|
||||||
|
|
||||||
# 必填:Supabase 前端公钥(鉴权开启时必须)
|
# 必填:Supabase 前端公钥(鉴权开启时必须)
|
||||||
NEXT_PUBLIC_SUPABASE_URL=
|
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"),
|
path.join(projectRoot, "components", "dashboard", "scan-terminal", "LiveTemperatureThresholdChart.tsx"),
|
||||||
"utf8",
|
"utf8",
|
||||||
);
|
);
|
||||||
const overviewApiSource = fs.readFileSync(
|
|
||||||
path.join(projectRoot, "..", "web", "services", "market_overview_api.py"),
|
|
||||||
"utf8",
|
|
||||||
);
|
|
||||||
|
|
||||||
assert(
|
assert(
|
||||||
querySource.includes("DASHBOARD_REFRESH_POLICY_MS.scanRows"),
|
querySource.includes("DASHBOARD_REFRESH_POLICY_MS.scanRows"),
|
||||||
@@ -42,10 +38,4 @@ export function runTests() {
|
|||||||
!chartSource.includes("window.setInterval"),
|
!chartSource.includes("window.setInterval"),
|
||||||
"selected city detail chart should be on-demand and use model-layer cache instead of 60-second polling",
|
"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():
|
def test_backend_defaults_use_refresh_policy():
|
||||||
import src.data_collection.weather_sources as weather_sources
|
import src.data_collection.weather_sources as weather_sources
|
||||||
import web.services.city_runtime as city_runtime
|
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
|
import web.services.scan_ai_config as scan_ai_config
|
||||||
|
|
||||||
assert scan_ai_config.SCAN_TERMINAL_PAYLOAD_TTL_SEC == SCAN_ROWS_REFRESH_SEC
|
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_FULL_CACHE_TTL_SEC == OBSERVATION_REFRESH_SEC
|
||||||
assert city_runtime.CITY_PANEL_CACHE_TTL_SEC == SCAN_ROWS_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 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({})
|
source = weather_sources.WeatherDataCollector({})
|
||||||
assert source.metar_cache_ttl_sec == METAR_POLL_TTL_SEC
|
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