术语统一:合约/市场/交易 → 阈值/信号/实况路径/多模型预测
This commit is contained in:
@@ -51,6 +51,21 @@ function pairKey(pair: [string, string]) {
|
||||
return pair.map(normalizeRunwayLabel).sort().join("/");
|
||||
}
|
||||
|
||||
function runwaySeriesKey(rwy: string) {
|
||||
return `runway_${String(rwy || "unknown")
|
||||
.split("/")
|
||||
.map(normalizeRunwayLabel)
|
||||
.filter(Boolean)
|
||||
.join("_")}`;
|
||||
}
|
||||
|
||||
function isTemperatureSeriesVisibleByDefault(city: string, seriesKey: string) {
|
||||
if (seriesKey.startsWith("model_curve_")) {
|
||||
return normalizeCityKey(city) === "paris" && seriesKey === "model_curve_AROME HD";
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildRunwayPlates(
|
||||
amos: AmosData | null | undefined,
|
||||
row: ScanOpportunityRow | null,
|
||||
@@ -289,6 +304,8 @@ type HourlyForecast = {
|
||||
airportPrimary?: AirportCurrentConditions | null;
|
||||
forecastDaily?: ForecastDay[];
|
||||
multiModelDaily?: Record<string, DailyModelForecast>;
|
||||
settlementTodayObs?: ObsPoint[];
|
||||
metarTodayObs?: ObsPoint[];
|
||||
} | null;
|
||||
|
||||
function parseRunwayHistoryValue(point: Record<string, unknown>) {
|
||||
@@ -335,7 +352,7 @@ function buildRunwayHistorySeries(
|
||||
.slice(-MAX_OBS_POINTS);
|
||||
const isSettlement = isSettlementRunway(row, normalizedRwy);
|
||||
return {
|
||||
key: `runway_${index}`,
|
||||
key: runwaySeriesKey(normalizedRwy),
|
||||
label: `${normalizedRwy}${isSettlement ? (row ? " 结算跑道" : " Settlement") : ""}`,
|
||||
rwy: normalizedRwy,
|
||||
isSettlement,
|
||||
@@ -374,7 +391,7 @@ function buildRunwayHistorySeries(
|
||||
.filter((point): point is { ts: number; value: number } => point !== null);
|
||||
if (values.length <= 1) return null;
|
||||
return {
|
||||
key: `runway_${index}`,
|
||||
key: runwaySeriesKey(rwy),
|
||||
label: `${rwy}${isSettlement ? " 结算跑道" : ""}`,
|
||||
rwy,
|
||||
isSettlement,
|
||||
@@ -605,8 +622,8 @@ function buildFullDayChartData(
|
||||
const tzOffset = row?.tz_offset_seconds ?? 0;
|
||||
const localDateStr = row?.local_date || new Date().toISOString().slice(0, 10);
|
||||
|
||||
const settlementObs = normObs(row?.settlement_today_obs || row?.metar_context?.settlement_today_obs, tzOffset);
|
||||
const metarObs = normObs(row?.metar_today_obs || row?.metar_context?.today_obs || row?.metar_recent_obs || row?.metar_context?.recent_obs, tzOffset);
|
||||
const settlementObs = normObs(hourly?.settlementTodayObs || row?.settlement_today_obs || row?.metar_context?.settlement_today_obs, tzOffset);
|
||||
const metarObs = normObs(hourly?.metarTodayObs || row?.metar_today_obs || row?.metar_context?.today_obs || row?.metar_recent_obs || row?.metar_context?.recent_obs, tzOffset);
|
||||
const runwayHistorySeries = buildRunwayHistorySeries(row, hourly, tzOffset, localDateStr);
|
||||
|
||||
const slots = generateFullDaySlots(localDateStr);
|
||||
@@ -633,7 +650,7 @@ function buildFullDayChartData(
|
||||
});
|
||||
|
||||
// ── Settlement observations ──
|
||||
if (!runwayHistorySeries.length && settlementObs.length) {
|
||||
if (settlementObs.length) {
|
||||
const svals = binObservationsToSlots(slots, settlementObs);
|
||||
if (svals.some((v) => v !== null)) {
|
||||
series.push({
|
||||
@@ -653,7 +670,7 @@ function buildFullDayChartData(
|
||||
if (mvals.some((v) => v !== null)) {
|
||||
series.push({
|
||||
key: "metar",
|
||||
label: "METAR",
|
||||
label: row?.metar_context?.station_label || "METAR",
|
||||
source: row?.airport || "METAR",
|
||||
color: "#0ea5e9",
|
||||
dashed: true,
|
||||
@@ -888,6 +905,8 @@ export function LiveTemperatureThresholdChart({
|
||||
airportPrimary: json.airport_primary || null,
|
||||
forecastDaily: json.forecast?.daily || [],
|
||||
multiModelDaily: json.multi_model_daily || {},
|
||||
settlementTodayObs: (json as any).timeseries?.settlement_today_obs || (json as any)?.settlement_today_obs || undefined,
|
||||
metarTodayObs: (json as any).timeseries?.metar_today_obs || (json as any)?.metar_today_obs || undefined,
|
||||
};
|
||||
_hourlyCache.set(city, { ts: Date.now(), data });
|
||||
setHourly(data);
|
||||
@@ -905,8 +924,15 @@ export function LiveTemperatureThresholdChart({
|
||||
|
||||
const tzOffset = row?.tz_offset_seconds ?? 0;
|
||||
const settlementObs = useMemo(() => {
|
||||
return normObs(row?.settlement_today_obs || row?.metar_context?.settlement_today_obs, tzOffset);
|
||||
}, [row, tzOffset]);
|
||||
let obs = normObs(hourly?.settlementTodayObs || row?.settlement_today_obs || row?.metar_context?.settlement_today_obs, tzOffset);
|
||||
if (!obs.length && !hourly?.runwayPlateHistory) {
|
||||
const mObs = normObs(hourly?.metarTodayObs || row?.metar_today_obs || row?.metar_context?.today_obs, tzOffset);
|
||||
if (mObs.length > 0) {
|
||||
obs = mObs;
|
||||
}
|
||||
}
|
||||
return obs;
|
||||
}, [row, hourly, tzOffset]);
|
||||
|
||||
const runwayPlates = useMemo(() => buildRunwayPlates(hourly?.amos, row, settlementObs), [hourly?.amos, row, settlementObs]);
|
||||
const hasRunwayData = runwayPlates.length > 0;
|
||||
@@ -920,13 +946,7 @@ export function LiveTemperatureThresholdChart({
|
||||
if (userToggledKeys[sKey] !== undefined) {
|
||||
return userToggledKeys[sKey];
|
||||
}
|
||||
if (sKey.startsWith("model_curve_")) {
|
||||
if (city === "paris" && sKey === "model_curve_AROME HD") {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
return isTemperatureSeriesVisibleByDefault(city, sKey);
|
||||
};
|
||||
|
||||
const activeSeries = useMemo(() => {
|
||||
@@ -1436,3 +1456,13 @@ export function LiveTemperatureThresholdChart({
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
export function __buildTemperatureChartDataForTest(
|
||||
row: ScanOpportunityRow | null,
|
||||
hourly: HourlyForecast,
|
||||
timeframe: "1D" | "3D" = "1D",
|
||||
) {
|
||||
return timeframe === "3D" ? build3DayChartData(row, hourly) : buildFullDayChartData(row, hourly);
|
||||
}
|
||||
|
||||
export const __isTemperatureSeriesVisibleByDefaultForTest = isTemperatureSeriesVisibleByDefault;
|
||||
|
||||
@@ -22,13 +22,13 @@ function SubscriptionGate({ isEn }: { isEn: boolean }) {
|
||||
"Real-time METAR observations across 500+ stations",
|
||||
"DEB forecast blends with 0–240h horizon",
|
||||
"AI decision cards with Poly-score ranking",
|
||||
"Historical backtesting & weather market signals",
|
||||
"Historical backtesting & weather signals",
|
||||
]
|
||||
: [
|
||||
"500+ 气象站实时 METAR 实况",
|
||||
"DEB 智能融合预测(0–240 小时)",
|
||||
"AI 决策卡片 + Poly-score 排名",
|
||||
"历史回测与天气市场交易信号",
|
||||
"历史回测与天气信号",
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -46,7 +46,7 @@ function SubscriptionGate({ isEn }: { isEn: boolean }) {
|
||||
<h1 className="text-xl font-black tracking-tight">
|
||||
{isEn
|
||||
? "Unlock the Weather Terminal"
|
||||
: "解锁天气交易决策台"}
|
||||
: "解锁天气决策台"}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-blue-100">
|
||||
{isEn
|
||||
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
import {
|
||||
__buildTemperatureChartDataForTest,
|
||||
__isTemperatureSeriesVisibleByDefaultForTest,
|
||||
} from "@/components/dashboard/scan-terminal/LiveTemperatureThresholdChart";
|
||||
|
||||
function assert(condition: unknown, message: string) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
function seriesByKey(series: Array<{ key: string }>, key: string) {
|
||||
return series.find((item) => item.key === key);
|
||||
}
|
||||
|
||||
export function runTests() {
|
||||
const guangzhou = {
|
||||
city: "guangzhou",
|
||||
local_date: "2026-05-25",
|
||||
local_time: "10:00",
|
||||
tz_offset_seconds: 8 * 60 * 60,
|
||||
airport: "ZGGG",
|
||||
deb_prediction: 31,
|
||||
runway_plate_history: {
|
||||
"02L/20R": [
|
||||
{ time: "00:05", temp: 29.1 },
|
||||
{ time: "00:35", temp: 29.3 },
|
||||
],
|
||||
"01L/19R": [
|
||||
{ time: "00:05", temp: 28.7 },
|
||||
{ time: "00:35", temp: 28.9 },
|
||||
],
|
||||
},
|
||||
settlement_today_obs: [
|
||||
{ time: "00:05", temp: 29.0 },
|
||||
{ time: "00:35", temp: 29.2 },
|
||||
],
|
||||
metar_today_obs: [
|
||||
{ time: "00:05", temp: 28.0 },
|
||||
{ time: "00:35", temp: 28.5 },
|
||||
],
|
||||
} as any;
|
||||
|
||||
const hourly = {
|
||||
localTime: "10:00",
|
||||
times: ["00:00", "00:30"],
|
||||
temps: [29, 30],
|
||||
modelCurves: {
|
||||
ECMWF: [30.1, 30.2],
|
||||
GFS: [29.7, 29.9],
|
||||
},
|
||||
} as any;
|
||||
|
||||
const { series } = __buildTemperatureChartDataForTest(guangzhou, hourly, "1D");
|
||||
|
||||
const settlementRunway = seriesByKey(series, "runway_02L_20R") as any;
|
||||
assert(settlementRunway, "settlement runway should use a stable runway-pair key");
|
||||
assert(settlementRunway.label.includes("结算跑道"), "settlement runway should be labeled as settlement runway");
|
||||
assert(settlementRunway.color === "#009688", "settlement runway should use the highlight cyan color");
|
||||
assert(settlementRunway.featured === true, "settlement runway should be featured");
|
||||
assert(!settlementRunway.dashed, "settlement runway should be solid");
|
||||
|
||||
const auxiliaryRunway = seriesByKey(series, "runway_01L_19R") as any;
|
||||
assert(auxiliaryRunway, "auxiliary runway should be displayed by default in the chart data");
|
||||
assert(auxiliaryRunway.dashed === true, "auxiliary runway should be dashed");
|
||||
assert(auxiliaryRunway.featured !== true, "auxiliary runway should not be featured");
|
||||
|
||||
assert(seriesByKey(series, "settlement"), "settlement/HKO observation series should still be present when runway data exists");
|
||||
assert(seriesByKey(series, "metar"), "METAR observation series should be present by its own key");
|
||||
|
||||
assert(
|
||||
__isTemperatureSeriesVisibleByDefaultForTest("guangzhou", "runway_02L_20R"),
|
||||
"runway series should be visible by default",
|
||||
);
|
||||
assert(
|
||||
__isTemperatureSeriesVisibleByDefaultForTest("guangzhou", "settlement"),
|
||||
"settlement/HKO observations should be visible by default",
|
||||
);
|
||||
assert(
|
||||
__isTemperatureSeriesVisibleByDefaultForTest("guangzhou", "metar"),
|
||||
"METAR observations should be visible by default",
|
||||
);
|
||||
assert(
|
||||
!__isTemperatureSeriesVisibleByDefaultForTest("guangzhou", "model_curve_ECMWF"),
|
||||
"multi-model curves should be hidden by default",
|
||||
);
|
||||
assert(
|
||||
__isTemperatureSeriesVisibleByDefaultForTest("paris", "model_curve_AROME HD"),
|
||||
"Paris AROME HD should be the only default-visible model curve exception",
|
||||
);
|
||||
|
||||
const shenzhen = __buildTemperatureChartDataForTest(
|
||||
{
|
||||
city: "shenzhen",
|
||||
local_date: "2026-05-25",
|
||||
local_time: "10:00",
|
||||
tz_offset_seconds: 8 * 60 * 60,
|
||||
metar_context: {
|
||||
station: "Lau Fau Shan",
|
||||
station_label: "HKO Lau Fau Shan",
|
||||
today_obs: [
|
||||
{ time: "00:05", temp: 28.4 },
|
||||
{ time: "00:35", temp: 28.5 },
|
||||
],
|
||||
},
|
||||
} as any,
|
||||
null,
|
||||
"1D",
|
||||
);
|
||||
assert(seriesByKey(shenzhen.series, "metar"), "Shenzhen/Lau Fau Shan observations should stay as METAR/HKO observations, not runway data");
|
||||
assert(!shenzhen.series.some((item) => item.key.startsWith("runway_")), "Shenzhen should not be treated as an AMSC runway city");
|
||||
}
|
||||
@@ -466,8 +466,8 @@ export function buildMarketDecisionView({
|
||||
? "Quote is available, but model probability or YES price is incomplete."
|
||||
: "已获取报价,但模型概率或 YES 价格不完整。"
|
||||
: isEn
|
||||
? `Model probability is ${formatMarketPercent(modelProbability)} versus market-implied ${formatMarketPercent(implied)}.${bucketMappingExplanation ? ` ${bucketMappingExplanation}` : ""}`
|
||||
: `模型概率 ${formatMarketPercent(modelProbability)},市场隐含约 ${formatMarketPercent(implied)}。${bucketMappingExplanation ? ` ${bucketMappingExplanation}` : ""}`,
|
||||
? `Model probability is ${formatMarketPercent(modelProbability)} versus signal-implied ${formatMarketPercent(implied)}.${bucketMappingExplanation ? ` ${bucketMappingExplanation}` : ""}`
|
||||
: `模型概率 ${formatMarketPercent(modelProbability)},信号隐含约 ${formatMarketPercent(implied)}。${bucketMappingExplanation ? ` ${bucketMappingExplanation}` : ""}`,
|
||||
status: "ready",
|
||||
title,
|
||||
tone,
|
||||
|
||||
Reference in New Issue
Block a user