From 7f0506b89708ffbd6cfd324992c13e5805eee087 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Mon, 25 May 2026 20:26:26 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B8=85=E7=90=86=E6=AE=8B=E7=95=99=20skip=5Fp?= =?UTF-8?q?olymarket=20=E5=8F=82=E6=95=B0=E5=92=8C=20PM=20=E6=B3=A8?= =?UTF-8?q?=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/app/api/scan/terminal/route.ts | 1 - .../LiveTemperatureThresholdChart.tsx | 525 ++++++++++++++---- .../scan-terminal/scan-terminal-client.ts | 1 - web/routers/scan.py | 2 - web/scan_terminal_filters.py | 1 - web/services/scan_api.py | 2 - 6 files changed, 429 insertions(+), 103 deletions(-) diff --git a/frontend/app/api/scan/terminal/route.ts b/frontend/app/api/scan/terminal/route.ts index 2d6ec539..f6c08ced 100644 --- a/frontend/app/api/scan/terminal/route.ts +++ b/frontend/app/api/scan/terminal/route.ts @@ -30,7 +30,6 @@ export async function GET(req: NextRequest) { "time_range", "limit", "force_refresh", - "skip_polymarket", "timezone_offset_seconds", ]) { const value = req.nextUrl.searchParams.get(key); diff --git a/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx b/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx index c31fd5be..8abacdca 100644 --- a/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx +++ b/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx @@ -287,6 +287,8 @@ type HourlyForecast = { amos?: AmosData | null; airportCurrent?: AirportCurrentConditions | null; airportPrimary?: AirportCurrentConditions | null; + forecastDaily?: ForecastDay[]; + multiModelDaily?: Record; } | null; function parseRunwayHistoryValue(point: Record) { @@ -383,6 +385,219 @@ function buildRunwayHistorySeries( .filter((series): series is RunwayHistorySeries => series !== null); } +function generate3DaySlots(localDateStr: string): number[] { + const parts = localDateStr.split("-"); + if (parts.length !== 3) return []; + const year = parseInt(parts[0], 10); + const month = parseInt(parts[1], 10) - 1; + const day = parseInt(parts[2], 10); + + const slots: number[] = []; + // Generate 72 hours starting from local date 00:00 + for (let h = 0; h < 72; h++) { + slots.push(Date.UTC(year, month, day, h, 0)); + } + return slots; +} + +function format3DayTimestamp(ts: number): string { + const d = new Date(ts); + const mm = String(d.getUTCMonth() + 1).padStart(2, "0"); + const dd = String(d.getUTCDate()).padStart(2, "0"); + const hh = String(d.getUTCHours()).padStart(2, "0"); + return `${mm}/${dd} ${hh}:00`; +} + +function build3DayChartData( + row: ScanOpportunityRow | null, + hourly: HourlyForecast, +): { data: Array>; series: EvidenceSeries[] } { + const tzOffset = row?.tz_offset_seconds ?? 0; + const localDateStr = row?.local_date || new Date().toISOString().slice(0, 10); + + const slots = generate3DaySlots(localDateStr); + if (!slots.length) return { data: [], series: [] }; + const n = slots.length; + + const series: EvidenceSeries[] = []; + const na = (): Array => new Array(n).fill(null); + + // DEB forecast curve (from hourly.times & hourly.temps) + if (hourly?.times?.length && hourly?.temps?.length) { + const debVals = na(); + hourly.times.forEach((t, i) => { + const ts = getCityLocalUtcTimestamp(t, tzOffset, localDateStr); + if (ts === null) return; + const slotIdx = slots.findIndex((s) => s === ts); + if (slotIdx >= 0) { + debVals[slotIdx] = validNumber(hourly.temps[i]); + } + }); + if (debVals.some((v) => v !== null)) { + series.push({ + key: "hourly_forecast", + label: "DEB Forecast", + source: "DEB Hourly", + color: "#f97316", + featured: true, + smooth: true, + values: debVals, + }); + } + + // Per-model curves + if (hourly.modelCurves) { + const modelColors = ["#2563eb", "#7c3aed", "#059669", "#d97706", "#dc2626", "#0891b2"]; + Object.keys(hourly.modelCurves).forEach((model, idx) => { + const modelTemps = hourly.modelCurves![model]; + if (!modelTemps?.length) return; + const vals = na(); + hourly.times.forEach((t, i) => { + const ts = getCityLocalUtcTimestamp(t, tzOffset, localDateStr); + if (ts === null) return; + const slotIdx = slots.findIndex((s) => s === ts); + if (slotIdx >= 0 && i < modelTemps.length) { + vals[slotIdx] = validNumber(modelTemps[i]); + } + }); + if (vals.some((v) => v !== null)) { + series.push({ + key: `model_curve_${model}`, + label: model, + source: "Multi-model hourly", + color: modelColors[idx % modelColors.length], + dashed: true, + smooth: true, + values: vals, + }); + } + }); + } + } + + // Historical METAR observations (past timestamps of the 3 days) + const metarObs = normObs( + row?.metar_today_obs || row?.metar_context?.today_obs || row?.metar_recent_obs || row?.metar_context?.recent_obs, + tzOffset + ); + if (metarObs.length) { + const mvals = binObservationsToSlots(slots, metarObs); + if (mvals.some((v) => v !== null)) { + series.push({ + key: "metar", + label: "METAR", + source: row?.airport || "METAR", + color: "#0ea5e9", + dashed: true, + values: mvals, + }); + } + } + + // Build data rows + const data = slots.map((ts, i) => { + const point: Record = { + label: format3DayTimestamp(ts), + ts, + }; + series.forEach((s) => { + point[s.key] = s.values[i] ?? null; + }); + return point; + }); + + return { data, series }; +} + +function generateDailySlots(localDateStr: string, daysCount: number): string[] { + const parts = localDateStr.split("-"); + if (parts.length !== 3) return []; + const year = parseInt(parts[0], 10); + const month = parseInt(parts[1], 10) - 1; + const day = parseInt(parts[2], 10); + + const dates: string[] = []; + for (let i = 0; i < daysCount; i++) { + const d = new Date(Date.UTC(year, month, day + i)); + const yyyy = d.getUTCFullYear(); + const mm = String(d.getUTCMonth() + 1).padStart(2, "0"); + const dd = String(d.getUTCDate()).padStart(2, "0"); + dates.push(`${yyyy}-${mm}-${dd}`); + } + return dates; +} + +function formatDailyDateLabel(dateStr: string): string { + const parts = dateStr.split("-"); + if (parts.length !== 3) return dateStr; + return `${parts[1]}/${parts[2]}`; +} + +function buildDailyChartData( + row: ScanOpportunityRow | null, + hourly: HourlyForecast, + daysCount: number, +): { data: Array>; series: EvidenceSeries[] } { + const localDateStr = row?.local_date || new Date().toISOString().slice(0, 10); + const slots = generateDailySlots(localDateStr, daysCount); + + const series: EvidenceSeries[] = [ + { + key: "deb_prediction", + label: "DEB Daily Max", + source: "DEB", + color: "#f97316", // orange + featured: true, + values: [], + }, + { + key: "max_temp", + label: "Model Daily Max", + source: "Standard Forecast", + color: "#dc2626", // red + dashed: true, + values: [], + }, + { + key: "min_temp", + label: "Model Daily Min", + source: "Standard Forecast", + color: "#2563eb", // blue + dashed: true, + values: [], + }, + ]; + + const data = slots.map((dateStr) => { + const dayForecast = hourly?.forecastDaily?.find((d) => d.date === dateStr); + const dayMultiModel = hourly?.multiModelDaily?.[dateStr]; + + const label = formatDailyDateLabel(dateStr); + + const debMax = validNumber(dayMultiModel?.deb?.prediction) ?? (dateStr === localDateStr ? validNumber(row?.deb_prediction) : null); + const maxTemp = validNumber(dayForecast?.max_temp); + const minTemp = validNumber(dayForecast?.min_temp); + + return { + label, + date: dateStr, + deb_prediction: debMax, + max_temp: maxTemp, + min_temp: minTemp, + }; + }); + + // Populate series values + series[0].values = data.map((d) => d.deb_prediction); + series[1].values = data.map((d) => d.max_temp); + series[2].values = data.map((d) => d.min_temp); + + // Filter out series that have no valid data points + const activeSeries = series.filter((s) => s.values.some((v) => v !== null)); + + return { data, series: activeSeries }; +} + function buildFullDayChartData( row: ScanOpportunityRow | null, hourly: HourlyForecast, @@ -616,13 +831,21 @@ export function LiveTemperatureThresholdChart({ isEn, row, allRows = [], + compact = false, }: { isEn: boolean; row: ScanOpportunityRow | null; allRows?: ScanOpportunityRow[]; + compact?: boolean; }) { const [hourly, setHourly] = useState(null); const city = String(row?.city || "").toLowerCase().trim(); + const [timeframe, setTimeframe] = useState<"1D" | "3D" | "5D" | "7D">("1D"); + const [hiddenSeriesKeys, setHiddenSeriesKeys] = useState>(new Set()); + + useEffect(() => { + setHiddenSeriesKeys(new Set()); + }, [timeframe]); useEffect(() => { if (!city) return; @@ -653,6 +876,8 @@ export function LiveTemperatureThresholdChart({ amos: json.amos || null, airportCurrent: json.airport_current || null, airportPrimary: json.airport_primary || null, + forecastDaily: json.forecast?.daily || [], + multiModelDaily: json.multi_model_daily || {}, }; _hourlyCache.set(city, { ts: Date.now(), data }); setHourly(data); @@ -661,8 +886,18 @@ export function LiveTemperatureThresholdChart({ return () => { cancelled = true; }; }, [city]); - const { data, series } = useMemo(() => buildFullDayChartData(row, hourly), [row, hourly]); - const [hiddenSeriesKeys, setHiddenSeriesKeys] = useState>(new Set()); + const { data, series } = useMemo(() => { + if (timeframe === "3D") { + return build3DayChartData(row, hourly); + } + if (timeframe === "5D") { + return buildDailyChartData(row, hourly, 5); + } + if (timeframe === "7D") { + return buildDailyChartData(row, hourly, 7); + } + return buildFullDayChartData(row, hourly); + }, [row, hourly, timeframe]); const tzOffset = row?.tz_offset_seconds ?? 0; const settlementObs = useMemo(() => { @@ -674,8 +909,11 @@ export function LiveTemperatureThresholdChart({ const settlementPlate = useMemo(() => runwayPlates.find((p) => p.isSettlement), [runwayPlates]); const chartSeries = useMemo(() => { + if (timeframe !== "1D") { + return series; + } return series.filter((item) => !hasRunwayData || !item.key.startsWith("model_curve_")); - }, [series, hasRunwayData]); + }, [series, hasRunwayData, timeframe]); const activeSeries = useMemo(() => { return chartSeries.filter((s) => !hiddenSeriesKeys.has(s.key)); @@ -771,90 +1009,186 @@ export function LiveTemperatureThresholdChart({ [series, data], ); + const panelTitle = row + ? `${rowName(row)} · ${ + isEn + ? timeframe === "1D" + ? "Live & Forecast" + : `${timeframe} Forecast` + : timeframe === "1D" + ? "实测与预测" + : `${timeframe}预报` + }` + : isEn + ? "Temperature Chart" + : "气温图表"; + + const timeframeActions = ( +
+ {(["1D", "3D", "5D", "7D"] as const).map((tf) => ( + + ))} +
+ ); + return ( - -
- {/* Stats bar */} -
- {/* Top Row: Large temperatures */} -
-
-
- - {isEn ? "Runway Live (1m)" : `${runwayHeaderLabel}`} + +
+ {/* Compact stats bar */} + {compact ? ( +
+ {timeframe === "1D" ? ( +
+ + {isEn ? "Runway" : runwayHeaderLabel}:{" "} + {temp(currentRunwayTemp)} - - {temp(currentRunwayTemp)} + | + + {isEn ? "METAR" : metarHeaderLabel}:{" "} + {temp(observedHighMetar)}
-
- - {isEn ? "METAR Settlement (30m) · Daily High" : `${metarHeaderLabel} · 当日最高`} + ) : ( +
+ + DEB: {temp(debVal)} - - {temp(observedHighMetar)} - -
-
- -
- - {isEn ? "Daily Peak" : "当日最高气温"} - -
- {isEn ? "Runway" : runwayHighLabel}: {temp(observedHighRunway)} - | - {isEn ? "METAR" : metarHighLabel}: {temp(observedHighMetar)} - {wundergroundDailyHigh !== null && ( + {modelMin !== null && modelMax !== null && ( <> - | - WU: {temp(wundergroundDailyHigh)} + | + + {isEn ? "Models" : "多模型"}:{" "} + + {temp(modelMin)} - {temp(modelMax)} + + )}
+ )} +
+ {timeframe === "1D" && formattedUpdateTime.includes(" ") ? formattedUpdateTime.split(" ")[1].slice(0, 5) : ""}
+ ) : ( + /* Normal detailed stats bar */ +
+ {/* Top Row: Large temperatures */} +
+ {timeframe === "1D" ? ( +
+
+ + {isEn ? "Runway Live (1m)" : `${runwayHeaderLabel}`} + + + {temp(currentRunwayTemp)} + +
+
+ + {isEn ? "METAR Settlement (30m) · Daily High" : `${metarHeaderLabel} · 当日最高`} + + + {temp(observedHighMetar)} + +
+
+ ) : ( +
+
+ + DEB Max + + + {temp(debVal)} + +
+
+ + {isEn ? "Model Range" : "多模型区间"} + + + {modelMin !== null && modelMax !== null ? `${temp(modelMin)} - ${temp(modelMax)}` : "--"} + +
+
+ )} + +
+ + {isEn ? "Daily Peak" : "当日最高气温"} + +
+ {isEn ? "Runway" : runwayHighLabel}: {temp(observedHighRunway)} + | + {isEn ? "METAR" : metarHighLabel}: {temp(observedHighMetar)} + {wundergroundDailyHigh !== null && ( + <> + | + WU: {temp(wundergroundDailyHigh)} + + )} +
+
+
- {/* Bottom Row: Model Range Panel */} -
-
- - {isEn ? "Model Range" : "模型区间"} - - - {modelMin !== null && modelMax !== null ? `${temp(modelMin)} - ${temp(modelMax)}` : "--"} - -
-
- - DEB - - - {temp(debVal)} - -
-
- - {isEn ? "Spread" : "分歧"} - - - {spread !== null ? `${spread.toFixed(1)}°C` : "--"} - {spreadLabel && ` · ${isEn ? spreadLabelEn : spreadLabel}`} - -
-
- - {isEn ? "Updated" : "更新时间"} - - - {formattedUpdateTime} - -
+ {/* Bottom Row: Model Range Panel (Only for 1D mode) */} + {timeframe === "1D" && ( +
+
+ + {isEn ? "Model Range" : "模型区间"} + + + {modelMin !== null && modelMax !== null ? `${temp(modelMin)} - ${temp(modelMax)}` : "--"} + +
+
+ + DEB + + + {temp(debVal)} + +
+
+ + {isEn ? "Spread" : "分歧"} + + + {spread !== null ? `${spread.toFixed(1)}°C` : "--"} + {spreadLabel && ` · ${isEn ? spreadLabelEn : spreadLabel}`} + +
+
+ + {isEn ? "Updated" : "更新时间"} + + + {formattedUpdateTime} + +
+
+ )}
-
+ )} - {/* Runway observations */} - {runwayPlates.length > 0 && ( + {/* Runway observations (Only for 1D mode and when not compact) */} + {timeframe === "1D" && !compact && runwayPlates.length > 0 && (
{isEn ? "Runway Observations" : "跑道观测"} @@ -898,8 +1232,8 @@ export function LiveTemperatureThresholdChart({
)} - {/* Multi-model list (only when runway data is on chart) */} - {hasRunwayData && series.some((s) => s.key.startsWith("model_curve_")) && ( + {/* Multi-model list (Only in 1D mode and when not compact) */} + {timeframe === "1D" && !compact && hasRunwayData && series.some((s) => s.key.startsWith("model_curve_")) && (
@@ -923,11 +1257,8 @@ export function LiveTemperatureThresholdChart({ {/* Chart */}
-
- {row ? rowName(row) : ""} -
{/* Interactive legend */} -
+
{chartSeries.length > 1 && chartSeries.map((s) => (
diff --git a/frontend/components/dashboard/scan-terminal/scan-terminal-client.ts b/frontend/components/dashboard/scan-terminal/scan-terminal-client.ts index d93a8262..4a1c5e07 100644 --- a/frontend/components/dashboard/scan-terminal/scan-terminal-client.ts +++ b/frontend/components/dashboard/scan-terminal/scan-terminal-client.ts @@ -260,7 +260,6 @@ async function getTerminal({ time_range: "today", limit: "180", force_refresh: String(forceRefresh), - skip_polymarket: "true", }); if (tradingRegion && tradingRegion !== "all") { params.set("trading_region", tradingRegion); diff --git a/web/routers/scan.py b/web/routers/scan.py index 1be1eace..23763777 100644 --- a/web/routers/scan.py +++ b/web/routers/scan.py @@ -30,7 +30,6 @@ async def scan_terminal( force_refresh: bool = False, region: str = "", trading_region: str = "", - skip_polymarket: bool = False, timezone_offset_seconds: int | None = None, ): return await get_scan_terminal_payload( @@ -46,7 +45,6 @@ async def scan_terminal( limit=limit, force_refresh=force_refresh, region=region or trading_region or None, - skip_polymarket=skip_polymarket, timezone_offset_seconds=timezone_offset_seconds, ) diff --git a/web/scan_terminal_filters.py b/web/scan_terminal_filters.py index b66c7640..ffc30495 100644 --- a/web/scan_terminal_filters.py +++ b/web/scan_terminal_filters.py @@ -48,7 +48,6 @@ def normalize_scan_terminal_filters( or "today", "limit": max(1, min(safe_int(raw.get("limit"), 25), 200)), "max_spread": max(0.0, _safe_float(raw.get("max_spread")) or 0.03), - "skip_polymarket": str(raw.get("skip_polymarket") or "false").lower() in {"1", "true", "yes", "on"}, } trading_region = str(raw.get("trading_region") or "").strip().lower() diff --git a/web/services/scan_api.py b/web/services/scan_api.py index 4fe3f4dd..5e215790 100644 --- a/web/services/scan_api.py +++ b/web/services/scan_api.py @@ -47,7 +47,6 @@ async def get_scan_terminal_payload( limit: int = 25, force_refresh: bool = False, region: str = "", - skip_polymarket: bool = False, timezone_offset_seconds: int | None = None, ) -> Dict[str, Any]: legacy_routes._assert_entitlement(request) @@ -61,7 +60,6 @@ async def get_scan_terminal_payload( "market_type": market_type, "time_range": time_range, "limit": limit, - "skip_polymarket": skip_polymarket, } if timezone_offset_seconds is not None: filters["timezone_offset_seconds"] = timezone_offset_seconds