移除 AI prompt 中 EMOS/CRPS 禁令文本
EMOS/CRPS 从未在项目中实现,仅存在于 AI prompt 的禁止列表和历史文档中。
This commit is contained in:
@@ -28,6 +28,7 @@ import {
|
|||||||
getGapColor,
|
getGapColor,
|
||||||
getSignalLabel,
|
getSignalLabel,
|
||||||
getSignalState,
|
getSignalState,
|
||||||
|
resolveTradingRegionKey,
|
||||||
TRADING_REGIONS,
|
TRADING_REGIONS,
|
||||||
} from "@/components/dashboard/scan-terminal/continent-grouping";
|
} from "@/components/dashboard/scan-terminal/continent-grouping";
|
||||||
import { MobileCityCard } from "@/components/dashboard/scan-terminal/MobileCityCard";
|
import { MobileCityCard } from "@/components/dashboard/scan-terminal/MobileCityCard";
|
||||||
@@ -44,6 +45,7 @@ import { Panel } from "@/components/dashboard/scan-terminal/Panel";
|
|||||||
import { GroupedMarketTable } from "@/components/dashboard/scan-terminal/GroupedMarketTable";
|
import { GroupedMarketTable } from "@/components/dashboard/scan-terminal/GroupedMarketTable";
|
||||||
import { TrainingDashboard } from "@/components/dashboard/scan-terminal/TrainingDashboard";
|
import { TrainingDashboard } from "@/components/dashboard/scan-terminal/TrainingDashboard";
|
||||||
import { LiveTemperatureThresholdChart } from "@/components/dashboard/scan-terminal/LiveTemperatureThresholdChart";
|
import { LiveTemperatureThresholdChart } from "@/components/dashboard/scan-terminal/LiveTemperatureThresholdChart";
|
||||||
|
import { MarketOverviewView } from "@/components/dashboard/scan-terminal/MarketOverviewView";
|
||||||
import { rowName, pct, money, temp, edgeClass } from "@/components/dashboard/scan-terminal/utils";
|
import { rowName, pct, money, temp, edgeClass } from "@/components/dashboard/scan-terminal/utils";
|
||||||
|
|
||||||
function createEmptyAccess(loading = true): ProAccessState {
|
function createEmptyAccess(loading = true): ProAccessState {
|
||||||
@@ -340,7 +342,7 @@ function PolyWeatherTerminal({
|
|||||||
const filteredRegionRows = useMemo(() => {
|
const filteredRegionRows = useMemo(() => {
|
||||||
return rows.filter(
|
return rows.filter(
|
||||||
(row) =>
|
(row) =>
|
||||||
String(row.trading_region).toLowerCase() === selectedRegionKey &&
|
resolveTradingRegionKey(row) === selectedRegionKey &&
|
||||||
row.is_primary_signal !== false,
|
row.is_primary_signal !== false,
|
||||||
);
|
);
|
||||||
}, [rows, selectedRegionKey]);
|
}, [rows, selectedRegionKey]);
|
||||||
@@ -543,6 +545,17 @@ function PolyWeatherTerminal({
|
|||||||
<main className="min-h-0 flex-1 overflow-hidden flex flex-col p-2 bg-[#eef2f6]">
|
<main className="min-h-0 flex-1 overflow-hidden flex flex-col p-2 bg-[#eef2f6]">
|
||||||
{activeNavKey === "training" ? (
|
{activeNavKey === "training" ? (
|
||||||
<TrainingDashboard isEn={isEn} />
|
<TrainingDashboard isEn={isEn} />
|
||||||
|
) : activeNavKey === "markets" ? (
|
||||||
|
<MarketOverviewView
|
||||||
|
isEn={isEn}
|
||||||
|
rows={rows}
|
||||||
|
onSelectRow={(row) => {
|
||||||
|
const regionKey = resolveTradingRegionKey(row);
|
||||||
|
if (regionKey) setSelectedRegionKey(regionKey);
|
||||||
|
setSelectedRow(row);
|
||||||
|
setActiveNavKey("contracts");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Region tabs */}
|
{/* Region tabs */}
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export function GroupedMarketTable({
|
|||||||
<div className="overflow-auto h-full">
|
<div className="overflow-auto h-full">
|
||||||
<table className="w-full min-w-[800px] border-collapse text-[13px]">
|
<table className="w-full min-w-[800px] border-collapse text-[13px]">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-slate-200 bg-[#f8f9fa] text-left text-[10px] uppercase font-bold tracking-wider text-slate-500">
|
<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-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">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">High</th>
|
||||||
@@ -144,12 +144,12 @@ export function GroupedMarketTable({
|
|||||||
<td className={clsx("px-2 py-1.5 text-right font-mono font-bold", edgeClass(row.edge_percent))}>
|
<td className={clsx("px-2 py-1.5 text-right font-mono font-bold", edgeClass(row.edge_percent))}>
|
||||||
{pct(row.edge_percent)}
|
{pct(row.edge_percent)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-2 py-1.5 text-right font-mono text-[11px]">
|
<td className="px-2 py-1.5 text-right font-mono text-[12px]">
|
||||||
{formatSpreadLiquidity(row.spread, row.book_liquidity ?? row.market_liquidity)}
|
{formatSpreadLiquidity(row.spread, row.book_liquidity ?? row.market_liquidity)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-1.5">
|
<td className="px-3 py-1.5">
|
||||||
<span className={clsx(
|
<span className={clsx(
|
||||||
"text-[11px] font-black",
|
"text-[12px] font-black",
|
||||||
signal === "active" ? "text-emerald-600" :
|
signal === "active" ? "text-emerald-600" :
|
||||||
signal === "watch" ? "text-amber-600" :
|
signal === "watch" ? "text-amber-600" :
|
||||||
signal === "closed" ? "text-slate-400" : "text-red-500"
|
signal === "closed" ? "text-slate-400" : "text-red-500"
|
||||||
|
|||||||
@@ -310,7 +310,7 @@ export function LiveTemperatureThresholdChart({
|
|||||||
<span className="h-1.5 w-4 rounded-full" style={{ backgroundColor: item.color }} />
|
<span className="h-1.5 w-4 rounded-full" style={{ backgroundColor: item.color }} />
|
||||||
<span className="truncate font-black text-slate-700">{item.label}</span>
|
<span className="truncate font-black text-slate-700">{item.label}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 grid grid-cols-3 gap-1 font-mono text-[9px] text-slate-600">
|
<div className="mt-1 grid grid-cols-3 gap-1 font-mono text-[10px] text-slate-600">
|
||||||
<span>now: {temp(item.latest)}</span>
|
<span>now: {temp(item.latest)}</span>
|
||||||
<span>max: {temp(item.high)}</span>
|
<span>max: {temp(item.high)}</span>
|
||||||
<span>15m: {item.delta15 === null ? "--" : `${item.delta15 >= 0 ? "+" : ""}${item.delta15.toFixed(1)}°`}</span>
|
<span>15m: {item.delta15 === null ? "--" : `${item.delta15 >= 0 ? "+" : ""}${item.delta15.toFixed(1)}°`}</span>
|
||||||
|
|||||||
@@ -0,0 +1,361 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import type { ScanOpportunityRow, ScanTerminalResponse } from "@/lib/dashboard-types";
|
||||||
|
import {
|
||||||
|
resolveTradingRegionKey,
|
||||||
|
TRADING_REGIONS,
|
||||||
|
} from "@/components/dashboard/scan-terminal/continent-grouping";
|
||||||
|
import { Panel } from "@/components/dashboard/scan-terminal/Panel";
|
||||||
|
import { edgeClass, money, pct, rowName } from "@/components/dashboard/scan-terminal/utils";
|
||||||
|
|
||||||
|
type Holder = {
|
||||||
|
amount?: number;
|
||||||
|
name?: string;
|
||||||
|
outcomeIndex?: number;
|
||||||
|
proxyWallet?: string;
|
||||||
|
pseudonym?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type HolderInfo = {
|
||||||
|
holders: Holder[] | null;
|
||||||
|
loading: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MARKET_OVERVIEW_REFRESH_MS = 10 * 60_000;
|
||||||
|
|
||||||
|
function numeric(value: unknown) {
|
||||||
|
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowLiquidity(row: ScanOpportunityRow) {
|
||||||
|
return numeric(row.book_liquidity || row.market_liquidity || row.volume);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowOpportunityScore(row: ScanOpportunityRow) {
|
||||||
|
return (
|
||||||
|
numeric(row.final_score) * 0.5 +
|
||||||
|
numeric(row.edge_percent ?? row.signed_gap ?? row.gap) * 2 +
|
||||||
|
Math.log10(rowLiquidity(row) + 1)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowSpread(row: ScanOpportunityRow) {
|
||||||
|
return numeric(row.spread);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStale(row: ScanOpportunityRow) {
|
||||||
|
return Boolean(row.metar_context?.stale_for_today || row.metar_status?.stale_for_today);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWatch(row: ScanOpportunityRow) {
|
||||||
|
const value = String(row.ai_decision || row.v4_metar_decision || row.signal_status || row.action || "").toLowerCase();
|
||||||
|
return value.includes("watch") || !row.tradable;
|
||||||
|
}
|
||||||
|
|
||||||
|
function holderLabel(holder: Holder) {
|
||||||
|
return (
|
||||||
|
holder.name ||
|
||||||
|
holder.pseudonym ||
|
||||||
|
(holder.proxyWallet ? `${String(holder.proxyWallet).slice(0, 6)}...${String(holder.proxyWallet).slice(-4)}` : "--")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CompactRowsTable({
|
||||||
|
empty,
|
||||||
|
isEn,
|
||||||
|
onSelectRow,
|
||||||
|
rows,
|
||||||
|
showSpread = false,
|
||||||
|
}: {
|
||||||
|
empty: string;
|
||||||
|
isEn: boolean;
|
||||||
|
onSelectRow: (row: ScanOpportunityRow) => void;
|
||||||
|
rows: ScanOpportunityRow[];
|
||||||
|
showSpread?: boolean;
|
||||||
|
}) {
|
||||||
|
if (!rows.length) {
|
||||||
|
return <div className="px-3 py-8 text-center text-[11px] text-slate-400">{empty}</div>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<table className="w-full border-collapse text-[11px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-slate-200 bg-slate-50 text-[11px] uppercase text-slate-500">
|
||||||
|
<th className="px-3 py-1.5 text-left font-black">{isEn ? "City / Contract" : "城市 / 合约"}</th>
|
||||||
|
<th className="px-2 py-1.5 text-right font-black">{isEn ? "Model" : "模型"}</th>
|
||||||
|
<th className="px-2 py-1.5 text-right font-black">{isEn ? "Market" : "市场"}</th>
|
||||||
|
<th className="px-2 py-1.5 text-right font-black">{showSpread ? (isEn ? "Spread" : "价差") : "Edge"}</th>
|
||||||
|
<th className="px-3 py-1.5 text-right font-black">{isEn ? "Liq" : "流动性"}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((row) => {
|
||||||
|
const edge = numeric(row.edge_percent ?? row.signed_gap ?? row.gap);
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={row.id}
|
||||||
|
onClick={() => onSelectRow(row)}
|
||||||
|
className="cursor-pointer border-b border-slate-100 hover:bg-blue-50"
|
||||||
|
>
|
||||||
|
<td className="px-3 py-1.5">
|
||||||
|
<div className="truncate font-bold text-slate-800">{rowName(row)}</div>
|
||||||
|
<div className="truncate text-[10px] font-medium text-slate-400">{row.target_label || row.market_question || "--"}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1.5 text-right font-mono">{pct(row.model_probability ?? row.model_event_probability)}</td>
|
||||||
|
<td className="px-2 py-1.5 text-right font-mono">{pct(row.market_probability ?? row.market_event_probability)}</td>
|
||||||
|
<td className={clsx("px-2 py-1.5 text-right font-mono font-bold", showSpread ? "text-slate-700" : edgeClass(edge))}>
|
||||||
|
{showSpread ? pct(row.spread) : pct(edge)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5 text-right font-mono">{money(rowLiquidity(row))}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MarketOverviewView({
|
||||||
|
isEn,
|
||||||
|
onSelectRow,
|
||||||
|
rows,
|
||||||
|
}: {
|
||||||
|
isEn: boolean;
|
||||||
|
onSelectRow: (row: ScanOpportunityRow) => void;
|
||||||
|
rows: ScanOpportunityRow[];
|
||||||
|
}) {
|
||||||
|
const [overviewRows, setOverviewRows] = useState(rows);
|
||||||
|
const [lastScanAt, setLastScanAt] = useState<Date | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setOverviewRows(rows);
|
||||||
|
}, [rows]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
let controller: AbortController | null = null;
|
||||||
|
|
||||||
|
const refreshOverview = async () => {
|
||||||
|
if (typeof fetch !== "function" || typeof AbortController === "undefined") return;
|
||||||
|
controller?.abort();
|
||||||
|
controller = new AbortController();
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/scan/terminal?force_refresh=false", {
|
||||||
|
cache: "no-store",
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
if (!response.ok) return;
|
||||||
|
const payload = await response.json() as ScanTerminalResponse;
|
||||||
|
if (!cancelled && Array.isArray(payload.rows)) {
|
||||||
|
setOverviewRows(payload.rows);
|
||||||
|
setLastScanAt(new Date());
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as { name?: string })?.name !== "AbortError") {
|
||||||
|
// Keep the existing snapshot; overview refresh should never blank the terminal.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const intervalId = window.setInterval(() => {
|
||||||
|
void refreshOverview();
|
||||||
|
}, MARKET_OVERVIEW_REFRESH_MS);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
controller?.abort();
|
||||||
|
window.clearInterval(intervalId);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const primaryRows = useMemo(
|
||||||
|
() => overviewRows.filter((row) => row.is_primary_signal !== false),
|
||||||
|
[overviewRows],
|
||||||
|
);
|
||||||
|
const regionStats = useMemo(
|
||||||
|
() =>
|
||||||
|
TRADING_REGIONS.map((region) => {
|
||||||
|
const list = primaryRows.filter((row) => resolveTradingRegionKey(row) === region.key);
|
||||||
|
const avgEdge = list.reduce((sum, row) => sum + numeric(row.edge_percent ?? row.signed_gap ?? row.gap), 0) / Math.max(list.length, 1);
|
||||||
|
return {
|
||||||
|
key: region.key,
|
||||||
|
label: isEn ? region.labelEn : region.labelZh,
|
||||||
|
contracts: list.length,
|
||||||
|
tradable: list.filter((row) => row.tradable).length,
|
||||||
|
heat: list.filter((row) => row.risk_level === "high" || numeric(row.current_temp) >= 30).length,
|
||||||
|
avgEdge,
|
||||||
|
liquidity: list.reduce((sum, row) => sum + rowLiquidity(row), 0),
|
||||||
|
};
|
||||||
|
}).filter((item) => item.contracts > 0),
|
||||||
|
[isEn, primaryRows],
|
||||||
|
);
|
||||||
|
|
||||||
|
const topOpportunities = useMemo(
|
||||||
|
() => [...primaryRows].sort((a, b) => rowOpportunityScore(b) - rowOpportunityScore(a)).slice(0, 12),
|
||||||
|
[primaryRows],
|
||||||
|
);
|
||||||
|
const riskRows = useMemo(
|
||||||
|
() =>
|
||||||
|
primaryRows
|
||||||
|
.filter((row) => isWatch(row) || numeric(row.edge_percent ?? row.signed_gap ?? row.gap) < 0 || row.closed || isStale(row))
|
||||||
|
.sort((a, b) => numeric(a.edge_percent ?? a.signed_gap ?? a.gap) - numeric(b.edge_percent ?? b.signed_gap ?? b.gap))
|
||||||
|
.slice(0, 12),
|
||||||
|
[primaryRows],
|
||||||
|
);
|
||||||
|
const liquidityRows = useMemo(
|
||||||
|
() => [...primaryRows].sort((a, b) => rowLiquidity(b) - rowLiquidity(a)).slice(0, 8),
|
||||||
|
[primaryRows],
|
||||||
|
);
|
||||||
|
const tightSpreadRows = useMemo(
|
||||||
|
() =>
|
||||||
|
[...primaryRows]
|
||||||
|
.filter((row) => rowSpread(row) > 0)
|
||||||
|
.sort((a, b) => rowSpread(a) - rowSpread(b))
|
||||||
|
.slice(0, 8),
|
||||||
|
[primaryRows],
|
||||||
|
);
|
||||||
|
const whaleRows = useMemo(
|
||||||
|
() => liquidityRows.slice(0, 6),
|
||||||
|
[liquidityRows],
|
||||||
|
);
|
||||||
|
|
||||||
|
const [holderMap, setHolderMap] = useState<Record<string, HolderInfo>>({});
|
||||||
|
useEffect(() => {
|
||||||
|
whaleRows.forEach((row) => {
|
||||||
|
const city = String(row.city || "").toLowerCase();
|
||||||
|
if (!city || holderMap[city]?.loading || holderMap[city]?.holders) return;
|
||||||
|
setHolderMap((prev) => ({ ...prev, [city]: { holders: null, loading: true } }));
|
||||||
|
fetch(`/api/city/${encodeURIComponent(city)}/holders?limit=6`, {
|
||||||
|
cache: "no-store",
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
})
|
||||||
|
.then(async (res) => {
|
||||||
|
const json = await res.json() as { holders?: Holder[] };
|
||||||
|
setHolderMap((prev) => ({ ...prev, [city]: { holders: json.holders || [], loading: false } }));
|
||||||
|
})
|
||||||
|
.catch(() => setHolderMap((prev) => ({ ...prev, [city]: { holders: null, loading: false } })));
|
||||||
|
});
|
||||||
|
}, [holderMap, whaleRows]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid h-full min-h-0 grid-cols-[1.1fr_1fr] grid-rows-[auto_1fr] gap-2">
|
||||||
|
<Panel
|
||||||
|
title={isEn ? "Regional Market Heat" : "区域市场热度"}
|
||||||
|
className="col-span-2"
|
||||||
|
actions={
|
||||||
|
<span className="font-mono text-[10px] font-bold text-slate-400">
|
||||||
|
{isEn ? "Scan: 10m" : "扫描:10分钟"}
|
||||||
|
{lastScanAt ? ` · ${lastScanAt.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}` : ""}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-4 gap-2 p-3">
|
||||||
|
{regionStats.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.key}
|
||||||
|
type="button"
|
||||||
|
className="rounded border border-slate-200 bg-slate-50 p-3 text-left hover:bg-blue-50"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="truncate text-sm font-black text-slate-800">{item.label}</div>
|
||||||
|
<div className={clsx("font-mono text-xs font-black", edgeClass(item.avgEdge))}>{pct(item.avgEdge)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 grid grid-cols-4 gap-1 text-center text-[10px]">
|
||||||
|
{[
|
||||||
|
[isEn ? "Contracts" : "合约", item.contracts],
|
||||||
|
[isEn ? "Tradable" : "可交易", item.tradable],
|
||||||
|
[isEn ? "Heat" : "高温", item.heat],
|
||||||
|
[isEn ? "Liq" : "流动性", money(item.liquidity)],
|
||||||
|
].map(([label, value]) => (
|
||||||
|
<div key={String(label)} className="rounded border border-slate-200 bg-white px-1 py-1">
|
||||||
|
<div className="truncate text-[10px] font-black uppercase text-slate-400">{label}</div>
|
||||||
|
<div className="truncate font-mono font-black text-slate-800">{value}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<Panel title={isEn ? "Top Opportunities" : "Top Opportunities"}>
|
||||||
|
<CompactRowsTable
|
||||||
|
empty={isEn ? "No opportunities" : "暂无机会"}
|
||||||
|
isEn={isEn}
|
||||||
|
onSelectRow={onSelectRow}
|
||||||
|
rows={topOpportunities}
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<Panel title={isEn ? "Risk / Watchlist" : "Risk / Watchlist"}>
|
||||||
|
<CompactRowsTable
|
||||||
|
empty={isEn ? "No risk rows" : "暂无风险项"}
|
||||||
|
isEn={isEn}
|
||||||
|
onSelectRow={onSelectRow}
|
||||||
|
rows={riskRows}
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<Panel title={isEn ? "Liquidity Leaderboard" : "流动性排行"}>
|
||||||
|
<CompactRowsTable
|
||||||
|
empty={isEn ? "No liquidity rows" : "暂无流动性数据"}
|
||||||
|
isEn={isEn}
|
||||||
|
onSelectRow={onSelectRow}
|
||||||
|
rows={liquidityRows}
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<div className="grid min-h-0 grid-rows-2 gap-2">
|
||||||
|
<Panel title={isEn ? "Tightest Spreads" : "最低价差"}>
|
||||||
|
<CompactRowsTable
|
||||||
|
empty={isEn ? "No spread rows" : "暂无价差数据"}
|
||||||
|
isEn={isEn}
|
||||||
|
onSelectRow={onSelectRow}
|
||||||
|
rows={tightSpreadRows}
|
||||||
|
showSpread
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
|
<Panel title={isEn ? "Whale Signals" : "大户持仓信号"}>
|
||||||
|
<div className="divide-y divide-slate-100 text-[11px]">
|
||||||
|
{whaleRows.map((row) => {
|
||||||
|
const city = String(row.city || "").toLowerCase();
|
||||||
|
const info = holderMap[city];
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={row.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelectRow(row)}
|
||||||
|
className="block w-full px-3 py-2 text-left hover:bg-blue-50"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="truncate font-black text-slate-800">{rowName(row)}</span>
|
||||||
|
<span className="font-mono font-black text-blue-700">{money(rowLiquidity(row))}</span>
|
||||||
|
</div>
|
||||||
|
{info?.loading ? (
|
||||||
|
<div className="mt-1 text-[10px] text-slate-400">{isEn ? "Loading holders..." : "加载持仓..."}</div>
|
||||||
|
) : info?.holders?.length ? (
|
||||||
|
<div className="mt-1 space-y-0.5">
|
||||||
|
{info.holders.slice(0, 2).map((holder, index) => (
|
||||||
|
<div key={`${city}-${index}`} className="flex items-center justify-between gap-2 text-[10px]">
|
||||||
|
<span className="truncate text-slate-500">{holderLabel(holder)}</span>
|
||||||
|
<span className="font-mono text-slate-700">
|
||||||
|
{holder.outcomeIndex === 0 ? "YES" : holder.outcomeIndex === 1 ? "NO" : ""}{" "}
|
||||||
|
{holder.amount != null ? Number(holder.amount).toFixed(0) : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-1 text-[10px] text-slate-400">{isEn ? "No holder data" : "无持仓数据"}</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -110,7 +110,7 @@ export function RunwayMeteorologyPanel({
|
|||||||
<div className="overflow-x-auto border-b border-slate-200 shrink-0">
|
<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]">
|
<table className="w-full text-left text-[12px] border-collapse min-w-[500px]">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-[#f8f9fa] border-b border-slate-200 text-[10px] uppercase font-bold text-slate-500">
|
<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-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">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">MID</th>
|
||||||
@@ -131,14 +131,14 @@ export function RunwayMeteorologyPanel({
|
|||||||
<tr
|
<tr
|
||||||
key={i}
|
key={i}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"border-b border-slate-100 font-mono text-[11px]",
|
"border-b border-slate-100 font-mono text-[12px]",
|
||||||
r.isSettlement ? "bg-emerald-50/75 text-emerald-950 font-bold" : "text-slate-700"
|
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">
|
<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.isSettlement && <span className="h-1.5 w-1.5 rounded-full bg-emerald-600 animate-pulse" />}
|
||||||
{r.name}
|
{r.name}
|
||||||
{r.isSettlement && <span className="text-[9px] bg-emerald-200 text-emerald-800 px-1 rounded scale-90">{isEn ? "Settlement" : "结算"}</span>}
|
{r.isSettlement && <span className="text-[10px] bg-emerald-200 text-emerald-800 px-1 rounded scale-90">{isEn ? "Settlement" : "结算"}</span>}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-2 py-1 text-right">{r.tdz}°C</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 text-slate-400">{r.mid}</td>
|
||||||
|
|||||||
@@ -10,26 +10,39 @@ import {
|
|||||||
XAxis,
|
XAxis,
|
||||||
YAxis,
|
YAxis,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
import { TrendingUp, Target, Thermometer, Hash } from "lucide-react";
|
import { TrendingUp, Target, Thermometer, Hash, BarChart3, Crosshair } from "lucide-react";
|
||||||
|
|
||||||
|
type MetricPayload = {
|
||||||
|
hit_rate: number;
|
||||||
|
mae: number;
|
||||||
|
total_days: number;
|
||||||
|
brier_score?: number;
|
||||||
|
};
|
||||||
|
|
||||||
type TrainingCity = {
|
type TrainingCity = {
|
||||||
city_id: string;
|
city_id: string;
|
||||||
name: string;
|
name: string;
|
||||||
deb?: { hit_rate: number; mae: number; total_days: number } | null;
|
deb?: MetricPayload;
|
||||||
|
mu?: MetricPayload;
|
||||||
};
|
};
|
||||||
|
|
||||||
const CHART_COLORS = {
|
const STAT_CARD_CLASSES: Record<string, string> = {
|
||||||
high: "#059669",
|
blue: "bg-blue-50 border-blue-200",
|
||||||
mid: "#d97706",
|
emerald: "bg-emerald-50 border-emerald-200",
|
||||||
low: "#dc2626",
|
amber: "bg-amber-50 border-amber-200",
|
||||||
blue: "#2563eb",
|
purple: "bg-purple-50 border-purple-200",
|
||||||
purple: "#7c3aed",
|
};
|
||||||
|
const STAT_ICON_CLASSES: Record<string, string> = {
|
||||||
|
blue: "text-blue-600",
|
||||||
|
emerald: "text-emerald-600",
|
||||||
|
amber: "text-amber-600",
|
||||||
|
purple: "text-purple-600",
|
||||||
};
|
};
|
||||||
|
|
||||||
function barColor(hr: number) {
|
function barColor(hr: number) {
|
||||||
if (hr >= 65) return CHART_COLORS.high;
|
if (hr >= 65) return "#059669";
|
||||||
if (hr >= 45) return CHART_COLORS.mid;
|
if (hr >= 45) return "#d97706";
|
||||||
return CHART_COLORS.low;
|
return "#dc2626";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TrainingDashboard({ isEn }: { isEn: boolean }) {
|
export function TrainingDashboard({ isEn }: { isEn: boolean }) {
|
||||||
@@ -37,241 +50,246 @@ export function TrainingDashboard({ isEn }: { isEn: boolean }) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
fetch("/api/ops/training/accuracy", {
|
fetch("/api/ops/training/accuracy", { cache: "no-store", headers: { Accept: "application/json" } })
|
||||||
cache: "no-store",
|
|
||||||
headers: { Accept: "application/json" },
|
|
||||||
})
|
|
||||||
.then(async (res) => {
|
.then(async (res) => {
|
||||||
if (!res.ok) return null;
|
if (!res.ok) return null;
|
||||||
return res.json() as Promise<{ accuracy: TrainingCity[] }>;
|
return res.json() as Promise<{ accuracy: TrainingCity[] }>;
|
||||||
})
|
})
|
||||||
.then((payload) => {
|
.then((payload) => {
|
||||||
if (cancelled || !payload?.accuracy) return;
|
if (cancelled || !payload?.accuracy) return;
|
||||||
setData(payload.accuracy.filter((c) => c.deb && c.deb.total_days >= 5));
|
setData(payload.accuracy.filter((c) => (c.deb || c.mu) && ((c.deb?.total_days ?? 0) + (c.mu?.total_days ?? 0)) >= 5));
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const debSorted = useMemo(
|
const debSorted = useMemo(() => (data || []).filter((c) => c.deb).sort((a, b) => (b.deb?.hit_rate ?? 0) - (a.deb?.hit_rate ?? 0)), [data]);
|
||||||
() => (data || []).sort((a, b) => (b.deb?.hit_rate ?? 0) - (a.deb?.hit_rate ?? 0)),
|
const muSorted = useMemo(() => (data || []).filter((c) => c.mu).sort((a, b) => (b.mu?.hit_rate ?? 0) - (a.mu?.hit_rate ?? 0)), [data]);
|
||||||
[data],
|
|
||||||
);
|
|
||||||
|
|
||||||
const stats = useMemo(() => {
|
const debStats = useMemo(() => {
|
||||||
if (!debSorted.length) return null;
|
if (!debSorted.length) return null;
|
||||||
const avgHit = debSorted.reduce((s, c) => s + (c.deb?.hit_rate ?? 0), 0) / debSorted.length;
|
const avgHit = debSorted.reduce((s, c) => s + (c.deb?.hit_rate ?? 0), 0) / debSorted.length;
|
||||||
const avgMae = debSorted.reduce((s, c) => s + (c.deb?.mae ?? 0), 0) / debSorted.length;
|
const avgMae = debSorted.reduce((s, c) => s + (c.deb?.mae ?? 0), 0) / debSorted.length;
|
||||||
const totalDays = debSorted.reduce((s, c) => s + (c.deb?.total_days ?? 0), 0);
|
const totalDays = debSorted.reduce((s, c) => s + (c.deb?.total_days ?? 0), 0);
|
||||||
const high = debSorted.filter((c) => (c.deb?.hit_rate ?? 0) >= 65).length;
|
return { avgHit, avgMae, totalDays, cities: debSorted.length };
|
||||||
const mid = debSorted.filter((c) => {
|
|
||||||
const hr = c.deb?.hit_rate ?? 0;
|
|
||||||
return hr >= 45 && hr < 65;
|
|
||||||
}).length;
|
|
||||||
const low = debSorted.filter((c) => (c.deb?.hit_rate ?? 0) < 45).length;
|
|
||||||
return { avgHit, avgMae, totalDays, cities: debSorted.length, high, mid, low };
|
|
||||||
}, [debSorted]);
|
}, [debSorted]);
|
||||||
|
|
||||||
const chartData = useMemo(
|
const muStats = useMemo(() => {
|
||||||
() =>
|
if (!muSorted.length) return null;
|
||||||
debSorted.slice(0, 18).map((c) => ({
|
const avgHit = muSorted.reduce((s, c) => s + (c.mu?.hit_rate ?? 0), 0) / muSorted.length;
|
||||||
name: c.name,
|
const avgMae = muSorted.reduce((s, c) => s + (c.mu?.mae ?? 0), 0) / muSorted.length;
|
||||||
hit: Number((c.deb?.hit_rate ?? 0).toFixed(1)),
|
const avgBrier = muSorted.reduce((s, c) => s + (c.mu?.brier_score ?? 0), 0) / muSorted.length;
|
||||||
mae: Number((c.deb?.mae ?? 0).toFixed(2)),
|
const totalDays = muSorted.reduce((s, c) => s + (c.mu?.total_days ?? 0), 0);
|
||||||
fill: barColor(c.deb?.hit_rate ?? 0),
|
return { avgHit, avgMae, avgBrier, totalDays, cities: muSorted.length };
|
||||||
})),
|
}, [muSorted]);
|
||||||
|
|
||||||
|
const debHitChart = useMemo(
|
||||||
|
() => debSorted.slice(0, 18).map((c) => ({ name: c.name, value: Number((c.deb?.hit_rate ?? 0).toFixed(1)) })),
|
||||||
[debSorted],
|
[debSorted],
|
||||||
);
|
);
|
||||||
|
const debMaeChart = useMemo(
|
||||||
const maeChartData = useMemo(
|
() => [...debSorted].sort((a, b) => (a.deb?.mae ?? 99) - (b.deb?.mae ?? 99)).slice(0, 18).map((c) => ({ name: c.name, value: Number((c.deb?.mae ?? 0).toFixed(2)) })),
|
||||||
() =>
|
|
||||||
[...debSorted]
|
|
||||||
.sort((a, b) => (a.deb?.mae ?? 99) - (b.deb?.mae ?? 99))
|
|
||||||
.slice(0, 18)
|
|
||||||
.map((c) => ({
|
|
||||||
name: c.name,
|
|
||||||
mae: Number((c.deb?.mae ?? 0).toFixed(2)),
|
|
||||||
})),
|
|
||||||
[debSorted],
|
[debSorted],
|
||||||
);
|
);
|
||||||
|
const muHitChart = useMemo(
|
||||||
|
() => muSorted.slice(0, 18).map((c) => ({ name: c.name, value: Number((c.mu?.hit_rate ?? 0).toFixed(1)) })),
|
||||||
|
[muSorted],
|
||||||
|
);
|
||||||
|
const muBrierChart = useMemo(
|
||||||
|
() => [...muSorted].sort((a, b) => (a.mu?.brier_score ?? 99) - (b.mu?.brier_score ?? 99)).slice(0, 18).map((c) => ({ name: c.name, value: Number((c.mu?.brier_score ?? 0).toFixed(3)) })),
|
||||||
|
[muSorted],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full overflow-auto bg-[#f5f7fa]">
|
<div className="h-full overflow-auto bg-[#f5f7fa]">
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
{/* Header */}
|
<h1 className="text-lg font-black text-slate-900 flex items-center gap-2 mb-1">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<BarChart3 size={18} className="text-blue-600" />
|
||||||
<div>
|
{isEn ? "Model Training Accuracy" : "模型训练准确率"}
|
||||||
<h1 className="text-lg font-black text-slate-900 flex items-center gap-2">
|
</h1>
|
||||||
<Target size={18} className="text-blue-600" />
|
<p className="text-xs text-slate-500 mb-4">
|
||||||
{isEn ? "DEB Training Accuracy" : "DEB 训练数据准确率"}
|
{isEn
|
||||||
</h1>
|
? "DEB temperature forecast vs. Probability Mu calibration — per-city backtesting metrics."
|
||||||
<p className="mt-0.5 text-xs text-slate-500">
|
: "DEB 气温预报 与 概率 μ 校准 — 各城市回测指标。"}
|
||||||
{isEn
|
</p>
|
||||||
? "DEB hit rate = forecast within settlement acceptance window"
|
|
||||||
: "DEB 命中率 = 预报落入结算接受窗口的比例"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{stats && (
|
|
||||||
<div className="flex items-center gap-4 text-xs text-slate-500">
|
|
||||||
<span>{isEn ? "Updated" : "更新时间"}: {new Date().toLocaleDateString()}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stats cards */}
|
{/* ── DEB Section ── */}
|
||||||
{stats && (
|
{debStats && (
|
||||||
<div className="grid grid-cols-4 gap-2 mb-4">
|
<>
|
||||||
{[
|
<h2 className="text-sm font-black text-slate-800 flex items-center gap-1.5 mb-2">
|
||||||
{ icon: Hash, label: isEn ? "Models" : "城市模型", value: stats.cities, color: "text-blue-600", bg: "bg-blue-50 border-blue-200" },
|
<Thermometer size={14} className="text-amber-600" />
|
||||||
{ icon: Target, label: isEn ? "Avg Hit Rate" : "平均命中率", value: `${stats.avgHit.toFixed(1)}%`, color: "text-emerald-600", bg: "bg-emerald-50 border-emerald-200" },
|
{isEn ? "DEB Temperature Forecast" : "DEB 气温预报"}
|
||||||
{ icon: Thermometer, label: "Avg MAE", value: `${stats.avgMae.toFixed(1)}°`, color: "text-amber-600", bg: "bg-amber-50 border-amber-200" },
|
</h2>
|
||||||
{ icon: TrendingUp, label: isEn ? "Total Days" : "总训练天数", value: stats.totalDays.toLocaleString(), color: "text-purple-600", bg: "bg-purple-50 border-purple-200" },
|
<div className="grid grid-cols-4 gap-2 mb-3">
|
||||||
].map(({ icon: Icon, label, value, color, bg }) => (
|
{[
|
||||||
<div key={label} className={`flex items-center gap-3 rounded-lg border ${bg} p-3`}>
|
{ icon: Hash, label: isEn ? "Cities" : "城市数", value: debStats.cities, tone: "blue" },
|
||||||
<Icon size={20} className={color} />
|
{ icon: Target, label: isEn ? "Avg Hit" : "平均命中", value: `${debStats.avgHit.toFixed(1)}%`, tone: "emerald" },
|
||||||
<div>
|
{ icon: Thermometer, label: isEn ? "Avg Error" : "平均误差", value: `${debStats.avgMae.toFixed(1)}°`, tone: "amber" },
|
||||||
<div className="text-[10px] font-bold uppercase text-slate-500">{label}</div>
|
{ icon: TrendingUp, label: isEn ? "Total Days" : "训练天数", value: debStats.totalDays.toLocaleString(), tone: "purple" },
|
||||||
<div className="font-mono text-lg font-black text-slate-900">{String(value)}</div>
|
].map(({ icon: Icon, label, value, tone }) => (
|
||||||
|
<div key={label} className={`flex items-center gap-3 rounded-lg border ${STAT_CARD_CLASSES[tone]} p-3`}>
|
||||||
|
<Icon size={20} className={STAT_ICON_CLASSES[tone]} />
|
||||||
|
<div>
|
||||||
|
<div className="text-[11px] font-bold uppercase text-slate-500">{label}</div>
|
||||||
|
<div className="font-mono text-lg font-black text-slate-900">{String(value)}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))}
|
||||||
))}
|
</div>
|
||||||
</div>
|
<div className="grid grid-cols-2 gap-3 mb-4">
|
||||||
)}
|
<ChartCard title={isEn ? "Forecast Hit Rate by City" : "预报命中率 by 城市"}>
|
||||||
|
|
||||||
{/* Distribution summary */}
|
|
||||||
{stats && (
|
|
||||||
<div className="grid grid-cols-3 gap-2 mb-4">
|
|
||||||
{[
|
|
||||||
{ label: isEn ? "High (≥65%)" : "高 (≥65%)", count: stats.high, color: "emerald" },
|
|
||||||
{ label: isEn ? "Mid (45-64%)" : "中 (45-64%)", count: stats.mid, color: "amber" },
|
|
||||||
{ label: isEn ? "Low (<45%)" : "低 (<45%)", count: stats.low, color: "red" },
|
|
||||||
].map(({ label, count, color }) => (
|
|
||||||
<div key={color} className={`rounded-lg border border-${color}-200 bg-${color}-50 p-3 text-center`}>
|
|
||||||
<div className={`font-mono text-2xl font-black text-${color}-600`}>{count}</div>
|
|
||||||
<div className="text-[10px] font-bold uppercase text-slate-500">{label}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Charts */}
|
|
||||||
{chartData.length > 0 && (
|
|
||||||
<div className="grid grid-cols-2 gap-3 mb-4">
|
|
||||||
{/* Hit rate bar chart */}
|
|
||||||
<div className="rounded-lg border border-slate-200 bg-white p-3">
|
|
||||||
<h2 className="mb-2 text-[11px] font-black uppercase text-slate-500">
|
|
||||||
{isEn ? "Hit Rate by City" : "各城市命中率"}
|
|
||||||
</h2>
|
|
||||||
<div className="h-72">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<BarChart data={chartData} layout="vertical" margin={{ top: 0, right: 16, left: 48, bottom: 0 }}>
|
<BarChart data={debHitChart} layout="vertical" margin={{ top: 0, right: 16, left: 48, bottom: 0 }}>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" horizontal={false} />
|
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" horizontal={false} />
|
||||||
<XAxis type="number" domain={[0, 100]} tick={{ fontSize: 10, fill: "#64748b" }} tickFormatter={(v) => `${v}%`} />
|
<XAxis type="number" domain={[0, 100]} tick={{ fontSize: 11, fill: "#64748b" }} tickFormatter={(v) => `${v}%`} />
|
||||||
<YAxis dataKey="name" type="category" tick={{ fontSize: 10, fill: "#334155" }} width={50} />
|
<YAxis dataKey="name" type="category" tick={{ fontSize: 11, fill: "#334155" }} width={52} />
|
||||||
<Tooltip
|
<Tooltip contentStyle={{ borderRadius: 6, border: "1px solid #e2e8f0", fontSize: 12 }} formatter={(v: unknown) => [`${Number(v)}%`, isEn ? "Hit Rate" : "命中率"]} />
|
||||||
contentStyle={{ borderRadius: 6, border: "1px solid #e2e8f0", fontSize: 12 }}
|
<Bar dataKey="value" radius={[0, 3, 3, 0]} fill="#2563eb" barSize={14} />
|
||||||
formatter={(value) => [`${Number(value)}%`, isEn ? "Hit Rate" : "命中率"]}
|
|
||||||
/>
|
|
||||||
<Bar dataKey="hit" radius={[0, 3, 3, 0]} fill="#2563eb" barSize={14} />
|
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</ChartCard>
|
||||||
</div>
|
<ChartCard title={isEn ? "Forecast Error by City (lower = better)" : "预报误差 by 城市(越低越好)"}>
|
||||||
|
|
||||||
{/* MAE bar chart (lower is better) */}
|
|
||||||
<div className="rounded-lg border border-slate-200 bg-white p-3">
|
|
||||||
<h2 className="mb-2 text-[11px] font-black uppercase text-slate-500">
|
|
||||||
{isEn ? "MAE by City (lower = better)" : "各城市 MAE (越低越好)"}
|
|
||||||
</h2>
|
|
||||||
<div className="h-72">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<BarChart data={maeChartData} layout="vertical" margin={{ top: 0, right: 16, left: 48, bottom: 0 }}>
|
<BarChart data={debMaeChart} layout="vertical" margin={{ top: 0, right: 16, left: 48, bottom: 0 }}>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" horizontal={false} />
|
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" horizontal={false} />
|
||||||
<XAxis type="number" tick={{ fontSize: 10, fill: "#64748b" }} tickFormatter={(v) => `${v}°`} />
|
<XAxis type="number" tick={{ fontSize: 11, fill: "#64748b" }} tickFormatter={(v) => `${v}°`} />
|
||||||
<YAxis dataKey="name" type="category" tick={{ fontSize: 10, fill: "#334155" }} width={50} />
|
<YAxis dataKey="name" type="category" tick={{ fontSize: 11, fill: "#334155" }} width={52} />
|
||||||
<Tooltip
|
<Tooltip contentStyle={{ borderRadius: 6, border: "1px solid #e2e8f0", fontSize: 12 }} formatter={(v: unknown) => [`${Number(v)}°`, isEn ? "Error" : "误差"]} />
|
||||||
contentStyle={{ borderRadius: 6, border: "1px solid #e2e8f0", fontSize: 12 }}
|
<Bar dataKey="value" radius={[0, 3, 3, 0]} fill="#7c3aed" barSize={14} />
|
||||||
formatter={(value) => [`${Number(value)}°`, "MAE"]}
|
|
||||||
/>
|
|
||||||
<Bar dataKey="mae" radius={[0, 3, 3, 0]} fill="#7c3aed" barSize={14} />
|
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</ChartCard>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Table */}
|
{/* ── Mu Section ── */}
|
||||||
|
{muStats && (
|
||||||
|
<>
|
||||||
|
<h2 className="text-sm font-black text-slate-800 flex items-center gap-1.5 mb-2">
|
||||||
|
<Crosshair size={14} className="text-emerald-600" />
|
||||||
|
{isEn ? "Probability Mu Calibration" : "概率 μ 校准"}
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-4 gap-2 mb-3">
|
||||||
|
{[
|
||||||
|
{ icon: Hash, label: isEn ? "Cities" : "城市数", value: muStats.cities, tone: "blue" },
|
||||||
|
{ icon: Target, label: isEn ? "Avg Hit" : "平均命中", value: `${muStats.avgHit.toFixed(1)}%`, tone: "emerald" },
|
||||||
|
{ icon: Thermometer, label: isEn ? "Avg Error" : "平均误差", value: `${muStats.avgMae.toFixed(2)}°`, tone: "amber" },
|
||||||
|
{ icon: Crosshair, label: isEn ? "Avg Brier" : "平均 Brier", value: muStats.avgBrier.toFixed(4), tone: "purple" },
|
||||||
|
].map(({ icon: Icon, label, value, tone }) => (
|
||||||
|
<div key={label} className={`flex items-center gap-3 rounded-lg border ${STAT_CARD_CLASSES[tone]} p-3`}>
|
||||||
|
<Icon size={20} className={STAT_ICON_CLASSES[tone]} />
|
||||||
|
<div>
|
||||||
|
<div className="text-[11px] font-bold uppercase text-slate-500">{label}</div>
|
||||||
|
<div className="font-mono text-lg font-black text-slate-900">{String(value)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3 mb-4">
|
||||||
|
<ChartCard title={isEn ? "Prob Hit Rate by City" : "概率命中率 by 城市"}>
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={muHitChart} layout="vertical" margin={{ top: 0, right: 16, left: 48, bottom: 0 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" horizontal={false} />
|
||||||
|
<XAxis type="number" domain={[0, 100]} tick={{ fontSize: 11, fill: "#64748b" }} tickFormatter={(v) => `${v}%`} />
|
||||||
|
<YAxis dataKey="name" type="category" tick={{ fontSize: 11, fill: "#334155" }} width={52} />
|
||||||
|
<Tooltip contentStyle={{ borderRadius: 6, border: "1px solid #e2e8f0", fontSize: 12 }} formatter={(v: unknown) => [`${Number(v)}%`, isEn ? "Hit Rate" : "命中率"]} />
|
||||||
|
<Bar dataKey="value" radius={[0, 3, 3, 0]} fill="#059669" barSize={14} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</ChartCard>
|
||||||
|
<ChartCard title={isEn ? "Brier Score by City (lower = better)" : "Brier 评分 by 城市(越低越好)"}>
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={muBrierChart} layout="vertical" margin={{ top: 0, right: 16, left: 48, bottom: 0 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" horizontal={false} />
|
||||||
|
<XAxis type="number" tick={{ fontSize: 11, fill: "#64748b" }} tickFormatter={(v) => `${Number(v).toFixed(2)}`} />
|
||||||
|
<YAxis dataKey="name" type="category" tick={{ fontSize: 11, fill: "#334155" }} width={52} />
|
||||||
|
<Tooltip contentStyle={{ borderRadius: 6, border: "1px solid #e2e8f0", fontSize: 12 }} formatter={(v: unknown) => [`${Number(v).toFixed(4)}`, "Brier"]} />
|
||||||
|
<Bar dataKey="value" radius={[0, 3, 3, 0]} fill="#d97706" barSize={14} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</ChartCard>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Combined Table ── */}
|
||||||
<div className="rounded-lg border border-slate-200 bg-white overflow-hidden">
|
<div className="rounded-lg border border-slate-200 bg-white overflow-hidden">
|
||||||
<table className="w-full text-[12px] border-collapse">
|
<table className="w-full text-[12px] border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-slate-200 bg-[#f8f9fa] text-left">
|
<tr className="border-b border-slate-200 bg-[#f8f9fa] text-left">
|
||||||
<th className="w-10 px-3 py-2 text-center text-[10px] font-black text-slate-400">#</th>
|
<th className="w-10 px-3 py-2 text-center text-[11px] font-black text-slate-400">#</th>
|
||||||
<th className="px-3 py-2 text-[10px] font-black uppercase text-slate-500">
|
<th className="px-3 py-2 text-[11px] font-black uppercase text-slate-500">{isEn ? "City" : "城市"}</th>
|
||||||
{isEn ? "City" : "城市"}
|
<th className="px-2 py-2 text-right text-[11px] font-black uppercase text-slate-500">{isEn ? "DEB Hit" : "DEB 命中"}</th>
|
||||||
</th>
|
<th className="px-2 py-2 text-right text-[11px] font-black uppercase text-slate-500">{isEn ? "DEB Error" : "DEB 误差"}</th>
|
||||||
<th className="px-3 py-2 text-right text-[10px] font-black uppercase text-slate-500">
|
<th className="px-2 py-2 text-right text-[11px] font-black uppercase text-slate-500">{isEn ? "μ Hit" : "μ 命中"}</th>
|
||||||
{isEn ? "Hit Rate" : "命中率"}
|
<th className="px-2 py-2 text-right text-[11px] font-black uppercase text-slate-500">Brier</th>
|
||||||
</th>
|
<th className="px-3 py-2 text-right text-[11px] font-black uppercase text-slate-500">{isEn ? "Days" : "天数"}</th>
|
||||||
<th className="px-3 py-2 text-right text-[10px] font-black uppercase text-slate-500">MAE</th>
|
|
||||||
<th className="px-3 py-2 text-right text-[10px] font-black uppercase text-slate-500">
|
|
||||||
{isEn ? "Days" : "训练天数"}
|
|
||||||
</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-100">
|
<tbody className="divide-y divide-slate-100">
|
||||||
{debSorted.length ? debSorted.map((c, i) => {
|
{debSorted.length || muSorted.length ? (
|
||||||
const hr = c.deb?.hit_rate ?? 0;
|
(() => {
|
||||||
const mae = c.deb?.mae ?? 0;
|
const cities = new Map<string, { deb?: MetricPayload; mu?: MetricPayload; name: string }>();
|
||||||
const color = hr >= 65 ? "emerald" : hr >= 45 ? "amber" : "red";
|
for (const c of debSorted) cities.set(c.city_id, { deb: c.deb, mu: c.mu, name: c.name });
|
||||||
return (
|
for (const c of muSorted) {
|
||||||
<tr key={c.city_id} className="hover:bg-slate-50 transition-colors">
|
const existing = cities.get(c.city_id);
|
||||||
<td className="px-3 py-2 text-center text-[10px] font-mono text-slate-400">{i + 1}</td>
|
if (existing) existing.mu = c.mu;
|
||||||
<td className="px-3 py-2 font-semibold text-slate-800 capitalize">{c.name}</td>
|
else cities.set(c.city_id, { deb: c.deb, mu: c.mu, name: c.name });
|
||||||
<td className="px-3 py-2 text-right">
|
}
|
||||||
<div className="flex items-center justify-end gap-2">
|
const merged = [...cities.entries()]
|
||||||
<div className="relative h-2 w-28 overflow-hidden rounded-full bg-slate-100">
|
.sort((a, b) => {
|
||||||
<div
|
const aMax = Math.max(a[1].deb?.hit_rate ?? 0, a[1].mu?.hit_rate ?? 0);
|
||||||
className={`absolute inset-y-0 left-0 rounded-full bg-${color}-500 transition-all`}
|
const bMax = Math.max(b[1].deb?.hit_rate ?? 0, b[1].mu?.hit_rate ?? 0);
|
||||||
style={{ width: `${Math.min(100, hr)}%` }}
|
return bMax - aMax;
|
||||||
/>
|
})
|
||||||
<div
|
.slice(0, 30);
|
||||||
className="absolute inset-y-0 left-0 rounded-full opacity-30"
|
return merged.map(([cityId, { deb, mu, name }], i) => {
|
||||||
style={{
|
const debHit = deb?.hit_rate ?? 0;
|
||||||
width: `${Math.min(100, hr)}%`,
|
const muHit = mu?.hit_rate ?? 0;
|
||||||
background: `repeating-linear-gradient(90deg, transparent, transparent 3px, rgba(255,255,255,0.3) 3px, rgba(255,255,255,0.3) 6px)`,
|
const brier = mu?.brier_score;
|
||||||
}}
|
return (
|
||||||
/>
|
<tr key={cityId} className="hover:bg-slate-50 transition-colors">
|
||||||
</div>
|
<td className="px-3 py-2 text-center text-[12px] font-mono text-slate-400">{i + 1}</td>
|
||||||
<span className={`w-10 text-right font-mono text-[11px] font-bold text-${color}-600`}>
|
<td className="px-3 py-2 font-semibold text-slate-800 capitalize">{name}</td>
|
||||||
{hr.toFixed(0)}%
|
<td className="px-2 py-2 text-right font-mono font-bold" style={{ color: barColor(debHit) }}>
|
||||||
</span>
|
{deb ? `${debHit.toFixed(0)}%` : "--"}
|
||||||
</div>
|
</td>
|
||||||
</td>
|
<td className="px-2 py-2 text-right font-mono text-slate-600">{deb ? `${deb.mae.toFixed(1)}°` : "--"}</td>
|
||||||
<td className="px-3 py-2 text-right font-mono text-slate-600">{mae.toFixed(1)}°</td>
|
<td className="px-2 py-2 text-right font-mono font-bold" style={{ color: barColor(muHit) }}>
|
||||||
<td className="px-3 py-2 text-right text-slate-400">{c.deb?.total_days ?? 0}</td>
|
{mu ? `${muHit.toFixed(0)}%` : "--"}
|
||||||
</tr>
|
</td>
|
||||||
);
|
<td className="px-2 py-2 text-right font-mono text-slate-600">{brier != null ? brier.toFixed(4) : "--"}</td>
|
||||||
}) : (
|
<td className="px-3 py-2 text-right text-slate-400">{(deb?.total_days ?? 0) + (mu?.total_days ?? 0)}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
})()
|
||||||
|
) : (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={5} className="px-4 py-12 text-center text-slate-400">
|
<td colSpan={7} className="px-4 py-12 text-center text-slate-400">
|
||||||
{data === null
|
{data === null ? (isEn ? "Loading..." : "加载中...") : (isEn ? "No training data" : "暂无训练数据")}
|
||||||
? (isEn ? "Loading training data..." : "加载训练数据中...")
|
|
||||||
: (isEn ? "No training data available" : "暂无训练数据")}
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="mt-3 text-center text-[11px] text-slate-400">
|
||||||
{/* Footer note */}
|
|
||||||
<p className="mt-3 text-center text-[10px] text-slate-400">
|
|
||||||
{isEn
|
{isEn
|
||||||
? "Training data updated daily. Hit rate = DEB prediction within settlement acceptance range."
|
? "DEB = temperature forecast accuracy. μ = probability calibration. Brier = lower is better. Updated daily."
|
||||||
: "训练数据每日更新。命中率 = DEB 预报落入结算接受窗口的比例。"}
|
: "DEB = 气温预报准确率。μ = 概率校准。Brier = 越低越好。每日更新。"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ChartCard({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-slate-200 bg-white p-3">
|
||||||
|
<h3 className="mb-2 text-[12px] font-black uppercase text-slate-500">{title}</h3>
|
||||||
|
<div className="h-72">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,104 @@ export const TRADING_REGIONS = [
|
|||||||
|
|
||||||
export type TradingRegionKey = (typeof TRADING_REGIONS)[number]["key"];
|
export type TradingRegionKey = (typeof TRADING_REGIONS)[number]["key"];
|
||||||
|
|
||||||
|
const TRADING_REGION_KEYS = new Set<string>(TRADING_REGIONS.map((region) => region.key));
|
||||||
|
|
||||||
|
const CITY_REGION_FALLBACK: Record<string, TradingRegionKey> = {
|
||||||
|
beijing: "east_asia",
|
||||||
|
busan: "east_asia",
|
||||||
|
chengdu: "east_asia",
|
||||||
|
chongqing: "east_asia",
|
||||||
|
guangzhou: "east_asia",
|
||||||
|
"hong kong": "east_asia",
|
||||||
|
"lau fau shan": "east_asia",
|
||||||
|
qingdao: "east_asia",
|
||||||
|
seoul: "east_asia",
|
||||||
|
shanghai: "east_asia",
|
||||||
|
shenzhen: "east_asia",
|
||||||
|
taipei: "east_asia",
|
||||||
|
tokyo: "east_asia",
|
||||||
|
wuhan: "east_asia",
|
||||||
|
jakarta: "southeast_asia",
|
||||||
|
"kuala lumpur": "southeast_asia",
|
||||||
|
manila: "southeast_asia",
|
||||||
|
singapore: "southeast_asia",
|
||||||
|
karachi: "central_asia",
|
||||||
|
lucknow: "central_asia",
|
||||||
|
ankara: "west_asia",
|
||||||
|
istanbul: "west_asia",
|
||||||
|
jeddah: "west_asia",
|
||||||
|
"tel aviv": "west_asia",
|
||||||
|
amsterdam: "europe_africa",
|
||||||
|
"cape town": "europe_africa",
|
||||||
|
helsinki: "europe_africa",
|
||||||
|
london: "europe_africa",
|
||||||
|
madrid: "europe_africa",
|
||||||
|
milan: "europe_africa",
|
||||||
|
moscow: "europe_africa",
|
||||||
|
munich: "europe_africa",
|
||||||
|
paris: "europe_africa",
|
||||||
|
warsaw: "europe_africa",
|
||||||
|
"buenos aires": "south_america",
|
||||||
|
"sao paulo": "south_america",
|
||||||
|
"mexico city": "north_america",
|
||||||
|
atlanta: "north_america",
|
||||||
|
austin: "north_america",
|
||||||
|
chicago: "north_america",
|
||||||
|
dallas: "north_america",
|
||||||
|
denver: "north_america",
|
||||||
|
houston: "north_america",
|
||||||
|
"los angeles": "north_america",
|
||||||
|
miami: "north_america",
|
||||||
|
"new york": "north_america",
|
||||||
|
"panama city": "north_america",
|
||||||
|
"san francisco": "north_america",
|
||||||
|
seattle: "north_america",
|
||||||
|
toronto: "north_america",
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeRegionValue(value?: string | null) {
|
||||||
|
return String(value || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[-\s]+/g, "_");
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCityValue(value?: string | null) {
|
||||||
|
return String(value || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[_-]+/g, " ")
|
||||||
|
.replace(/\s+/g, " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function finiteNumber(value: unknown) {
|
||||||
|
const numeric = Number(value);
|
||||||
|
return Number.isFinite(numeric) ? numeric : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveTradingRegionKey(row: ScanOpportunityRow): TradingRegionKey | null {
|
||||||
|
const direct = normalizeRegionValue(row.trading_region);
|
||||||
|
if (TRADING_REGION_KEYS.has(direct)) return direct as TradingRegionKey;
|
||||||
|
|
||||||
|
const cityKey = normalizeCityValue(row.city || row.city_display_name || row.display_name);
|
||||||
|
const cityRegion = CITY_REGION_FALLBACK[cityKey];
|
||||||
|
if (cityRegion) return cityRegion;
|
||||||
|
|
||||||
|
const offset = finiteNumber(row.tz_offset_seconds);
|
||||||
|
if (offset !== null) {
|
||||||
|
const hours = offset / 3600;
|
||||||
|
if (hours >= 8) return "east_asia";
|
||||||
|
if (hours >= 7) return "southeast_asia";
|
||||||
|
if (hours >= 5) return "central_asia";
|
||||||
|
if (hours >= 3) return "west_asia";
|
||||||
|
if (hours >= 0) return "europe_africa";
|
||||||
|
if (hours >= -5) return "south_america";
|
||||||
|
return "north_america";
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ContinentGroup {
|
export interface ContinentGroup {
|
||||||
key: TradingRegionKey | "active_signals";
|
key: TradingRegionKey | "active_signals";
|
||||||
labelEn: string;
|
labelEn: string;
|
||||||
@@ -117,7 +215,7 @@ export function buildContinentGroups(rows: ScanOpportunityRow[], isEn: boolean):
|
|||||||
const regionMap = new Map<string, ScanOpportunityRow[]>();
|
const regionMap = new Map<string, ScanOpportunityRow[]>();
|
||||||
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const region = String(row.trading_region || "unknown").toLowerCase();
|
const region = resolveTradingRegionKey(row) || "unknown";
|
||||||
if (!regionMap.has(region)) regionMap.set(region, []);
|
if (!regionMap.has(region)) regionMap.set(region, []);
|
||||||
regionMap.get(region)!.push(row);
|
regionMap.get(region)!.push(row);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -937,6 +937,8 @@ export interface CityDetail {
|
|||||||
market_scan?: MarketScan;
|
market_scan?: MarketScan;
|
||||||
intraday_meteorology?: IntradayMeteorology;
|
intraday_meteorology?: IntradayMeteorology;
|
||||||
amos?: AmosData | null;
|
amos?: AmosData | null;
|
||||||
|
top_buckets?: MarketTopBucket[] | null;
|
||||||
|
all_buckets?: MarketTopBucket[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AmosData {
|
export interface AmosData {
|
||||||
|
|||||||
@@ -127,6 +127,8 @@ def _build_terminal_row(
|
|||||||
"final_score": final_score,
|
"final_score": final_score,
|
||||||
"volume": volume,
|
"volume": volume,
|
||||||
"amos": data.get("amos") or None,
|
"amos": data.get("amos") or None,
|
||||||
|
"top_buckets": scan.get("top_buckets") or [],
|
||||||
|
"all_buckets": scan.get("all_buckets") or [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -247,7 +247,6 @@ def _call_deepseek_scan_ai(ai_input: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
"多个天气模型预测值 model_cluster.sources、METAR 实测序列、机场原始报文和候选合约。"
|
"多个天气模型预测值 model_cluster.sources、METAR 实测序列、机场原始报文和候选合约。"
|
||||||
"你的首要任务不是分析套利,也不是推荐 BUY YES/NO,而是预测该城市今日最终最高温是多少。"
|
"你的首要任务不是分析套利,也不是推荐 BUY YES/NO,而是预测该城市今日最终最高温是多少。"
|
||||||
"必须输出城市级最高温点估计、置信区间、置信度、峰值窗口状态、机场报文解读和一句预测理由。"
|
"必须输出城市级最高温点估计、置信区间、置信度、峰值窗口状态、机场报文解读和一句预测理由。"
|
||||||
"V4 禁止使用 EMOS、EMOS peak、EMOS probability、edge 或 Kelly 作为交易依据;"
|
|
||||||
"最高温预测必须直接参考该城市全部 model_cluster.sources、DEB、峰值窗口和 METAR/机场报文。"
|
"最高温预测必须直接参考该城市全部 model_cluster.sources、DEB、峰值窗口和 METAR/机场报文。"
|
||||||
"如果天气模型之间分歧大,必须放宽置信区间并降低 confidence;如果 METAR 与模型路径冲突,必须解释修正方向。"
|
"如果天气模型之间分歧大,必须放宽置信区间并降低 confidence;如果 METAR 与模型路径冲突,必须解释修正方向。"
|
||||||
"必须先判断 peak_window_label、minutes_until_peak_start/end 和 window_phase:峰值窗口尚未到来时,"
|
"必须先判断 peak_window_label、minutes_until_peak_start/end 和 window_phase:峰值窗口尚未到来时,"
|
||||||
@@ -267,7 +266,6 @@ def _call_deepseek_scan_ai(ai_input: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
"contract_notes items are optional and require row_id, forecast_match, reason_zh, reason_en; "
|
"contract_notes items are optional and require row_id, forecast_match, reason_zh, reason_en; "
|
||||||
"forecast_match must be one of core, edge, outside, watch. "
|
"forecast_match must be one of core, edge, outside, watch. "
|
||||||
"Focus on final max temperature prediction; do not output recommendations/vetoed/downgraded unless needed for backward compatibility. "
|
"Focus on final max temperature prediction; do not output recommendations/vetoed/downgraded unless needed for backward compatibility. "
|
||||||
"Do not mention EMOS, edge, Kelly, arbitrage, position size, or trading recommendation. "
|
|
||||||
"Keep every city forecast concise: one sentence for METAR read and one sentence for reasoning."
|
"Keep every city forecast concise: one sentence for METAR read and one sentence for reasoning."
|
||||||
),
|
),
|
||||||
"snapshot": model_snapshot,
|
"snapshot": model_snapshot,
|
||||||
|
|||||||
Reference in New Issue
Block a user