From d5fb40a5440c386d8e14682e7270eea5cb90d913 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Sat, 25 Apr 2026 06:42:24 +0800 Subject: [PATCH] feat: implement weather dashboard components, state management, and API routes for terminal scanning and assistant chat --- .env.example | 4 +- frontend-next-3002.log | 10 + frontend/app/api/assistant/chat/route.ts | 11 + frontend/app/api/auth/me/route.ts | 15 + .../components/dashboard/Dashboard.module.css | 380 +++++ .../components/dashboard/OpportunityTable.tsx | 1379 +++++++++++++---- .../components/dashboard/PanelSections.tsx | 57 +- .../dashboard/ScanTerminalDashboard.tsx | 24 +- frontend/hooks/useDashboardStore.tsx | 28 +- frontend/lib/dashboard-types.ts | 54 + frontend/lib/local-dev-access.ts | 83 + frontend/middleware.ts | 11 + web/scan_terminal_service.py | 410 ++++- 13 files changed, 2109 insertions(+), 357 deletions(-) create mode 100644 frontend-next-3002.log create mode 100644 frontend/lib/local-dev-access.ts diff --git a/.env.example b/.env.example index 39432b0b..acf4d526 100644 --- a/.env.example +++ b/.env.example @@ -134,9 +134,9 @@ POLYWEATHER_DEEPSEEK_API_KEY= POLYWEATHER_DEEPSEEK_BASE_URL=https://api.deepseek.com POLYWEATHER_SCAN_AI_MODEL=deepseek-v4-flash POLYWEATHER_SCAN_AI_TIMEOUT_SEC=40 -POLYWEATHER_SCAN_AI_CACHE_TTL_SEC=600 +POLYWEATHER_SCAN_AI_CACHE_TTL_SEC=120 POLYWEATHER_SCAN_AI_MAX_ROWS=40 -POLYWEATHER_SCAN_AI_MAX_TOKENS=1600 +POLYWEATHER_SCAN_AI_MAX_TOKENS=3200 POLYWEATHER_SCAN_AI_PROXY_TIMEOUT_MS=45000 POLYWEATHER_PREWARM_CITIES=ankara,istanbul,shanghai,beijing,shenzhen,guangzhou,wuhan,chengdu,chongqing,hong kong,taipei,singapore,tokyo,seoul,busan,london,paris,madrid diff --git a/frontend-next-3002.log b/frontend-next-3002.log new file mode 100644 index 00000000..d68292b1 --- /dev/null +++ b/frontend-next-3002.log @@ -0,0 +1,10 @@ + +> polyweather-frontend@1.5.4 start +> next start -p 3002 + + ▲ Next.js 15.5.12 + - Local: http://localhost:3002 + - Network: http://172.23.64.1:3002 + + ✓ Starting... + ✓ Ready in 541ms diff --git a/frontend/app/api/assistant/chat/route.ts b/frontend/app/api/assistant/chat/route.ts index 33e2722d..465ee96f 100644 --- a/frontend/app/api/assistant/chat/route.ts +++ b/frontend/app/api/assistant/chat/route.ts @@ -4,6 +4,7 @@ import { applyAuthResponseCookies, buildBackendRequestHeaders, } from "@/lib/backend-auth"; +import { isLocalFullAccessHost } from "@/lib/local-dev-access"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -565,6 +566,16 @@ async function generateWithGroq(params: { async function ensureAssistantAccess(request: NextRequest) { const auth = await buildBackendRequestHeaders(request); + const requestHost = + request.headers.get("x-forwarded-host") || + request.headers.get("host") || + request.nextUrl.host; + if ( + isLocalFullAccessHost(requestHost) || + isLocalFullAccessHost(request.nextUrl.hostname) + ) { + return { allowed: true, auth }; + } if (process.env.POLYWEATHER_AI_ALLOW_FREE === "true") { return { allowed: true, auth }; } diff --git a/frontend/app/api/auth/me/route.ts b/frontend/app/api/auth/me/route.ts index c67e7f03..d300002e 100644 --- a/frontend/app/api/auth/me/route.ts +++ b/frontend/app/api/auth/me/route.ts @@ -3,10 +3,25 @@ import { applyAuthResponseCookies, buildBackendRequestHeaders, } from "@/lib/backend-auth"; +import { + getLocalDevAuthPayload, + isLocalFullAccessHost, +} from "@/lib/local-dev-access"; const API_BASE = process.env.POLYWEATHER_API_BASE_URL; export async function GET(req: NextRequest) { + const requestHost = + req.headers.get("x-forwarded-host") || req.headers.get("host") || req.nextUrl.host; + if ( + isLocalFullAccessHost(requestHost) || + isLocalFullAccessHost(req.nextUrl.hostname) + ) { + return NextResponse.json(getLocalDevAuthPayload(), { + headers: { "Cache-Control": "no-store" }, + }); + } + if (!API_BASE) { return NextResponse.json( { error: "POLYWEATHER_API_BASE_URL is not configured" }, diff --git a/frontend/components/dashboard/Dashboard.module.css b/frontend/components/dashboard/Dashboard.module.css index db073e50..bec68b28 100644 --- a/frontend/components/dashboard/Dashboard.module.css +++ b/frontend/components/dashboard/Dashboard.module.css @@ -10014,6 +10014,25 @@ background: rgba(59, 130, 246, 0.1); } +.root :global(.scan-opportunity-strength.approve) { + color: #71f7c8; + border-color: rgba(45, 212, 191, 0.34); + background: rgba(20, 184, 166, 0.12); +} + +.root :global(.scan-opportunity-strength.downgrade), +.root :global(.scan-opportunity-strength.watchlist) { + color: #ffd166; + border-color: rgba(245, 158, 11, 0.32); + background: rgba(245, 158, 11, 0.12); +} + +.root :global(.scan-opportunity-strength.veto) { + color: #ff8585; + border-color: rgba(248, 113, 113, 0.34); + background: rgba(248, 113, 113, 0.12); +} + .root :global(.scan-opportunity-expand) { display: inline-flex; align-items: center; @@ -10129,6 +10148,367 @@ font-weight: 900; } +.root :global(.scan-opportunity-group) { + overflow: visible; + border-radius: 14px; +} + +.root :global(.scan-opportunity-items) { + gap: 10px; + padding: 10px 12px 14px; +} + +.root :global(.scan-opportunity-item) { + display: flex; + flex-direction: column; + gap: 10px; + align-items: stretch; + min-height: 0; + padding: 0; + overflow: visible; + border-color: rgba(89, 128, 178, 0.16); + border-radius: 14px; + background: rgba(6, 18, 32, 0.72); +} + +.root :global(.scan-opportunity-item.expanded) { + border-color: rgba(23, 217, 139, 0.42); + background: + linear-gradient(180deg, rgba(8, 29, 43, 0.94), rgba(5, 17, 31, 0.96)), + radial-gradient(circle at 12% 0%, rgba(20, 184, 166, 0.12), transparent 36%); + box-shadow: + inset 0 1px 0 rgba(125, 251, 231, 0.09), + 0 18px 42px rgba(0, 0, 0, 0.2); +} + +.root :global(.scan-opportunity-summary-row) { + display: grid; + grid-template-columns: + 4px minmax(128px, 180px) minmax(360px, 1fr) + max-content max-content; + gap: 10px; + align-items: stretch; + padding: 12px 14px; +} + +.root :global(.scan-opportunity-branch) { + align-self: stretch; + width: 4px; + border-radius: 999px; + background: linear-gradient(180deg, rgba(23, 217, 139, 0.92), rgba(34, 211, 238, 0.28)); +} + +.root :global(.scan-opportunity-branch::before), +.root :global(.scan-opportunity-branch i) { + display: none; +} + +.root :global(.scan-opportunity-metrics) { + display: grid; + grid-template-columns: repeat(4, minmax(96px, 1fr)); + gap: 8px; + min-width: 0; +} + +.root :global(.scan-opportunity-stat) { + display: grid; + align-items: start; + gap: 3px; + min-width: 0; + max-width: none; + width: 100%; + padding: 8px 10px; + border-color: rgba(90, 123, 166, 0.14); + background: rgba(8, 25, 43, 0.62); +} + +.root :global(.scan-opportunity-stat.edge) { + justify-self: stretch; + justify-content: stretch; + justify-items: start; + min-width: 0; +} + +.root :global(.scan-opportunity-trade) { + flex-direction: column; + align-items: flex-start; + justify-content: center; + gap: 2px; +} + +.root :global(.scan-opportunity-action) { + white-space: normal; +} + +.root :global(.scan-opportunity-ai) { + display: grid; + grid-column: auto; + gap: 4px; + margin: 0 14px; + padding: 11px 13px; + border-color: rgba(64, 104, 168, 0.16); + background: rgba(5, 15, 27, 0.64); +} + +.root :global(.scan-opportunity-ai small) { + overflow: visible; + color: #a9bdd8; + white-space: normal; + text-overflow: clip; +} + +.root :global(.scan-v4-analysis) { + grid-column: auto; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; + margin: 0 14px 14px; + padding: 14px; + border-color: rgba(56, 189, 248, 0.18); + border-radius: 14px; + background: + linear-gradient(180deg, rgba(7, 20, 36, 0.96), rgba(5, 15, 28, 0.98)), + radial-gradient(circle at 4% 0%, rgba(45, 212, 191, 0.11), transparent 28%); +} + +.root :global(.scan-v4-analysis section) { + padding: 13px; + border-color: rgba(92, 132, 188, 0.14); + background: rgba(10, 25, 43, 0.7); +} + +.root :global(.scan-v4-analysis p), +.root :global(.scan-v4-analysis ul) { + color: #b8c9e3; +} + +.root :global(.scan-v4-analysis .scan-v4-evidence) { + grid-column: 1 / -1; +} + +.root :global(.scan-v4-analysis .scan-v4-decision) { + grid-column: 1 / -1; + border-color: rgba(45, 212, 191, 0.24); + background: + linear-gradient(180deg, rgba(6, 31, 43, 0.86), rgba(8, 23, 38, 0.86)), + radial-gradient(circle at 0 0, rgba(20, 184, 166, 0.12), transparent 32%); +} + +.root :global(.scan-v4-analysis .scan-v4-decision.downgrade), +.root :global(.scan-v4-analysis .scan-v4-decision.watchlist) { + border-color: rgba(245, 158, 11, 0.24); +} + +.root :global(.scan-v4-analysis .scan-v4-decision.veto) { + border-color: rgba(248, 113, 113, 0.28); +} + +.root :global(.scan-v4-decision p b) { + color: #ecfeff; + font-weight: 950; +} + +.root :global(.scan-v4-metar-summary) { + margin-top: 9px; + color: #8fb3d8; + font-size: 12px; + font-weight: 800; + line-height: 1.45; +} + +.root :global(.scan-v4-evidence > div), +.root :global(.scan-v4-model-sources) { + gap: 8px; +} + +.root :global(.scan-v4-analysis) { + display: flex; + flex-direction: column; + gap: 13px; + margin: 0 14px 14px; + padding: 15px 16px 16px; + border: 1px solid rgba(56, 189, 248, 0.16); + border-radius: 12px; + background: + linear-gradient(180deg, rgba(7, 20, 36, 0.96), rgba(5, 15, 28, 0.98)), + radial-gradient(circle at 4% 0%, rgba(45, 212, 191, 0.08), transparent 30%); +} + +.root :global(.scan-v4-analysis section), +.root :global(.scan-v4-analysis section:first-child), +.root :global(.scan-v4-analysis .scan-v4-evidence) { + grid-column: auto; + min-width: 0; + padding: 0; + border: 0; + border-radius: 0; + background: transparent; +} + +.root :global(.scan-v4-heading) { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 18px; + padding-bottom: 12px; + border-bottom: 1px solid rgba(90, 123, 166, 0.14); +} + +.root :global(.scan-v4-heading > div) { + display: grid; + gap: 7px; + min-width: 0; +} + +.root :global(.scan-v4-heading strong), +.root :global(.scan-v4-current b), +.root :global(.scan-v4-brief-grid strong) { + color: #72f3d1; + font-size: 12px; + font-weight: 950; + letter-spacing: 0.02em; +} + +.root :global(.scan-v4-heading p) { + margin: 0; + color: #d2e1f4; + font-size: 14px; + font-weight: 800; + line-height: 1.55; +} + +.root :global(.scan-v4-decision-pill) { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + justify-content: center; + min-height: 30px; + padding: 0 12px; + border: 1px solid rgba(45, 212, 191, 0.3); + border-radius: 999px; + background: rgba(20, 184, 166, 0.12); + color: #7dfbe7; + font-size: 12px; + font-weight: 950; + white-space: nowrap; +} + +.root :global(.scan-v4-decision-pill.downgrade), +.root :global(.scan-v4-decision-pill.watchlist) { + border-color: rgba(245, 158, 11, 0.34); + background: rgba(245, 158, 11, 0.12); + color: #ffd166; +} + +.root :global(.scan-v4-decision-pill.veto) { + border-color: rgba(248, 113, 113, 0.34); + background: rgba(248, 113, 113, 0.12); + color: #ff8585; +} + +.root :global(.scan-v4-current) { + display: grid; + gap: 6px; + padding-bottom: 12px; + border-bottom: 1px solid rgba(90, 123, 166, 0.12); +} + +.root :global(.scan-v4-current span) { + color: #c8d8ec; + font-size: 13px; + font-weight: 850; + line-height: 1.5; +} + +.root :global(.scan-v4-current small) { + color: #8fb3d8; + font-size: 12px; + font-weight: 800; + line-height: 1.45; +} + +.root :global(.scan-v4-brief-grid) { + display: grid; + grid-template-columns: minmax(0, 1.45fr) minmax(260px, 0.85fr); + gap: 26px; +} + +.root :global(.scan-v4-brief-grid section:first-child) { + grid-column: auto; +} + +.root :global(.scan-v4-brief-grid ul) { + display: grid; + gap: 8px; + margin: 8px 0 0; + padding: 0; + list-style: none; +} + +.root :global(.scan-v4-brief-grid li) { + position: relative; + padding-left: 16px; + color: #aebfd8; + font-size: 12px; + font-weight: 800; + line-height: 1.48; +} + +.root :global(.scan-v4-brief-grid li::before) { + content: ""; + position: absolute; + top: 0.65em; + left: 0; + width: 5px; + height: 5px; + border-radius: 999px; + background: #2dd4bf; + box-shadow: 0 0 10px rgba(45, 212, 191, 0.45); +} + +.root :global(.scan-v4-brief-grid section:nth-child(2) li::before) { + background: #f59e0b; + box-shadow: 0 0 10px rgba(245, 158, 11, 0.35); +} + +.root :global(details.scan-v4-evidence) { + padding-top: 11px; + border-top: 1px solid rgba(90, 123, 166, 0.12); +} + +.root :global(details.scan-v4-evidence summary) { + display: inline-flex; + align-items: center; + gap: 8px; + min-height: 30px; + padding: 0 11px; + border: 1px solid rgba(65, 118, 216, 0.28); + border-radius: 8px; + background: rgba(10, 30, 58, 0.52); + color: #8fbdff; + font-size: 12px; + font-weight: 950; + cursor: pointer; + list-style: none; +} + +.root :global(details.scan-v4-evidence summary::-webkit-details-marker) { + display: none; +} + +.root :global(details.scan-v4-evidence summary::after) { + content: "v"; + color: #8fbdff; + font-size: 12px; +} + +.root :global(details.scan-v4-evidence[open] summary::after) { + content: "^"; +} + +.root :global(details.scan-v4-evidence > div) { + margin-top: 10px; +} + .root :global(.scan-table-body::-webkit-scrollbar), .root :global(.scan-calendar-view::-webkit-scrollbar), .root :global(.scan-detail-panel::-webkit-scrollbar) { diff --git a/frontend/components/dashboard/OpportunityTable.tsx b/frontend/components/dashboard/OpportunityTable.tsx index 1760f84e..0efc3acc 100644 --- a/frontend/components/dashboard/OpportunityTable.tsx +++ b/frontend/components/dashboard/OpportunityTable.tsx @@ -5,7 +5,6 @@ import React from "react"; import { useI18n } from "@/hooks/useI18n"; import type { CityDetail, - DistributionPreviewPoint, ScanOpportunityRow, } from "@/lib/dashboard-types"; import { getLocalizedCityName } from "@/lib/dashboard-home-copy"; @@ -48,25 +47,25 @@ function formatWindowMinutes(value: number | null | undefined, locale: string) { return `剩余 ${hours}h ${remains}m`; } +function formatMinuteSpan(value: number | null | undefined, locale: string) { + if (value == null || !Number.isFinite(Number(value))) return "--"; + const minutes = Math.max(0, Math.round(Number(value))); + const hours = Math.floor(minutes / 60); + const remains = minutes % 60; + if (locale === "en-US") { + if (hours <= 0) return `${remains}m`; + return `${hours}h ${remains}m`; + } + if (hours <= 0) return `${remains} 分钟`; + return `${hours}h ${remains}m`; +} + function formatAction( row: ScanOpportunityRow, locale: string, tempSymbol?: string | null, ) { - const formattedTarget = normalizeTemperatureLabel(row.target_label, tempSymbol); - if (row.action) { - const normalizedAction = row.target_label - ? row.action.replace(String(row.target_label), formattedTarget || String(row.target_label)) - : row.action; - return normalizeTemperatureLabel(normalizedAction, tempSymbol); - } - if (row.side === "yes") { - return `${locale === "en-US" ? "Buy Yes" : "买入 Yes"} ${formattedTarget || ""}`.trim(); - } - if (row.side === "no") { - return `${locale === "en-US" ? "Buy No" : "买入 No"} ${formattedTarget || ""}`.trim(); - } - return "--"; + return formatTradeSide(row, locale, tempSymbol); } export function getWindowPhaseMeta( @@ -138,8 +137,30 @@ function formatTradeSide( tempSymbol?: string | null, ) { const side = String(row.side || "").toLowerCase(); - if (side === "yes") return "BUY YES"; - if (side === "no") return "BUY NO"; + const isEn = locale === "en-US"; + const { lower, upper } = getTargetRange(row); + const threshold = + lower != null && upper == null + ? formatTemperatureValue(lower, tempSymbol) + : upper != null && lower == null + ? formatTemperatureValue(upper, tempSymbol) + : null; + if (threshold && lower != null && upper == null) { + if (side === "yes") return isEn ? `High reaches ${threshold}` : `最高温达到 ${threshold}`; + if (side === "no") return isEn ? `High stays below ${threshold}` : `最高温低于 ${threshold}`; + } + if (threshold && upper != null && lower == null) { + if (side === "yes") return isEn ? `High stays at/below ${threshold}` : `最高温不高于 ${threshold}`; + if (side === "no") return isEn ? `High exceeds ${threshold}` : `最高温高于 ${threshold}`; + } + if (lower != null && upper != null && Math.abs(lower - upper) > 0.01) { + const range = `${formatTemperatureValue(lower, tempSymbol)} ~ ${formatTemperatureValue(upper, tempSymbol)}`; + if (side === "yes") return isEn ? `High lands in ${range}` : `最高温落在 ${range}`; + if (side === "no") return isEn ? `High avoids ${range}` : `最高温不在 ${range}`; + } + const bucket = formatThreshold(row, tempSymbol); + if (side === "yes") return isEn ? `High lands on ${bucket}` : `最高温落在 ${bucket} 桶`; + if (side === "no") return isEn ? `High avoids ${bucket}` : `最高温不落在 ${bucket} 桶`; if (row.action) { return normalizeTemperatureLabel( String(row.action).replace(String(row.target_label || ""), ""), @@ -167,6 +188,132 @@ function formatThreshold(row: ScanOpportunityRow, tempSymbol?: string | null) { return "--"; } +function formatTemperatureDelta(value: number, tempSymbol?: string | null) { + return formatTemperatureValue(Math.abs(value), tempSymbol, { digits: 1 }); +} + +function getDebDistanceSummary( + row: ScanOpportunityRow, + locale: string, + tempSymbol?: string | null, +) { + const deb = + row.deb_prediction != null && Number.isFinite(Number(row.deb_prediction)) + ? Number(row.deb_prediction) + : null; + if (deb == null) return locale === "en-US" ? "DEB pending" : "DEB 待确认"; + const { lower, upper } = getTargetRange(row); + if (lower != null && upper == null) { + const delta = deb - lower; + if (Math.abs(delta) < 0.05) return locale === "en-US" ? "DEB on threshold" : "DEB 贴近阈值"; + return delta >= 0 + ? locale === "en-US" + ? `DEB above by ${formatTemperatureDelta(delta, tempSymbol)}` + : `DEB 高于阈值 ${formatTemperatureDelta(delta, tempSymbol)}` + : locale === "en-US" + ? `DEB below by ${formatTemperatureDelta(delta, tempSymbol)}` + : `DEB 低于阈值 ${formatTemperatureDelta(delta, tempSymbol)}`; + } + if (upper != null && lower == null) { + const delta = deb - upper; + if (Math.abs(delta) < 0.05) return locale === "en-US" ? "DEB on threshold" : "DEB 贴近阈值"; + return delta <= 0 + ? locale === "en-US" + ? `DEB below by ${formatTemperatureDelta(delta, tempSymbol)}` + : `DEB 低于阈值 ${formatTemperatureDelta(delta, tempSymbol)}` + : locale === "en-US" + ? `DEB above by ${formatTemperatureDelta(delta, tempSymbol)}` + : `DEB 高于阈值 ${formatTemperatureDelta(delta, tempSymbol)}`; + } + if (lower != null && upper != null) { + if (deb >= lower && deb <= upper) return locale === "en-US" ? "DEB inside bucket" : "DEB 位于桶内"; + const nearest = deb < lower ? lower : upper; + const delta = deb - nearest; + return deb < lower + ? locale === "en-US" + ? `DEB below bucket by ${formatTemperatureDelta(delta, tempSymbol)}` + : `DEB 低于桶 ${formatTemperatureDelta(delta, tempSymbol)}` + : locale === "en-US" + ? `DEB above bucket by ${formatTemperatureDelta(delta, tempSymbol)}` + : `DEB 高于桶 ${formatTemperatureDelta(delta, tempSymbol)}`; + } + return locale === "en-US" + ? `DEB ${formatTemperatureValue(deb, tempSymbol, { digits: 1 })}` + : `DEB ${formatTemperatureValue(deb, tempSymbol, { digits: 1 })}`; +} + +function getModelSupportSummary( + row: ScanOpportunityRow, + locale: string, +) { + const sources = Object.values(row.model_cluster_sources || {}) + .map((value) => Number(value)) + .filter((value) => Number.isFinite(value)); + const deb = + row.deb_prediction != null && Number.isFinite(Number(row.deb_prediction)) + ? Number(row.deb_prediction) + : null; + if (!sources.length || deb == null) return locale === "en-US" ? "Models pending" : "模型待确认"; + const { lower, upper } = getTargetRange(row); + let supports = 0; + if (lower != null && upper == null) { + supports = sources.filter((value) => (deb >= lower ? value >= lower : value < lower)).length; + } else if (upper != null && lower == null) { + supports = sources.filter((value) => (deb <= upper ? value <= upper : value > upper)).length; + } else if (lower != null && upper != null) { + if (deb >= lower && deb <= upper) { + supports = sources.filter((value) => value >= lower && value <= upper).length; + } else if (deb < lower) { + supports = sources.filter((value) => value < lower).length; + } else { + supports = sources.filter((value) => value > upper).length; + } + } else { + const tolerance = 1; + supports = sources.filter((value) => Math.abs(value - deb) <= tolerance).length; + } + return locale === "en-US" + ? `${supports}/${sources.length} models support DEB` + : `${supports}/${sources.length} 模型支持 DEB`; +} + +function getMetarConflictSummary( + row: ScanOpportunityRow, + detail: CityDetail | null, + locale: string, +) { + const obs = getMetarObservationContext(row, detail); + if (obs.stale || obs.maxTemp == null) return locale === "en-US" ? "METAR pending" : "METAR 待确认"; + const deb = + row.deb_prediction != null && Number.isFinite(Number(row.deb_prediction)) + ? Number(row.deb_prediction) + : null; + const { lower, upper } = getTargetRange(row); + if (deb == null || (lower == null && upper == null)) { + return locale === "en-US" ? "METAR read only" : "METAR 仅参考"; + } + const phase = String(row.window_phase || "").toLowerCase(); + const peakPending = + phase === "early_today" || + phase === "setup_today" || + (row.minutes_until_peak_start != null && Number(row.minutes_until_peak_start) > 0); + if (lower != null && upper == null) { + if (deb < lower && obs.maxTemp >= lower) return locale === "en-US" ? "METAR conflicts" : "METAR 冲突"; + if (deb >= lower && obs.maxTemp < lower && peakPending) return locale === "en-US" ? "Await peak" : "等待峰值"; + return locale === "en-US" ? "METAR no conflict" : "METAR 未冲突"; + } + if (upper != null && lower == null) { + if (deb <= upper && obs.maxTemp > upper) return locale === "en-US" ? "METAR conflicts" : "METAR 冲突"; + if (deb > upper && obs.maxTemp <= upper && peakPending) return locale === "en-US" ? "Await peak" : "等待峰值"; + return locale === "en-US" ? "METAR no conflict" : "METAR 未冲突"; + } + if (lower != null && upper != null && deb >= lower && deb <= upper) { + if (obs.maxTemp > upper) return locale === "en-US" ? "METAR above bucket" : "METAR 已越过桶"; + if (obs.maxTemp < lower && peakPending) return locale === "en-US" ? "Await peak" : "等待峰值"; + } + return locale === "en-US" ? "METAR no conflict" : "METAR 未冲突"; +} + function getOpportunityStrength(edgePercent?: number | null, locale = "zh-CN") { const edge = Number(edgePercent); const normalized = Number.isFinite(edge) ? edge : 0; @@ -208,6 +355,22 @@ function formatModelSources(row: ScanOpportunityRow, tempSymbol?: string | null) })); } +function formatModelClusterRange( + sources?: Record | null, + tempSymbol?: string | null, +) { + const values = Object.values(sources || {}) + .map((value) => Number(value)) + .filter((value) => Number.isFinite(value)); + if (!values.length) return "--"; + const low = Math.min(...values); + const high = Math.max(...values); + if (Math.abs(low - high) < 0.05) { + return formatTemperatureValue(low, tempSymbol, { digits: 1 }); + } + return `${formatTemperatureValue(low, tempSymbol, { digits: 1 })} ~ ${formatTemperatureValue(high, tempSymbol, { digits: 1 })}`; +} + function getModelSourceSummary( row: ScanOpportunityRow, locale: string, @@ -228,7 +391,7 @@ function getModelSourceSummary( function getShortAiConclusion( row: ScanOpportunityRow, locale: string, - edgePercent?: number | null, + _edgePercent?: number | null, strengthLabel?: string, ) { const directReason = @@ -248,12 +411,11 @@ function getShortAiConclusion( ); if (cityThesis) return cityThesis; - const edgeText = formatPercent(edgePercent, true); const modelBasis = getModelSourceSummary(row, locale, row.target_unit || row.temp_symbol); if (locale === "en-US") { - return `${strengthLabel || "Watch"} setup: edge ${edgeText}; V4 should validate against ${modelBasis}.`; + return `${strengthLabel || "Watch"}: AI should validate against ${modelBasis}.`; } - return `${strengthLabel || "观察"}:edge ${edgeText},V4 需结合${modelBasis}确认。`; + return `${strengthLabel || "观察"}:AI 需结合${modelBasis}确认。`; } function getRiskHints( @@ -266,8 +428,8 @@ function getRiskHints( if (Number.isFinite(spread) && spread > 0.03) { hints.push( locale === "en-US" - ? `Wide spread ${formatQuoteCents(spread)} may distort executable edge.` - : `盘口价差 ${formatQuoteCents(spread)} 偏宽,可能扭曲可执行 edge。`, + ? `Wide spread ${formatQuoteCents(spread)} may distort the displayed market price.` + : `盘口价差 ${formatQuoteCents(spread)} 偏宽,可能扭曲市场价格参考。`, ); } const quoteAgeSeconds = @@ -291,8 +453,8 @@ function getRiskHints( if (row.cluster_adjusted) { hints.push( locale === "en-US" - ? "Tail bucket was cluster-adjusted; raw edge may be overstated." - : "尾部桶已做模型集群折扣,原始 edge 可能偏乐观。", + ? "Tail bucket was cluster-adjusted; bucket confidence may be overstated." + : "尾部桶已做模型集群折扣,温度桶信心可能偏乐观。", ); } if (modelProbability != null && modelProbability < 10) { @@ -315,8 +477,7 @@ function getRiskHints( function getRecommendationReasons( row: ScanOpportunityRow, locale: string, - modelProbability?: number | null, - edgePercent?: number | null, + _edgePercent?: number | null, price?: number | null, ) { const reasons: string[] = []; @@ -324,10 +485,11 @@ function getRecommendationReasons( if (aiReason && String(row.ai_decision || "").toLowerCase() === "approve") { reasons.push(aiReason); } + const modelBasis = getModelSourceSummary(row, locale, row.target_unit || row.temp_symbol); reasons.push( locale === "en-US" - ? `EMOS probability ${formatPercent(modelProbability)} vs market price ${formatQuoteCents(price)} gives edge ${formatPercent(edgePercent, true)}.` - : `EMOS 概率 ${formatPercent(modelProbability)} 对比市场价格 ${formatQuoteCents(price)},edge ${formatPercent(edgePercent, true)}。`, + ? `AI uses ${modelBasis} with market ask ${formatQuoteCents(price)} only as downstream bucket context.` + : `AI 以${modelBasis}为主,市场买价 ${formatQuoteCents(price)} 只作下游温度桶参考。`, ); if (row.peak_alignment_score != null) { reasons.push( @@ -357,21 +519,21 @@ function getExclusionReasons( return [ aiReason || (locale === "en-US" - ? "V4 did not classify this row as a primary recommendation." - : "V4 未把该合约列为主推荐。"), + ? "AI did not classify this row as the primary forecast bucket." + : "AI 未把该合约列为主预测桶。"), ]; } if (edgePercent != null && Number(edgePercent) < 10) { return [ locale === "en-US" - ? "Edge is below the medium-opportunity threshold." - : "edge 低于中机会阈值。", + ? "This bucket is not the current forecast center." + : "该桶不是当前预测中枢。", ]; } return [ locale === "en-US" - ? "No hard veto in the current V4/rule snapshot." - : "当前 V4/规则快照没有硬性排除项。", + ? "No hard veto in the current AI/rule snapshot." + : "当前 AI/规则快照没有硬性排除项。", ]; } @@ -414,55 +576,29 @@ function getAiMeta(row: ScanOpportunityRow, locale: string) { return null; } -function getDistributionPreview(row: ScanOpportunityRow, tempSymbol?: string | null) { - const preview = Array.isArray(row.distribution_preview) - ? row.distribution_preview.filter( - (item): item is DistributionPreviewPoint => - Boolean(item && (item.label || item.value != null)), - ) - : []; +type ObservationPoint = { time?: string; temp?: number | null }; - if (!preview.length) { - const targetBase = - row.target_value ?? - row.target_threshold ?? - row.target_lower ?? - row.target_upper ?? - null; - const targetLabel = - targetBase != null - ? formatTemperatureValue(Number(targetBase), tempSymbol) - : normalizeTemperatureLabel(row.target_label, tempSymbol) || "--"; - preview.push({ - label: targetLabel, - model_probability: - row.raw_model_event_probability ?? row.model_event_probability, - market_probability: row.market_event_probability, - highlighted: true, - }); - } - return preview; -} +type V4TradeDecision = { + decision: "approve" | "downgrade" | "veto" | "watchlist"; + label: string; + tone: "approve" | "downgrade" | "veto" | "watchlist"; + reason: string; + metarSummary?: string | null; + airportReport?: string | null; + metarEvidence: string[]; +}; -function getEmosPeak(row: ScanOpportunityRow, tempSymbol?: string | null) { - const preview = getDistributionPreview(row, tempSymbol); - const peak = - preview.reduce((best, item) => { - const probability = Number(item.model_probability ?? -1); - const bestProbability = Number(best?.model_probability ?? -1); - return probability > bestProbability ? item : best; - }, null) || preview.find((item) => item.highlighted) || preview[0]; - const peakValue = peak?.value ?? row.peak_value ?? null; - const peakLabel = normalizeTemperatureLabel(peak?.label, tempSymbol) || - (peakValue != null ? formatTemperatureValue(Number(peakValue), tempSymbol) : "--"); - const peakProbability = - peak?.model_probability != null - ? Number(peak.model_probability) * 100 - : row.peak_probability != null - ? Number(row.peak_probability) * 100 - : null; - return { label: peakLabel, probability: peakProbability }; -} +type V4CityForecast = { + predicted: number | null; + low: number | null; + high: number | null; + confidence?: string | null; + peakWindow?: string | null; + airportRead?: string | null; + reason?: string | null; + modelNote?: string | null; + source: "ai" | "fallback"; +}; function normalizeLookupKey(value?: string | null) { return String(value || "") @@ -513,6 +649,702 @@ function extractNumbers(value?: string | null) { ); } +function normalizeObservationPoints(points?: ObservationPoint[] | null) { + if (!Array.isArray(points)) return []; + return points + .map((point) => ({ + time: String(point?.time || "").trim(), + temp: + point?.temp != null && Number.isFinite(Number(point.temp)) + ? Number(point.temp) + : null, + })) + .filter((point): point is { time: string; temp: number } => + Boolean(point.time && point.temp != null), + ) + .sort((a, b) => getObservationSortMinutes(a.time) - getObservationSortMinutes(b.time)); +} + +function getObservationSortMinutes(time: string) { + const parsed = Date.parse(time); + if (Number.isFinite(parsed)) { + const date = new Date(parsed); + return date.getUTCHours() * 60 + date.getUTCMinutes(); + } + const match = time.match(/(\d{1,2}):(\d{2})/); + if (!match) return Number.MAX_SAFE_INTEGER; + return Number(match[1]) * 60 + Number(match[2]); +} + +function formatPeakWindowTiming(row: ScanOpportunityRow, locale: string) { + const isEn = locale === "en-US"; + const phase = String(row.window_phase || "").toLowerCase(); + const label = String(row.peak_window_label || "").trim(); + const untilStart = + row.minutes_until_peak_start != null && Number.isFinite(Number(row.minutes_until_peak_start)) + ? Number(row.minutes_until_peak_start) + : null; + const untilEnd = + row.minutes_until_peak_end != null && Number.isFinite(Number(row.minutes_until_peak_end)) + ? Number(row.minutes_until_peak_end) + : null; + const windowText = label ? `${isEn ? "peak window" : "峰值窗口"} ${label}` : isEn ? "peak window" : "峰值窗口"; + if (phase === "active_peak" || (untilStart != null && untilStart <= 0 && untilEnd != null && untilEnd > 0)) { + return isEn ? `Currently inside the ${windowText}.` : `当前已进入${windowText}。`; + } + if (phase === "post_peak" || (untilEnd != null && untilEnd <= 0)) { + return isEn ? `The ${windowText} has passed.` : `${windowText}已结束。`; + } + if (untilStart != null && untilStart > 0) { + return isEn + ? `${windowText} starts in ${formatMinuteSpan(untilStart, locale)}.` + : `${windowText}尚未开始,约 ${formatMinuteSpan(untilStart, locale)} 后进入。`; + } + if (phase === "early_today" || phase === "setup_today") { + return isEn ? `Before the ${windowText}; latest METAR is not final peak evidence yet.` : `尚处峰值前,最新 METAR 还不能当作最终峰值证据。`; + } + return label ? (isEn ? `Reference ${windowText}.` : `参考${windowText}。`) : null; +} + +function decodeRawMetarCloud(rawMetar?: string | null, locale = "zh-CN") { + const raw = String(rawMetar || "").toUpperCase(); + const matches = Array.from(raw.matchAll(/\b(FEW|SCT|BKN|OVC)(\d{3})?\b/g)); + if (!matches.length) return ""; + const coverText: Record = { + FEW: { zh: "少云", en: "few" }, + SCT: { zh: "散云", en: "scattered" }, + BKN: { zh: "多云", en: "broken" }, + OVC: { zh: "阴天", en: "overcast" }, + }; + return matches + .slice(0, 3) + .map((match) => { + const cover = coverText[match[1]] || { zh: match[1], en: match[1] }; + const base = match[2] ? `${Number(match[2]) * 100}ft` : ""; + return locale === "en-US" + ? [cover.en, base].filter(Boolean).join(" ") + : [cover.zh, base].filter(Boolean).join(" "); + }) + .join(locale === "en-US" ? ", " : "、"); +} + +function decodeRawMetarVisibility(rawMetar?: string | null) { + const raw = String(rawMetar || "").toUpperCase(); + if (/\b9999\b/.test(raw)) return "10km+"; + const meterMatch = raw.match(/\b(\d{4})\b/); + if (meterMatch) return `${Number(meterMatch[1]) / 1000}km`; + return ""; +} + +function formatAirportReportRead( + row: ScanOpportunityRow, + detail: CityDetail | null, + locale: string, + tempSymbol?: string | null, +) { + const isEn = locale === "en-US"; + const context = row.metar_context || {}; + const airport: Partial> = + detail?.airport_current || {}; + const station = + context.station || + detail?.risk?.icao || + airport.station_code || + null; + const obsTime = + context.airport_obs_time || + context.last_time || + airport.obs_time || + row.metar_status?.last_observation_time || + null; + const temp = + context.airport_current_temp != null && Number.isFinite(Number(context.airport_current_temp)) + ? Number(context.airport_current_temp) + : airport.temp != null && Number.isFinite(Number(airport.temp)) + ? Number(airport.temp) + : null; + const windSpeed = + context.airport_wind_speed_kt != null && Number.isFinite(Number(context.airport_wind_speed_kt)) + ? Number(context.airport_wind_speed_kt) + : airport.wind_speed_kt != null && Number.isFinite(Number(airport.wind_speed_kt)) + ? Number(airport.wind_speed_kt) + : null; + const windDir = + context.airport_wind_dir != null && Number.isFinite(Number(context.airport_wind_dir)) + ? Number(context.airport_wind_dir) + : airport.wind_dir != null && Number.isFinite(Number(airport.wind_dir)) + ? Number(airport.wind_dir) + : null; + const cloud = String(context.airport_cloud_desc || airport.cloud_desc || "").trim(); + const weather = String(context.airport_wx_desc || airport.wx_desc || "").trim(); + const rawMetar = String(context.airport_raw_metar || airport.raw_metar || "").trim(); + const decodedCloud = cloud || decodeRawMetarCloud(rawMetar, locale); + const visibility = + context.airport_visibility_mi != null && Number.isFinite(Number(context.airport_visibility_mi)) + ? Number(context.airport_visibility_mi) + : airport.visibility_mi != null && Number.isFinite(Number(airport.visibility_mi)) + ? Number(airport.visibility_mi) + : null; + const decodedVisibility = visibility != null ? `${visibility.toFixed(1)}mi` : decodeRawMetarVisibility(rawMetar); + + const parts: string[] = []; + if (temp != null) parts.push(formatTemperatureValue(temp, tempSymbol, { digits: 1 })); + if (windSpeed != null) { + parts.push( + windDir != null + ? isEn + ? `wind ${Math.round(windDir)}°/${Math.round(windSpeed)}kt` + : `风 ${Math.round(windDir)}°/${Math.round(windSpeed)}kt` + : isEn + ? `wind ${Math.round(windSpeed)}kt` + : `风 ${Math.round(windSpeed)}kt`, + ); + } + if (decodedCloud) parts.push(isEn ? `cloud ${decodedCloud}` : `云况 ${decodedCloud}`); + if (weather) parts.push(isEn ? `weather ${weather}` : `天气 ${weather}`); + if (decodedVisibility) parts.push(isEn ? `visibility ${decodedVisibility}` : `能见度 ${decodedVisibility}`); + if (!parts.length) return null; + const prefix = isEn ? "Latest airport METAR read" : "最新机场报文解读"; + const head = [station, obsTime].filter(Boolean).join(" "); + return `${prefix}${head ? ` ${head}` : ""}:${parts.join(",")}。`; +} + +function median(values: number[]) { + if (!values.length) return null; + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; +} + +function getV4CityForecast( + row: ScanOpportunityRow, + group: OpportunityGroup, + detail: CityDetail | null, + locale: string, + tempSymbol?: string | null, +): V4CityForecast { + const isEn = locale === "en-US"; + const aiPredicted = + row.ai_predicted_max != null && Number.isFinite(Number(row.ai_predicted_max)) + ? Number(row.ai_predicted_max) + : null; + const aiLow = + row.ai_predicted_low != null && Number.isFinite(Number(row.ai_predicted_low)) + ? Number(row.ai_predicted_low) + : null; + const aiHigh = + row.ai_predicted_high != null && Number.isFinite(Number(row.ai_predicted_high)) + ? Number(row.ai_predicted_high) + : null; + const modelValues = Object.values(row.model_cluster_sources || {}) + .map((value) => Number(value)) + .filter((value) => Number.isFinite(value)); + const fallbackPredicted = + aiPredicted ?? + (row.deb_prediction != null && Number.isFinite(Number(row.deb_prediction)) + ? Number(row.deb_prediction) + : median(modelValues)); + const fallbackLow = aiLow ?? (modelValues.length ? Math.min(...modelValues) : fallbackPredicted); + const fallbackHigh = aiHigh ?? (modelValues.length ? Math.max(...modelValues) : fallbackPredicted); + const peakWindow = + getLocalizedRowText(row, locale, row.ai_peak_window_zh, row.ai_peak_window_en) || + formatPeakWindowTiming(row, locale); + const airportRead = + getLocalizedRowText( + row, + locale, + row.ai_airport_metar_read_zh, + row.ai_airport_metar_read_en, + ) || formatAirportReportRead(row, detail, locale, tempSymbol); + const modelNote = + row.ai_city_model_cluster_note || + row.ai_model_cluster_note || + getModelSourceSummary(row, locale, tempSymbol); + const reason = + getLocalizedRowText(row, locale, row.ai_forecast_reason_zh, row.ai_forecast_reason_en) || + getLocalizedRowText(row, locale, row.ai_city_thesis_zh, row.ai_city_thesis_en) || + (fallbackPredicted != null + ? isEn + ? `${group.cityName} final high is centered near ${formatTemperatureValue(fallbackPredicted, tempSymbol, { digits: 1 })}; use the contract rows only as bucket mapping.` + : `${group.cityName} 最终最高温先以 ${formatTemperatureValue(fallbackPredicted, tempSymbol, { digits: 1 })} 附近为中枢,下面合约只作为温度桶映射。` + : null); + return { + predicted: fallbackPredicted, + low: fallbackLow, + high: fallbackHigh, + confidence: row.ai_forecast_confidence || row.ai_city_confidence, + peakWindow, + airportRead, + reason, + modelNote, + source: aiPredicted != null ? "ai" : "fallback", + }; +} + +function getForecastRangeLabel(forecast: V4CityForecast, tempSymbol?: string | null) { + if (forecast.low == null && forecast.high == null) return "--"; + if (forecast.low != null && forecast.high != null) { + if (Math.abs(forecast.low - forecast.high) < 0.05) { + return formatTemperatureValue(forecast.low, tempSymbol, { digits: 1 }); + } + return `${formatTemperatureValue(forecast.low, tempSymbol, { digits: 1 })} ~ ${formatTemperatureValue(forecast.high, tempSymbol, { digits: 1 })}`; + } + if (forecast.low != null) return `>= ${formatTemperatureValue(forecast.low, tempSymbol, { digits: 1 })}`; + return `<= ${formatTemperatureValue(Number(forecast.high), tempSymbol, { digits: 1 })}`; +} + +function getForecastContractFit( + row: ScanOpportunityRow, + forecast: V4CityForecast, + locale: string, + tempSymbol?: string | null, +) { + const isEn = locale === "en-US"; + const explicitMatch = String(row.ai_forecast_match || "").toLowerCase(); + const explicitReason = getLocalizedRowText( + row, + locale, + row.ai_forecast_match_reason_zh, + row.ai_forecast_match_reason_en, + ); + if (explicitMatch && explicitReason) { + return { + label: + explicitMatch === "core" + ? isEn ? "Core bucket" : "核心桶" + : explicitMatch === "outside" + ? isEn ? "Outside forecast" : "预测区间外" + : explicitMatch === "edge" + ? isEn ? "Boundary bucket" : "边界桶" + : isEn ? "Watch" : "观察", + tone: explicitMatch === "core" ? "approve" : explicitMatch === "outside" ? "veto" : "watchlist", + reason: explicitReason, + }; + } + + const { lower, upper } = getTargetRange(row); + const predicted = forecast.predicted; + if (predicted == null || (lower == null && upper == null)) { + return { + label: isEn ? "Await forecast" : "等待预测", + tone: "watchlist", + reason: isEn ? "AI has no stable max-temperature center yet." : "AI 还没有稳定的最高温中枢。", + }; + } + const unit = normalizeTemperatureSymbol(tempSymbol); + const tolerance = String(unit).toUpperCase().includes("F") ? 1.0 : 0.5; + const inside = + (lower == null || predicted >= lower - tolerance) && + (upper == null || predicted <= upper + tolerance); + const rangeOverlaps = + forecast.low != null && + forecast.high != null && + (lower == null || forecast.high >= lower - tolerance) && + (upper == null || forecast.low <= upper + tolerance); + if (inside) { + return { + label: isEn ? "Core bucket" : "核心桶", + tone: "approve", + reason: isEn + ? `AI max-temperature center ${formatTemperatureValue(predicted, unit, { digits: 1 })} sits inside this bucket.` + : `AI 最高温中枢 ${formatTemperatureValue(predicted, unit, { digits: 1 })} 落在这个温度桶内。`, + }; + } + if (rangeOverlaps) { + return { + label: isEn ? "Boundary bucket" : "边界桶", + tone: "watchlist", + reason: isEn + ? `This bucket touches the AI interval ${getForecastRangeLabel(forecast, unit)}, but is not the center.` + : `该桶触及 AI 区间 ${getForecastRangeLabel(forecast, unit)},但不是预测中枢。`, + }; + } + return { + label: isEn ? "Outside forecast" : "预测区间外", + tone: "veto", + reason: isEn + ? `This bucket is outside the AI max-temperature interval ${getForecastRangeLabel(forecast, unit)}.` + : `该桶位于 AI 最高温区间 ${getForecastRangeLabel(forecast, unit)} 之外。`, + }; +} + +function firstNonEmptyPoints(...groups: Array>) { + return groups.find((group) => group.length > 0) || []; +} + +function getTargetRange(row: ScanOpportunityRow) { + const lower = + row.target_lower != null && Number.isFinite(Number(row.target_lower)) + ? Number(row.target_lower) + : null; + const upper = + row.target_upper != null && Number.isFinite(Number(row.target_upper)) + ? Number(row.target_upper) + : null; + if (lower != null || upper != null) return { lower, upper }; + + const rawLabel = String(row.target_label || row.action || ""); + const numbers = extractNumbers(rawLabel); + if (numbers.length >= 2) { + return { lower: Math.min(numbers[0], numbers[1]), upper: Math.max(numbers[0], numbers[1]) }; + } + const value = + row.target_threshold ?? + row.target_value ?? + (numbers.length ? numbers[0] : null); + if (value == null || !Number.isFinite(Number(value))) { + return { lower: null, upper: null }; + } + const numeric = Number(value); + if (/(\+|above|higher|or\s+higher|>=|≥|以上)/i.test(rawLabel)) { + return { lower: numeric, upper: null }; + } + if (/(below|or\s+below|<=|≤|以下)/i.test(rawLabel)) { + return { lower: null, upper: numeric }; + } + return { lower: numeric, upper: numeric }; +} + +function getMetarObservationContext( + row: ScanOpportunityRow, + detail: CityDetail | null, +) { + const context = row.metar_context || {}; + const metarToday = firstNonEmptyPoints( + normalizeObservationPoints(context.today_obs), + normalizeObservationPoints(row.metar_today_obs), + ); + const detailMetarToday = normalizeObservationPoints(detail?.metar_today_obs); + const metarRecent = firstNonEmptyPoints( + normalizeObservationPoints(context.recent_obs), + normalizeObservationPoints(row.metar_recent_obs), + ); + const detailMetarRecent = normalizeObservationPoints(detail?.metar_recent_obs); + const settlementToday = firstNonEmptyPoints( + normalizeObservationPoints(context.settlement_today_obs), + normalizeObservationPoints(row.settlement_today_obs), + ); + const detailSettlementToday = normalizeObservationPoints(detail?.settlement_today_obs); + + const primaryPoints = + metarToday.length + ? metarToday + : detailMetarToday.length + ? detailMetarToday + : metarRecent.length + ? metarRecent + : detailMetarRecent.length + ? detailMetarRecent + : settlementToday.length + ? settlementToday + : detailSettlementToday; + const trendPoints = + (metarRecent.length + ? metarRecent + : detailMetarRecent.length + ? detailMetarRecent + : primaryPoints.slice(-4)); + const explicitLast = context.last_temp ?? row.metar_status?.last_temp ?? detail?.metar_status?.last_temp; + const lastPoint = primaryPoints[primaryPoints.length - 1] || null; + const maxPoint = primaryPoints.reduce<{ time: string; temp: number } | null>( + (best, point) => (!best || point.temp >= best.temp ? point : best), + null, + ); + const trendFirst = trendPoints[0] || null; + const trendLast = trendPoints[trendPoints.length - 1] || null; + const trendDelta = + context.trend_delta != null && Number.isFinite(Number(context.trend_delta)) + ? Number(context.trend_delta) + : trendFirst && trendLast && trendPoints.length >= 2 + ? trendLast.temp - trendFirst.temp + : null; + const lastTemp = + explicitLast != null && Number.isFinite(Number(explicitLast)) + ? Number(explicitLast) + : lastPoint?.temp ?? null; + const maxTemp = + context.max_temp != null && Number.isFinite(Number(context.max_temp)) + ? Number(context.max_temp) + : maxPoint?.temp ?? null; + const stale = + context.stale_for_today === true || + row.metar_status?.stale_for_today === true || + detail?.metar_status?.stale_for_today === true; + + return { + points: primaryPoints, + lastTime: String(context.last_time || lastPoint?.time || ""), + lastTemp, + maxTime: String(context.max_time || maxPoint?.time || ""), + maxTemp, + trendDelta, + stale, + station: context.station || detail?.risk?.icao || detail?.airport_current?.station_code || null, + }; +} + +function getMetarGate( + row: ScanOpportunityRow, + detail: CityDetail | null, + locale: string, + tempSymbol?: string | null, +) { + const isEn = locale === "en-US"; + const side = String(row.side || "").toLowerCase(); + const selectedDate = String(row.selected_date || ""); + const localDate = String(row.local_date || detail?.local_date || ""); + const futureContract = Boolean(selectedDate && localDate && selectedDate > localDate); + if (futureContract) return null; + + const obs = getMetarObservationContext(row, detail); + const evidence: string[] = []; + const unit = normalizeTemperatureSymbol(tempSymbol); + const peakTiming = formatPeakWindowTiming(row, locale); + const airportReport = formatAirportReportRead(row, detail, locale, unit); + if (peakTiming) evidence.push(peakTiming); + if (airportReport) evidence.push(airportReport); + if (obs.lastTemp != null) { + evidence.push( + `${isEn ? "METAR latest" : "METAR 最新"} ${formatTemperatureValue(obs.lastTemp, unit, { digits: 1 })}${obs.lastTime ? ` @ ${obs.lastTime}` : ""}`, + ); + } + if (obs.maxTemp != null) { + evidence.push( + `${isEn ? "METAR max" : "METAR 最高"} ${formatTemperatureValue(obs.maxTemp, unit, { digits: 1 })}${obs.maxTime ? ` @ ${obs.maxTime}` : ""}`, + ); + } + if (obs.trendDelta != null) { + evidence.push( + `${isEn ? "Recent METAR delta" : "近端 METAR 变化"} ${formatTemperatureValue(obs.trendDelta, unit, { digits: 1 })}`, + ); + } + if (obs.station) evidence.push(`${isEn ? "Station" : "站点"} ${obs.station}`); + + if (obs.stale || !obs.points.length || obs.maxTemp == null) { + return { + decision: "downgrade" as const, + reason: isEn + ? "AI has no same-day METAR confirmation yet, so this bucket remains forecast-only." + : "AI 还没有拿到同日 METAR 确认,该桶暂时只能作为预测映射。", + evidence, + }; + } + + const { lower, upper } = getTargetRange(row); + if (lower == null && upper == null) { + return { + decision: "watchlist" as const, + reason: isEn + ? "AI has METAR data, but the contract threshold cannot be mapped cleanly to a bucket." + : "AI 已读取 METAR,但该合约阈值无法稳定映射到温度桶。", + evidence, + }; + } + + const epsilon = String(unit).toUpperCase().includes("F") ? 0.7 : 0.4; + const trendDelta = obs.trendDelta; + const isNotRising = trendDelta != null && trendDelta <= epsilon; + const isFalling = trendDelta != null && trendDelta <= -epsilon; + const phase = String(row.window_phase || "").toLowerCase(); + const remaining = + row.remaining_window_minutes != null && Number.isFinite(Number(row.remaining_window_minutes)) + ? Number(row.remaining_window_minutes) + : null; + const minutesUntilPeakStart = + row.minutes_until_peak_start != null && Number.isFinite(Number(row.minutes_until_peak_start)) + ? Number(row.minutes_until_peak_start) + : null; + const lateWindow = + phase === "active_peak" || + phase === "post_peak" || + (remaining != null && remaining <= 180); + const beforePeak = + phase === "early_today" || + phase === "setup_today" || + phase === "tomorrow" || + phase === "week_ahead" || + (minutesUntilPeakStart != null && minutesUntilPeakStart > 0); + const aboveUpper = upper != null && obs.maxTemp > upper + epsilon; + const belowLower = lower != null && obs.maxTemp < lower - epsilon; + const insideBucket = + (lower == null || obs.maxTemp >= lower - epsilon) && + (upper == null || obs.maxTemp <= upper + epsilon); + + if (side === "no") { + if (aboveUpper) { + return { + decision: "approve" as const, + reason: isEn + ? "METAR max has already moved above this bucket, so AI marks the NO bucket as observation-supported." + : "METAR 实测最高已越过目标桶上沿,AI 判断 NO 桶有实测支撑。", + evidence, + }; + } + if (belowLower && (lateWindow || isFalling || isNotRising)) { + if (beforePeak && !lateWindow) { + return { + decision: "watchlist" as const, + reason: isEn + ? "The peak window has not arrived, so a still-low METAR path cannot confirm this NO bucket yet; AI keeps it on watch." + : "峰值窗口尚未到来,METAR 暂未触达不能直接确认 NO 桶,AI 先列观察。", + evidence, + }; + } + return { + decision: "approve" as const, + reason: isEn + ? "METAR max remains below this bucket and recent observations are not strengthening, so AI favors the NO bucket." + : "METAR 最高仍低于目标桶且近期走势不强,AI 倾向 NO 桶。", + evidence, + }; + } + if (insideBucket && lateWindow && isNotRising) { + return { + decision: "downgrade" as const, + reason: isEn + ? "METAR max is still close to this bucket, so AI cannot treat the NO bucket as confirmed." + : "METAR 最高仍贴近目标桶,AI 不能把 NO 桶视为已确认。", + evidence, + }; + } + } + + if (side === "yes") { + if (aboveUpper) { + return { + decision: "veto" as const, + reason: isEn + ? "METAR max has already exceeded the bucket, so AI marks this YES bucket as outside the observed path." + : "METAR 实测最高已越过目标桶上沿,AI 判断该 YES 桶已偏离实测路径。", + evidence, + }; + } + if (belowLower && (lateWindow || isFalling || isNotRising)) { + if (beforePeak && !lateWindow) { + return { + decision: "watchlist" as const, + reason: isEn + ? "The peak window has not arrived, so METAR not reaching the bucket only means this bucket still needs peak-window confirmation." + : "峰值窗口尚未到来,METAR 未触达目标桶只能说明仍需等待峰值验证,AI 暂列观察。", + evidence, + }; + } + return { + decision: "downgrade" as const, + reason: isEn + ? "METAR max has not reached the bucket and recent observations are weak, so AI downgrades the YES bucket." + : "METAR 最高仍未触达目标桶且走势不强,AI 将 YES 桶降级观察。", + evidence, + }; + } + if (insideBucket) { + return { + decision: "approve" as const, + reason: isEn + ? "METAR max is inside the target bucket, so AI sees observation support while monitoring an overshoot." + : "METAR 实测最高已落入目标桶,AI 认为 YES 桶有实测依据,但仍需防止继续升穿上沿。", + evidence, + }; + } + } + + return { + decision: "watchlist" as const, + reason: isEn + ? "METAR does not give a clean final confirmation yet, so AI keeps this as watchlist." + : "METAR 还没有给出干净的最终确认,AI 暂列观察。", + evidence, + }; +} + +function getV4DecisionLabel( + decision: V4TradeDecision["decision"], + locale: string, +) { + if (locale === "en-US") { + if (decision === "approve") return "AI Confirmed"; + if (decision === "veto") return "AI Outside"; + if (decision === "downgrade") return "AI Downgrade"; + return "AI Watch"; + } + if (decision === "approve") return "AI 确认"; + if (decision === "veto") return "AI 区间外"; + if (decision === "downgrade") return "AI 降级"; + return "AI 观察"; +} + +function getV4TradeDecision( + row: ScanOpportunityRow, + detail: CityDetail | null, + locale: string, + edgePercent?: number | null, + tempSymbol?: string | null, +): V4TradeDecision { + const isEn = locale === "en-US"; + const backendMetarDecision = String(row.v4_metar_decision || "").toLowerCase(); + const backendMetarReason = + getLocalizedRowText(row, locale, row.v4_metar_reason_zh, row.v4_metar_reason_en) || + null; + const metarGate = getMetarGate(row, detail, locale, tempSymbol); + const aiDecision = String(row.ai_decision || "").toLowerCase(); + const aiReason = + getLocalizedRowText(row, locale, row.ai_reason_zh, row.ai_reason_en) || + getLocalizedRowText( + row, + locale, + row.ai_watchlist_reason_zh, + row.ai_watchlist_reason_en, + ); + + let decision: V4TradeDecision["decision"] = + backendMetarDecision === "veto" || + backendMetarDecision === "downgrade" || + backendMetarDecision === "approve" || + backendMetarDecision === "watchlist" + ? (backendMetarDecision as V4TradeDecision["decision"]) + : metarGate?.decision || + (aiDecision === "veto" || aiDecision === "downgrade" || aiDecision === "approve" || aiDecision === "watchlist" + ? (aiDecision as V4TradeDecision["decision"]) + : Number(edgePercent || 0) >= 20 + ? "watchlist" + : "watchlist"); + + if (metarGate?.decision === "veto") decision = "veto"; + if (metarGate?.decision === "watchlist" && backendMetarDecision === "downgrade") { + decision = "watchlist"; + } + if (metarGate?.decision === "downgrade" && decision !== "veto") decision = "downgrade"; + if (metarGate?.decision === "approve" && decision !== "veto" && decision !== "downgrade") { + decision = "approve"; + } + + const reason = + (metarGate?.decision === "watchlist" ? metarGate.reason : null) || + backendMetarReason || + metarGate?.reason || + aiReason || + (isEn + ? "AI keeps this on watch until METAR and the full weather-model cluster align." + : "AI 会等 METAR 与全量天气模型集群对齐后再确认。"); + const airportReport = formatAirportReportRead( + row, + detail, + locale, + normalizeTemperatureSymbol(tempSymbol), + ); + const metarSummary = + metarGate?.evidence?.filter((item) => item !== airportReport).join(" · ") || null; + return { + decision, + label: getV4DecisionLabel(decision, locale), + tone: decision, + reason, + metarSummary, + airportReport, + metarEvidence: metarGate?.evidence || [], + }; +} + function getBucketText(bucket: { label?: string | null; bucket?: string | null; range?: string | null }) { return [bucket.label, bucket.bucket, bucket.range] .map((value) => String(value || "").trim()) @@ -569,33 +1401,6 @@ function bucketMatchesRow( return true; } -function getDetailPeak( - detail: CityDetail | null, - row?: ScanOpportunityRow | null, - tempSymbol?: string | null, -) { - if (!detail) return null; - const view = getProbabilityView(detail, getDetailViewDate(detail, row)); - const buckets = Array.isArray(view.probabilitiesAll) - ? view.probabilitiesAll - : []; - const peak = [...buckets] - .filter((bucket) => normalizeProbability(bucket?.probability) != null) - .sort( - (a, b) => - Number(normalizeProbability(b?.probability)) - - Number(normalizeProbability(a?.probability)), - )[0]; - if (!peak) return null; - const peakValue = peak.value ?? null; - return { - label: - normalizeTemperatureLabel(peak.label || peak.bucket || peak.range, tempSymbol) || - (peakValue != null ? formatTemperatureValue(Number(peakValue), tempSymbol) : "--"), - probability: Number(normalizeProbability(peak.probability)) * 100, - }; -} - function getDetailBucketEventProbability( detail: CityDetail | null, row: ScanOpportunityRow, @@ -641,9 +1446,12 @@ function buildOpportunityGroups( ); const date = detail ? getDetailViewDate(detail, row) : row.selected_date || row.local_date || ""; const key = `${row.city || cityName}|${date}`; - const peak = getDetailPeak(detail, row, tempSymbol) || getEmosPeak(row, tempSymbol); const modelView = detail ? getModelView(detail, date) : null; const debPrediction = modelView?.deb ?? row.deb_prediction ?? null; + const modelClusterLabel = formatModelClusterRange( + modelView?.models || row.model_cluster_sources, + tempSymbol, + ); const existing = groups.get(key); if (!existing) { groups.set(key, { @@ -655,8 +1463,8 @@ function buildOpportunityGroups( debPrediction != null ? formatTemperatureValue(Number(debPrediction), tempSymbol, { digits: 1 }) : "--", - peakLabel: peak.label, - peakProbability: peak.probability, + peakLabel: modelClusterLabel, + peakProbability: null, phaseMeta: getWindowPhaseMeta(row, locale), localTime: row.local_time, remainingMinutes: row.remaining_window_minutes, @@ -665,9 +1473,8 @@ function buildOpportunityGroups( continue; } existing.rows.push(row); - if ((peak.probability ?? -1) > (existing.peakProbability ?? -1)) { - existing.peakLabel = peak.label; - existing.peakProbability = peak.probability; + if (existing.peakLabel === "--" && modelClusterLabel !== "--") { + existing.peakLabel = modelClusterLabel; } } return Array.from(groups.values()).map((group) => ({ @@ -724,6 +1531,31 @@ export const OpportunityTable = React.memo(function OpportunityTable({ }); }, []); + const ensureExpandedRow = React.useCallback((rowId: string) => { + setExpandedRowIds((current) => { + if (current.has(rowId)) return current; + const next = new Set(current); + next.add(rowId); + return next; + }); + }, []); + + const selectAndOpenRow = React.useCallback( + (row: ScanOpportunityRow) => { + ensureExpandedRow(row.id); + onSelectRow?.(row); + }, + [ensureExpandedRow, onSelectRow], + ); + + const toggleRowAnalysis = React.useCallback( + (row: ScanOpportunityRow) => { + toggleExpandedRow(row.id); + onSelectRow?.(row); + }, + [onSelectRow, toggleExpandedRow], + ); + if (!hasRows) { const title = scanInProgress @@ -778,7 +1610,7 @@ export const OpportunityTable = React.memo(function OpportunityTable({ className="scan-opportunity-group-head" onClick={() => { const firstRow = group.rows[0]; - if (firstRow) onSelectRow?.(firstRow); + if (firstRow) selectAndOpenRow(firstRow); }} >
@@ -797,15 +1629,9 @@ export const OpportunityTable = React.memo(function OpportunityTable({ {group.debLabel} - EMOS peak + {isEn ? "Model range" : "模型区间"} {group.peakLabel} - {group.peakProbability != null ? ( - - {isEn ? "Peak prob" : "峰值概率"} - {formatPercent(group.peakProbability)} - - ) : null}
@@ -843,167 +1669,192 @@ export const OpportunityTable = React.memo(function OpportunityTable({ modelProbability != null && row.ask != null ? modelProbability - Number(row.ask) * 100 : row.edge_percent; - const modelLabel = isEn ? "EMOS prob" : "EMOS 概率"; - const priceLabel = isEn ? "Market price" : "市场价格"; - const edgePositive = Number(edgePercent || 0) >= 0; + const debDistanceLabel = isEn ? "DEB distance" : "DEB 距离"; + const modelSupportLabel = isEn ? "Model support" : "模型支持"; + const metarLabel = "METAR"; + const debDistanceText = getDebDistanceSummary(row, locale, tempSymbol); + const modelSupportText = getModelSupportSummary(row, locale); + const metarConflictText = getMetarConflictSummary(row, detail, locale); + const cityForecast = getV4CityForecast( + row, + group, + detail, + locale, + tempSymbol, + ); + const forecastFit = getForecastContractFit( + row, + cityForecast, + locale, + tempSymbol, + ); + const v4Decision = getV4TradeDecision( + row, + detail, + locale, + edgePercent, + tempSymbol, + ); const aiMeta = getAiMeta(row, locale); - const strength = getOpportunityStrength(edgePercent, locale); + const fallbackStrength = getOpportunityStrength(edgePercent, locale); + const strength = { + label: forecastFit.label || fallbackStrength.label, + tone: forecastFit.tone || fallbackStrength.tone, + }; const expanded = expandedRowIds.has(row.id); - const shortConclusion = getShortAiConclusion( - row, - locale, - edgePercent, - strength.label, - ); - const recommendationReasons = getRecommendationReasons( - row, - locale, - modelProbability, - edgePercent, - row.ask, - ); - const exclusionReasons = getExclusionReasons(row, locale, edgePercent); + const shortConclusion = + forecastFit.reason || + cityForecast.reason || + getShortAiConclusion( + row, + locale, + edgePercent, + fallbackStrength.label, + ); const riskHints = getRiskHints(row, locale, modelProbability); - const thesis = + if ( + v4Decision.decision === "watchlist" && + !riskHints.includes(v4Decision.reason) + ) { + riskHints.unshift(v4Decision.reason); + } + const cityAnalysis = getLocalizedRowText( row, locale, row.ai_city_thesis_zh, row.ai_city_thesis_en, ) || + cityForecast.reason || getLocalizedRowText(row, locale, row.ai_reason_zh, row.ai_reason_en) || (isEn - ? `${group.cityName} thesis: validate this ${formatTradeSide(row, locale, tempSymbol)} against the full model cluster before sizing.` - : `${group.cityName} thesis:该 ${formatTradeSide(row, locale, tempSymbol)} 需要先结合全部模型集群确认,再考虑仓位。`); - const modelSources = formatModelSources(row, tempSymbol); + ? `${group.cityName} is judged first as a max-temperature forecast; contract rows are only bucket mappings.` + : `${group.cityName} 当前先判断最终最高温,下面合约只作为温度桶映射。`); + const cityModelNote = + cityForecast.modelNote || + row.ai_city_model_cluster_note || + row.ai_model_cluster_note || + getModelSourceSummary(row, locale, tempSymbol); + const keyReasons = Array.from( + new Set( + [ + forecastFit.reason, + cityForecast.reason, + cityForecast.peakWindow, + cityForecast.airportRead, + cityModelNote, + ].filter(Boolean), + ), + ).slice(0, 4); + const riskItems = Array.from(new Set(riskHints.filter(Boolean))).slice(0, 3); return (
onSelectRow?.(row)} + className={`scan-opportunity-item ${selectedRowId === row.id ? "selected" : ""} ${expanded ? "expanded" : ""} v4-${strength.tone} ${aiMeta ? `ai-${aiMeta.tone}` : ""}`} + onClick={() => selectAndOpenRow(row)} > - - - - {formatTradeSide(row, locale, tempSymbol)} - - - - {isEn ? "Threshold" : "阈值"} - {formatThreshold(row, tempSymbol)} - - - {modelLabel} - {formatPercent(modelProbability)} - - - {priceLabel} - {formatQuoteCents(row.ask)} - - - edge - - {formatPercent(edgePercent, true)} - - - - {strength.label} - - - - {isEn ? "AI take" : "AI 结论"} +
+ + + + {formatTradeSide(row, locale, tempSymbol)} + + +
+ + {debDistanceLabel} + {debDistanceText} + + + {modelSupportLabel} + {modelSupportText} + + + {metarLabel} + {metarConflictText} + +
+ + {strength.label} + + +
+
+ {isEn ? "AI forecast mapping" : "AI 预测映射"} {shortConclusion} - +
{expanded ? (
-
- thesis -

{thesis}

-
-
- {isEn ? "Recommendation reasons" : "推荐理由"} -
    - {recommendationReasons.map((reason) => ( -
  • {reason}
  • - ))} -
-
-
- {isEn ? "Exclusion reasons" : "排除理由"} -
    - {exclusionReasons.map((reason) => ( -
  • {reason}
  • - ))} -
-
-
- {isEn ? "Risk notes" : "风险提示"} -
    - {riskHints.map((reason) => ( -
  • {reason}
  • - ))} -
-
-
- {isEn ? "Data basis" : "数据依据"} +
- DEB {group.debLabel} - EMOS peak {group.peakLabel} - - {isEn ? "Peak prob" : "峰值概率"}{" "} - {formatPercent(group.peakProbability)} - - - {isEn ? "Ask" : "买价"} {formatQuoteCents(row.ask)} - - edge {formatPercent(edgePercent, true)} - {row.kelly_fraction != null ? ( - - Kelly {formatPercent(Number(row.kelly_fraction) * 100)} - - ) : null} + {isEn ? "AI max-temperature forecast" : "AI 最高温预测"} +

{cityAnalysis}

-
- {modelSources.length ? ( - modelSources.map((source) => ( - - {source.name} - {source.value} - - )) - ) : ( - - {isEn ? "Models" : "模型"} - - {isEn - ? "waiting for cluster" - : "等待模型集群"} - - - )} -
-
+ + {strength.label} + +
+
+ {isEn ? "Forecast" : "预测"} + + {isEn ? "Expected high" : "预计最高温"}{" "} + + {cityForecast.predicted != null + ? formatTemperatureValue(cityForecast.predicted, tempSymbol, { digits: 1 }) + : "--"} + + {" · "} + {isEn ? "interval" : "区间"} {getForecastRangeLabel(cityForecast, tempSymbol)} + {cityForecast.confidence ? ` · ${isEn ? "confidence" : "信心"} ${cityForecast.confidence}` : ""} + + {cityForecast.reason ? ( + {cityForecast.reason} + ) : null} + {cityForecast.airportRead ? ( + {cityForecast.airportRead} + ) : null} + {cityForecast.peakWindow ? ( + {cityForecast.peakWindow} + ) : null} +
+
+
+ {isEn ? "Key basis" : "关键依据"} +
    + {keyReasons.map((reason) => ( +
  • {reason}
  • + ))} +
+
+
+ {isEn ? "Risk" : "风险"} +
    + {riskItems.map((reason) => ( +
  • {reason}
  • + ))} +
+
+
) : null}
diff --git a/frontend/components/dashboard/PanelSections.tsx b/frontend/components/dashboard/PanelSections.tsx index 1fd943f9..4b358840 100644 --- a/frontend/components/dashboard/PanelSections.tsx +++ b/frontend/components/dashboard/PanelSections.tsx @@ -831,6 +831,7 @@ export function ProbabilityDistribution({ }) { const { locale, t } = useI18n(); const view = getProbabilityView(detail, targetDate); + const modelView = getModelView(detail, targetDate); const marketYesPrice = getMarketYesPrice(marketScan); const marketYesText = toPercent(marketYesPrice); const isToday = !targetDate || targetDate === detail.local_date; @@ -1216,34 +1217,26 @@ export function ProbabilityDistribution({
- {locale === "en-US" ? "Probability x Price" : "概率 x 价格联动"} + {locale === "en-US" ? "Win-rate reference" : "胜率参考"} {!marketScan ? locale === "en-US" - ? "Waiting for market layer" - : "等待市场层" + ? "Waiting for market context" + : "等待市场参照" : !marketScan.available ? locale === "en-US" ? "No matched active market" : "未匹配到活跃盘口" - : linkedMarketBucket - ? locale === "en-US" - ? `${actionText}: ${linkedContractLabel || "contract bucket"}` - : `${actionText}:${linkedContractLabel || "合约桶"}` - : preferredPriceView?.edge != null - ? locale === "en-US" - ? `${actionText} · edge ${formatSignedPercent(preferredPriceView.edge)}` - : `${actionText} · 优势 ${formatSignedPercent(preferredPriceView.edge)}` - : locale === "en-US" - ? "Waiting for executable quote" - : "等待可执行报价"} + : locale === "en-US" + ? `${linkedContractLabel || topProbabilityLabel || "Temperature bucket"} · model ${linkedMarketProbabilityText || topProbabilityText || "--"}` + : `${linkedContractLabel || topProbabilityLabel || "温度桶"} · 模型 ${linkedMarketProbabilityText || topProbabilityText || "--"}`}
- {locale === "en-US" ? "Contract bucket" : "合约桶口径"} + {locale === "en-US" ? "Bucket" : "温度桶"} {linkedContractLabel || topProbabilityLabel || "--"} @@ -1253,33 +1246,29 @@ export function ProbabilityDistribution({
- {locale === "en-US" ? "Candidate" : "可关注"} - {actionText} - {actionNote} + {locale === "en-US" ? "DEB" : "DEB"} + + {modelView.deb != null && Number.isFinite(Number(modelView.deb)) + ? `${Number(modelView.deb).toFixed(1)}${detail.temp_symbol}` + : "--"} + + {locale === "en-US" ? "final fused forecast" : "最终融合预测"}
- {locale === "en-US" ? "YES price" : "YES 价格"} - {toPriceCents(yesDisplayPrice) || "--"} - - {locale === "en-US" - ? `edge ${formatSignedPercent(yesDisplayEdge)}` - : `优势 ${formatSignedPercent(yesDisplayEdge)}`} - + {locale === "en-US" ? "Model support" : "模型支持"} + {modelVoteHint || "--"} + {locale === "en-US" ? "raw model agreement" : "原始模型一致性"}
- {locale === "en-US" ? "NO price" : "NO 价格"} - {toPriceCents(noDisplayPrice) || "--"} - - {locale === "en-US" - ? `edge ${formatSignedPercent(noDisplayEdge)}` - : `优势 ${formatSignedPercent(noDisplayEdge)}`} - + {locale === "en-US" ? "Market role" : "盘口角色"} + {locale === "en-US" ? "Reference only" : "仅作参考"} + {quoteSourceLabel}

{locale === "en-US" - ? `Read-only comparison between model probability and executable ask; it does not place orders. Source: ${quoteSourceLabel}${lockAvailable ? ` · lock ${formatSignedPercent(lockEdge)}` : ""}.` - : `只比较模型概率与可执行买价;系统不会下单。来源:${quoteSourceLabel}${lockAvailable ? ` · 锁价 ${formatSignedPercent(lockEdge)}` : ""}。`} + ? "This card follows the same rule as the opportunity list: DEB first, model agreement second, METAR conflict check before settlement." + : "该卡片与机会列表口径一致:先看 DEB,再看模型支持,最后检查 METAR 是否冲突。"}

)} diff --git a/frontend/components/dashboard/ScanTerminalDashboard.tsx b/frontend/components/dashboard/ScanTerminalDashboard.tsx index b638cb14..cd8eb4df 100644 --- a/frontend/components/dashboard/ScanTerminalDashboard.tsx +++ b/frontend/components/dashboard/ScanTerminalDashboard.tsx @@ -701,7 +701,7 @@ function ScanTerminalScreen() { const [aiError, setAiError] = useState(null); const [error, setError] = useState(null); const [selectedRowId, setSelectedRowId] = useState(null); - const [activeView, setActiveView] = useState("list"); + const [activeView, setActiveView] = useState("map"); const [mapSelectedCityName, setMapSelectedCityName] = useState(null); const [showScanPaywall, setShowScanPaywall] = useState(false); const [aiLogs, setAiLogs] = useState([]); @@ -1000,7 +1000,7 @@ function ScanTerminalScreen() { .slice(0, 12); cities.forEach((cityName) => { - void store.ensureCityDetail(cityName, true, "market").catch(() => {}); + void store.ensureCityDetail(cityName, false, "market").catch(() => {}); }); }, [ isPro, @@ -1086,12 +1086,26 @@ function ScanTerminalScreen() { setMapSelectedCityName(cityName); const matchedRow = findRowForCity(timeSortedRows, cityName); setSelectedRowId(matchedRow?.id || null); - void store.selectCity(cityName); - }, [store, timeSortedRows]); + }, [timeSortedRows]); const handleSelectRow = useCallback((row: ScanOpportunityRow) => { + const cityName = row.city || row.city_display_name || row.display_name || ""; + if (!cityName) return; setSelectedRowId(row.id); - void store.selectCity(row.city); + const selectedCityKey = normalizeCityKey(store.selectedCity); + const rowCityKey = normalizeCityKey(cityName); + const hasCachedDetail = + Boolean(store.cityDetailsByName[cityName]) || + Object.values(store.cityDetailsByName).some((detail) => + rowMatchesCity(row, detail?.name || detail?.display_name || ""), + ); + if (store.isPanelOpen && selectedCityKey === rowCityKey) { + if (!hasCachedDetail) { + void store.ensureCityDetail(cityName, false, "panel").catch(() => {}); + } + return; + } + void store.selectCity(cityName); }, [store]); const openScanPaywall = useCallback(() => { diff --git a/frontend/hooks/useDashboardStore.tsx b/frontend/hooks/useDashboardStore.tsx index fa540db0..c88ebb2d 100644 --- a/frontend/hooks/useDashboardStore.tsx +++ b/frontend/hooks/useDashboardStore.tsx @@ -18,6 +18,10 @@ import { getSupabaseBrowserClient, hasSupabasePublicEnv, } from "@/lib/supabase/client"; +import { + getLocalDevProAccessState, + isBrowserLocalFullAccess, +} from "@/lib/local-dev-access"; import { CityDetail, CityListItem, @@ -94,6 +98,9 @@ function getInitialHistoryState(): HistoryState { } function getInitialProAccessState(): ProAccessState { + if (isBrowserLocalFullAccess()) { + return getLocalDevProAccessState(); + } return { loading: true, authenticated: false, @@ -705,6 +712,10 @@ export function DashboardStoreProvider({ }; const refreshProAccess = async () => { + if (isBrowserLocalFullAccess()) { + setProAccess(getLocalDevProAccessState()); + return; + } setProAccess((current) => ({ ...current, loading: true, @@ -870,10 +881,14 @@ export function DashboardStoreProvider({ ]); const selectCity = async (cityName: string) => { + const wasSelectedCity = selectedCityRef.current === cityName; + const cached = cityDetailsByName[cityName]; selectedCityRef.current = cityName; setSelectedCity(cityName); setIsPanelOpen(true); - setSelectedForecastDate(null); + setSelectedForecastDate( + cached?.local_date || (wasSelectedCity ? selectedForecastDate : null), + ); setFutureModalDate(null); setForecastModalMode(null); @@ -898,9 +913,10 @@ export function DashboardStoreProvider({ return; } - const needsDetailRefresh = true; - setLoadingState((current) => ({ ...current, cityDetail: true })); - const detailPromise = ensureCityDetail(cityName, needsDetailRefresh, "panel"); + if (!cached) { + setLoadingState((current) => ({ ...current, cityDetail: true })); + } + const detailPromise = ensureCityDetail(cityName, false, "panel"); void Promise.allSettled([summaryPromise, detailPromise]) .then(([, detail]) => { if (selectedCityRef.current !== cityName) return; @@ -910,7 +926,9 @@ export function DashboardStoreProvider({ }) .finally(() => { if (selectedCityRef.current !== cityName) return; - setLoadingState((current) => ({ ...current, cityDetail: false })); + if (!cached) { + setLoadingState((current) => ({ ...current, cityDetail: false })); + } }); }; diff --git a/frontend/lib/dashboard-types.ts b/frontend/lib/dashboard-types.ts index 6321f86f..00920174 100644 --- a/frontend/lib/dashboard-types.ts +++ b/frontend/lib/dashboard-types.ts @@ -455,6 +455,43 @@ export interface ScanOpportunityRow { temp_symbol?: string | null; current_temp?: number | null; current_max_so_far?: number | null; + metar_context?: { + source?: string | null; + station?: string | null; + station_label?: string | null; + obs_count?: number | null; + last_time?: string | null; + last_temp?: number | null; + max_temp?: number | null; + max_time?: string | null; + trend_delta?: number | null; + stale_for_today?: boolean; + available_for_today?: boolean; + last_observation_time?: string | null; + airport_current_temp?: number | null; + airport_max_so_far?: number | null; + airport_obs_time?: string | null; + airport_report_time?: string | null; + airport_raw_metar?: string | null; + airport_wx_desc?: string | null; + airport_cloud_desc?: string | null; + airport_visibility_mi?: number | null; + airport_wind_speed_kt?: number | null; + airport_wind_dir?: number | null; + airport_humidity?: number | null; + today_obs?: Array<{ time?: string; temp?: number | null }>; + recent_obs?: Array<{ time?: string; temp?: number | null }>; + settlement_today_obs?: Array<{ time?: string; temp?: number | null }>; + } | null; + metar_recent_obs?: Array<{ time?: string; temp?: number | null }>; + metar_today_obs?: Array<{ time?: string; temp?: number | null }>; + settlement_today_obs?: Array<{ time?: string; temp?: number | null }>; + metar_status?: { + available_for_today?: boolean; + stale_for_today?: boolean; + last_observation_time?: string | null; + last_temp?: number | null; + } | null; deb_prediction?: number | null; airport?: string | null; risk_level?: RiskLevel | null; @@ -559,8 +596,25 @@ export interface ScanOpportunityRow { ai_city_thesis_en?: string | null; ai_city_confidence?: string | null; ai_city_model_cluster_note?: string | null; + ai_predicted_max?: number | null; + ai_predicted_low?: number | null; + ai_predicted_high?: number | null; + ai_forecast_unit?: string | null; + ai_forecast_confidence?: string | null; + ai_peak_window_zh?: string | null; + ai_peak_window_en?: string | null; + ai_airport_metar_read_zh?: string | null; + ai_airport_metar_read_en?: string | null; + ai_forecast_reason_zh?: string | null; + ai_forecast_reason_en?: string | null; + ai_forecast_match?: "core" | "edge" | "outside" | "watch" | string | null; + ai_forecast_match_reason_zh?: string | null; + ai_forecast_match_reason_en?: string | null; ai_watchlist_reason_zh?: string | null; ai_watchlist_reason_en?: string | null; + v4_metar_decision?: "approve" | "veto" | "downgrade" | "watchlist" | string | null; + v4_metar_reason_zh?: string | null; + v4_metar_reason_en?: string | null; } export interface PrimarySignal extends ScanOpportunityRow {} diff --git a/frontend/lib/local-dev-access.ts b/frontend/lib/local-dev-access.ts new file mode 100644 index 00000000..d6664070 --- /dev/null +++ b/frontend/lib/local-dev-access.ts @@ -0,0 +1,83 @@ +import type { ProAccessState } from "@/lib/dashboard-types"; + +const LOCAL_DEV_EXPIRY = "2099-12-31T23:59:59Z"; +const LOCAL_DEV_POINTS = 999_999; + +function readLocalAccessFlag() { + const raw = + process.env.NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS ?? + process.env.POLYWEATHER_LOCAL_FULL_ACCESS; + if (raw == null) return true; + return !/^(0|false|off|no)$/i.test(String(raw).trim()); +} + +export function isLocalHostname(value?: string | null) { + const normalized = String(value || "") + .trim() + .toLowerCase() + .replace(/^\[/, "") + .replace(/\]$/, ""); + return ( + normalized === "localhost" || + normalized === "127.0.0.1" || + normalized === "::1" + ); +} + +export function extractHostname(value?: string | null) { + const firstHost = String(value || "") + .split(",")[0] + .trim(); + if (!firstHost) return ""; + try { + return new URL( + firstHost.includes("://") ? firstHost : `http://${firstHost}`, + ).hostname; + } catch { + if (firstHost.startsWith("[")) { + const endIndex = firstHost.indexOf("]"); + return endIndex > 0 ? firstHost.slice(1, endIndex) : firstHost; + } + return firstHost.split(":")[0] || firstHost; + } +} + +export function isLocalFullAccessHost(value?: string | null) { + return readLocalAccessFlag() && isLocalHostname(extractHostname(value)); +} + +export function isBrowserLocalFullAccess() { + if (typeof window === "undefined") return false; + return readLocalAccessFlag() && isLocalHostname(window.location.hostname); +} + +export function getLocalDevAuthPayload() { + return { + authenticated: true, + user_id: "local-dev", + email: "local-dev@polyweather.local", + subscription_active: true, + subscription_plan_code: "local-full-access", + subscription_expires_at: LOCAL_DEV_EXPIRY, + subscription_total_expires_at: LOCAL_DEV_EXPIRY, + subscription_queued_days: 0, + subscription_queued_count: 0, + points: LOCAL_DEV_POINTS, + local_dev_full_access: true, + }; +} + +export function getLocalDevProAccessState(): ProAccessState { + return { + loading: false, + authenticated: true, + userId: "local-dev", + subscriptionActive: true, + subscriptionPlanCode: "local-full-access", + subscriptionExpiresAt: LOCAL_DEV_EXPIRY, + subscriptionTotalExpiresAt: LOCAL_DEV_EXPIRY, + subscriptionQueuedDays: 0, + points: LOCAL_DEV_POINTS, + error: null, + }; +} diff --git a/frontend/middleware.ts b/frontend/middleware.ts index 7677f31b..6ca128e0 100644 --- a/frontend/middleware.ts +++ b/frontend/middleware.ts @@ -3,6 +3,7 @@ import { createSupabaseMiddlewareClient, hasSupabaseServerEnv, } from "@/lib/supabase/server"; +import { isLocalFullAccessHost } from "@/lib/local-dev-access"; const SESSION_COOKIE = "polyweather_entitlement"; @@ -181,6 +182,16 @@ export async function middleware(request: NextRequest) { if (isStaticAsset(pathname)) { return NextResponse.next(); } + const requestHost = + request.headers.get("x-forwarded-host") || + request.headers.get("host") || + request.nextUrl.host; + if ( + isLocalFullAccessHost(requestHost) || + isLocalFullAccessHost(request.nextUrl.hostname) + ) { + return NextResponse.next(); + } if (SUPABASE_AUTH_ENABLED && hasSupabaseServerEnv()) { if (SUPABASE_AUTH_REQUIRED) { diff --git a/web/scan_terminal_service.py b/web/scan_terminal_service.py index c427b401..1af62594 100644 --- a/web/scan_terminal_service.py +++ b/web/scan_terminal_service.py @@ -2,6 +2,7 @@ from __future__ import annotations import json import os +import re import threading import time import hashlib @@ -44,7 +45,7 @@ SCAN_AI_TIMEOUT_SEC = max( ) SCAN_AI_CACHE_TTL_SEC = max( 30, - int(os.getenv("POLYWEATHER_SCAN_AI_CACHE_TTL_SEC", "600")), + int(os.getenv("POLYWEATHER_SCAN_AI_CACHE_TTL_SEC", "120")), ) SCAN_AI_MAX_ROWS = max( 1, @@ -52,7 +53,7 @@ SCAN_AI_MAX_ROWS = max( ) SCAN_AI_MAX_TOKENS = max( 600, - int(os.getenv("POLYWEATHER_SCAN_AI_MAX_TOKENS", "1600")), + int(os.getenv("POLYWEATHER_SCAN_AI_MAX_TOKENS", "3200")), ) @@ -321,6 +322,7 @@ def _extract_ai_json_object(raw_text: str) -> Dict[str, Any]: def _scan_ai_cache_key(snapshot_id: str, filters: Dict[str, Any]) -> str: raw = json.dumps( { + "schema_version": "city_forecast_v1", "snapshot_id": snapshot_id, "filters": _normalize_scan_terminal_filters(filters), "model": SCAN_AI_MODEL, @@ -366,21 +368,20 @@ def _compact_ai_candidate(row: Dict[str, Any]) -> Dict[str, Any]: "target_value": row.get("target_value"), "target_threshold": row.get("target_threshold"), "target_unit": row.get("target_unit"), - "model_probability": row.get("model_probability"), "market_probability": row.get("market_probability"), - "model_event_probability": row.get("model_event_probability"), "market_event_probability": row.get("market_event_probability"), "yes_ask": row.get("yes_ask"), "no_ask": row.get("no_ask"), "ask": row.get("ask"), "spread": row.get("spread"), "quote_age_ms": row.get("quote_age_ms"), - "edge_percent": row.get("edge_percent"), - "kelly_fraction": row.get("kelly_fraction"), - "quarter_kelly": row.get("quarter_kelly"), - "final_score": row.get("final_score"), "cluster_role": row.get("cluster_role"), "model_cluster_sources": _compact_ai_model_sources(row), + "metar_context": row.get("metar_context") or {}, + "window_phase": row.get("window_phase"), + "peak_window_label": row.get("peak_window_label"), + "minutes_until_peak_start": row.get("minutes_until_peak_start"), + "minutes_until_peak_end": row.get("minutes_until_peak_end"), "trend_alignment": row.get("trend_alignment"), "tradable": row.get("tradable"), "accepting_orders": row.get("accepting_orders"), @@ -424,15 +425,246 @@ def _compact_ai_model_sources(row: Dict[str, Any]) -> List[Dict[str, Any]]: return sources[:12] +def _observation_sort_key(point: Dict[str, Any]) -> tuple[int, str]: + raw_time = str(point.get("time") or "").strip() + try: + parsed = datetime.fromisoformat(raw_time.replace("Z", "+00:00")) + return parsed.hour * 60 + parsed.minute, raw_time + except Exception: + pass + match = re.search(r"(\d{1,2}):(\d{2})", raw_time) + if match: + hour = max(0, min(23, int(match.group(1)))) + minute = max(0, min(59, int(match.group(2)))) + return hour * 60 + minute, raw_time + return 9999, raw_time + + +def _compact_observation_points(raw_points: Any, limit: int = 24) -> List[Dict[str, Any]]: + if not isinstance(raw_points, list): + return [] + points: List[Dict[str, Any]] = [] + for item in raw_points: + if isinstance(item, dict): + temp = _safe_float(item.get("temp")) + time_value = str(item.get("time") or item.get("obs_time") or item.get("time_label") or "").strip() + elif isinstance(item, (list, tuple)) and len(item) >= 2: + time_value = str(item[0] or "").strip() + temp = _safe_float(item[1]) + else: + continue + if temp is None or not time_value: + continue + points.append({"time": time_value, "temp": temp}) + sorted_points = sorted(points, key=_observation_sort_key) + return sorted_points[-max(1, int(limit)) :] + + +def _build_metar_decision_context(data: Dict[str, Any]) -> Dict[str, Any]: + today_obs = _compact_observation_points(data.get("metar_today_obs"), 36) + recent_obs = _compact_observation_points(data.get("metar_recent_obs"), 12) + settlement_obs = _compact_observation_points(data.get("settlement_today_obs"), 36) + airport_current = data.get("airport_current") if isinstance(data.get("airport_current"), dict) else {} + metar_status = data.get("metar_status") if isinstance(data.get("metar_status"), dict) else {} + + source_obs = today_obs or recent_obs or settlement_obs + trend_source = recent_obs or source_obs[-4:] + last_point = source_obs[-1] if source_obs else {} + first_trend = trend_source[0] if trend_source else {} + last_trend = trend_source[-1] if trend_source else {} + max_point = None + for point in source_obs: + if max_point is None or float(point["temp"]) >= float(max_point["temp"]): + max_point = point + + last_temp = _safe_float(last_point.get("temp")) + first_temp = _safe_float(first_trend.get("temp")) + trend_last_temp = _safe_float(last_trend.get("temp")) + trend_delta = ( + trend_last_temp - first_temp + if trend_last_temp is not None and first_temp is not None and len(trend_source) >= 2 + else None + ) + station = data.get("risk") if isinstance(data.get("risk"), dict) else {} + return { + "source": "METAR", + "station": station.get("icao") or airport_current.get("station_code"), + "station_label": station.get("airport") or airport_current.get("station_label"), + "today_obs": today_obs[-12:], + "recent_obs": recent_obs[-8:], + "settlement_today_obs": settlement_obs[-12:], + "obs_count": len(source_obs), + "last_time": last_point.get("time"), + "last_temp": last_temp, + "max_temp": _safe_float((max_point or {}).get("temp")), + "max_time": (max_point or {}).get("time"), + "trend_delta": trend_delta, + "stale_for_today": bool(metar_status.get("stale_for_today")), + "available_for_today": bool(metar_status.get("available_for_today")), + "last_observation_time": metar_status.get("last_observation_time"), + "airport_current_temp": _safe_float(airport_current.get("temp")), + "airport_max_so_far": _safe_float(airport_current.get("max_so_far")), + "airport_obs_time": airport_current.get("obs_time"), + "airport_report_time": airport_current.get("report_time"), + "airport_raw_metar": airport_current.get("raw_metar"), + "airport_wx_desc": airport_current.get("wx_desc"), + "airport_cloud_desc": airport_current.get("cloud_desc"), + "airport_visibility_mi": _safe_float(airport_current.get("visibility_mi")), + "airport_wind_speed_kt": _safe_float(airport_current.get("wind_speed_kt")), + "airport_wind_dir": _safe_float(airport_current.get("wind_dir")), + "airport_humidity": _safe_float(airport_current.get("humidity")), + } + + +def _target_range_from_row(row: Dict[str, Any]) -> tuple[Optional[float], Optional[float]]: + lower = _safe_float(row.get("target_lower")) + upper = _safe_float(row.get("target_upper")) + if lower is not None or upper is not None: + return lower, upper + threshold = _safe_float(row.get("target_threshold")) + target_value = _safe_float(row.get("target_value")) + raw_label = str(row.get("target_label") or row.get("action") or "") + numbers = [float(match.group(0)) for match in re.finditer(r"-?\d+(?:\.\d+)?", raw_label)] + if len(numbers) >= 2: + return min(numbers[0], numbers[1]), max(numbers[0], numbers[1]) + value = threshold if threshold is not None else target_value if target_value is not None else (numbers[0] if numbers else None) + if value is None: + return None, None + if re.search(r"(\+|above|higher|or\s+higher|>=|≥|以上)", raw_label, re.I): + return value, None + if re.search(r"(below|or\s+below|<=|≤|以下)", raw_label, re.I): + return None, value + return value, value + + +def _metar_gate_for_row(row: Dict[str, Any]) -> Optional[Dict[str, Any]]: + context = row.get("metar_context") if isinstance(row.get("metar_context"), dict) else {} + side = str(row.get("side") or "").strip().lower() + if side not in {"yes", "no"}: + return None + obs_count = _safe_int(context.get("obs_count"), 0) + if obs_count <= 0 or context.get("stale_for_today"): + return { + "decision": "downgrade", + "reason_zh": "V4 未拿到同日 METAR 实测,不能只凭 edge/Kelly 给出交易。", + "reason_en": "V4 has no same-day METAR observations, so edge/Kelly alone cannot drive a trade.", + } + + lower, upper = _target_range_from_row(row) + max_temp = _safe_float(context.get("max_temp")) + last_temp = _safe_float(context.get("last_temp")) + trend_delta = _safe_float(context.get("trend_delta")) + if max_temp is None or (lower is None and upper is None): + return None + + unit = str(row.get("target_unit") or row.get("temp_symbol") or "") + epsilon = 0.7 if "F" in unit.upper() else 0.4 + phase = str(row.get("window_phase") or "").lower() + remaining = _safe_float(row.get("remaining_window_minutes")) + minutes_until_peak_start = _safe_float(row.get("minutes_until_peak_start")) + is_late = phase in {"active_peak", "post_peak"} or (remaining is not None and remaining <= 180) + is_before_peak = phase in {"early_today", "setup_today", "tomorrow", "week_ahead"} or ( + minutes_until_peak_start is not None and minutes_until_peak_start > 0 + ) + is_falling = trend_delta is not None and trend_delta <= -epsilon + is_not_rising = trend_delta is not None and trend_delta <= epsilon + + above_upper = upper is not None and max_temp > upper + epsilon + below_lower = lower is not None and max_temp < lower - epsilon + inside_bucket = ( + (lower is None or max_temp >= lower - epsilon) + and (upper is None or max_temp <= upper + epsilon) + ) + + if side == "no": + if above_upper: + return { + "decision": "approve", + "reason_zh": "METAR 实测最高已越过目标桶上沿,V4 确认 BUY NO 有实测支撑。", + "reason_en": "METAR max has already moved above the bucket, so V4 confirms BUY NO has observation support.", + } + if below_lower and (is_late or is_falling or is_not_rising): + if is_before_peak and not is_late: + return { + "decision": "watchlist", + "reason_zh": "峰值窗口尚未到来,METAR 暂未触达不能直接确认 BUY NO,V4 先列观察。", + "reason_en": "The peak window has not arrived, so a still-low METAR path cannot confirm BUY NO yet; V4 keeps it on watch.", + } + return { + "decision": "approve", + "reason_zh": "METAR 最高仍低于目标桶且近期走势不强,V4 确认 BUY NO 优先。", + "reason_en": "METAR max remains below the bucket and recent observations are not strengthening, so V4 favors BUY NO.", + } + if inside_bucket and is_late and is_not_rising: + return { + "decision": "downgrade", + "reason_zh": "METAR 最高仍贴近目标桶,V4 不允许只因 edge 高就直接交易 NO。", + "reason_en": "METAR max is still close to the target bucket, so V4 will not trade NO on edge alone.", + } + else: + if above_upper: + return { + "decision": "veto", + "reason_zh": "METAR 实测最高已越过目标桶上沿,V4 排除该 BUY YES。", + "reason_en": "METAR max has already exceeded the bucket, so V4 vetoes this BUY YES.", + } + if below_lower and (is_late or is_falling or is_not_rising): + if is_before_peak and not is_late: + return { + "decision": "watchlist", + "reason_zh": "峰值窗口尚未到来,METAR 未触达目标桶只能说明仍需等待峰值验证,V4 暂列观察。", + "reason_en": "The peak window has not arrived, so METAR not reaching the bucket only means the setup still needs peak-window confirmation; V4 keeps it on watch.", + } + return { + "decision": "downgrade", + "reason_zh": "METAR 最高仍未触达目标桶且走势不强,V4 将 BUY YES 降级观察。", + "reason_en": "METAR max has not reached the bucket and recent observations are weak, so V4 downgrades BUY YES.", + } + if inside_bucket: + return { + "decision": "approve", + "reason_zh": "METAR 实测最高已落入目标桶,V4 认为 BUY YES 有实测依据,但仍需防止继续升穿上沿。", + "reason_en": "METAR max is inside the target bucket, so V4 sees observation support for BUY YES while monitoring an overshoot.", + } + if last_temp is not None and trend_delta is not None: + direction = "走弱" if trend_delta < -epsilon else "走强" if trend_delta > epsilon else "横盘" + return { + "decision": "watchlist", + "reason_zh": f"METAR 最新 {last_temp:.1f},近期{direction},V4 暂不把该合约升级为最终交易。", + "reason_en": f"Latest METAR is {last_temp:.1f} with a recent {'downtrend' if trend_delta < -epsilon else 'uptrend' if trend_delta > epsilon else 'flat trend'}, so V4 keeps this as watchlist.", + } + return None + + +def _apply_metar_gate_to_row(row: Dict[str, Any]) -> None: + gate = _metar_gate_for_row(row) + if not gate: + return + decision = str(gate.get("decision") or "").lower() + row["v4_metar_decision"] = decision + row["v4_metar_reason_zh"] = gate.get("reason_zh") + row["v4_metar_reason_en"] = gate.get("reason_en") + + current_decision = str(row.get("ai_decision") or "").lower() + hard_decisions = {"veto", "downgrade"} + if decision == "veto": + row["ai_decision"] = "veto" + row.pop("ai_rank", None) + elif decision == "downgrade" and current_decision != "veto": + row["ai_decision"] = "downgrade" + row.pop("ai_rank", None) + elif decision == "approve" and current_decision not in hard_decisions: + row["ai_decision"] = "approve" + elif decision == "watchlist" and current_decision not in {"approve", "veto", "downgrade"}: + row["ai_decision"] = "watchlist" + + if decision in {"approve", "veto", "downgrade"}: + row["ai_reason_zh"] = gate.get("reason_zh") or row.get("ai_reason_zh") + row["ai_reason_en"] = gate.get("reason_en") or row.get("ai_reason_en") + + def _compact_ai_city_group(rows: List[Dict[str, Any]]) -> Dict[str, Any]: first = rows[0] - distribution = _compact_ai_distribution(first) - peak = next((item for item in distribution if item.get("highlighted")), None) - if not peak and distribution: - peak = max( - distribution, - key=lambda item: _safe_float(item.get("model_probability")) or -1.0, - ) return { "city": first.get("city"), "city_display_name": first.get("city_display_name") or first.get("display_name") or first.get("city"), @@ -444,12 +676,10 @@ def _compact_ai_city_group(rows: List[Dict[str, Any]]) -> Dict[str, Any]: "deb_prediction": first.get("deb_prediction"), "window_phase": first.get("window_phase"), "remaining_window_minutes": first.get("remaining_window_minutes"), - "emos_distribution": distribution, - "emos_peak": { - "label": (peak or {}).get("label"), - "value": (peak or {}).get("value"), - "probability": (peak or {}).get("model_probability"), - }, + "peak_window_label": first.get("peak_window_label"), + "minutes_until_peak_start": first.get("minutes_until_peak_start"), + "minutes_until_peak_end": first.get("minutes_until_peak_end"), + "metar_context": first.get("metar_context") or {}, "model_cluster": { "core_low": first.get("cluster_core_low"), "core_high": first.get("cluster_core_high"), @@ -480,7 +710,7 @@ def _build_scan_ai_prompt(payload: Dict[str, Any]) -> Dict[str, Any]: cities = [_compact_ai_city_group(rows) for rows in grouped.values() if rows] sent_contracts = sum(len(city.get("contracts") or []) for city in cities) return { - "schema_version": "city_group_v2", + "schema_version": "city_forecast_v1", "snapshot_id": payload.get("snapshot_id"), "generated_at": payload.get("generated_at"), "summary": payload.get("summary") or {}, @@ -501,28 +731,33 @@ def _call_deepseek_scan_ai(ai_input: Dict[str, Any]) -> Dict[str, Any]: raise RuntimeError("POLYWEATHER_DEEPSEEK_API_KEY is not configured") system_prompt = ( - "你是 PolyWeather 的付费 V4-Flash 市场扫描员。你只能基于用户提供的 JSON 快照做判断," - "不得编造城市、价格、概率、盘口或天气数据。输入已经按城市分组,每城包含 EMOS 分布、" - "DEB、模型集群、当前实测和候选合约。你的任务不是复述 edge,而是形成 city thesis," - "再决定哪些合约值得推荐、哪些必须排除、哪些只降级观察。" - "重点规则:最高温市场一天只会落入一个桶;如果 DEB/模型集群/EMOS 主峰集中在更高温区," - "低温 YES 即使有表面 edge 也通常应 veto,优先考虑相邻尾部 NO;若价格过期、盘口太宽或窗口不支持,也要降级。" - "任何基于 edge 或 Kelly/仓位的推荐,都必须先参考该城市全部 model_cluster.sources、DEB 和 EMOS 分布;" - "不得只凭单一 EMOS 概率、单一模型或表面 edge 推荐。" - "只能引用输入中的 row id。必须输出 JSON object。" + "你是 PolyWeather 的付费 V4-Flash 城市最高温预测员。你只能基于用户提供的 JSON 快照做判断," + "不得编造城市、价格、概率、盘口或天气数据。输入已经按城市分组,每城包含 DEB、" + "多个天气模型预测值 model_cluster.sources、METAR 实测序列、机场原始报文和候选合约。" + "你的首要任务不是分析套利,也不是推荐 BUY YES/NO,而是预测该城市今日最终最高温是多少。" + "必须输出城市级最高温点估计、置信区间、置信度、峰值窗口状态、机场报文解读和一句预测理由。" + "V4 禁止使用 EMOS、EMOS peak、EMOS probability、edge 或 Kelly 作为交易依据;" + "最高温预测必须直接参考该城市全部 model_cluster.sources、DEB、峰值窗口和 METAR/机场报文。" + "如果天气模型之间分歧大,必须放宽置信区间并降低 confidence;如果 METAR 与模型路径冲突,必须解释修正方向。" + "必须先判断 peak_window_label、minutes_until_peak_start/end 和 window_phase:峰值窗口尚未到来时," + "不能因为 METAR 暂未触达目标温度就下最终结论,只能说明仍需峰值窗口验证;" + "必须检查 metar_context 的 today_obs/recent_obs、max_temp、last_temp、trend_delta、" + "airport_raw_metar、airport_wx_desc、airport_cloud_desc、airport_wind_* 和 stale 状态;" + "合约只作为下游映射:可以为每个候选 row_id 给出 forecast_match(core/edge/outside/watch)和一句原因," + "但不要输出交易建议,不要使用套利、仓位、edge 或 Kelly 语言。必须输出 JSON object。" ) model_snapshot = dict(ai_input) model_snapshot.pop("_polyweather_input_meta", None) user_payload = { "task": ( - "Analyze each city first, then contract decisions. Return strict JSON only with: " - "summary_zh, summary_en, city_theses, recommendations, vetoed, downgraded, watchlist. " - "city_theses items need city, thesis_zh, thesis_en, model_cluster_note, confidence, " - "recommended_row_ids, vetoed_row_ids. recommendations items need row_id, rank, decision, " - "confidence, reason_zh, reason_en, model_cluster_note. vetoed/downgraded items need row_id, " - "reason_zh/reason_en. watchlist items are optional and need row_id plus reason. " - "If a recommendation cites edge or Kelly, the reason must mention the full model cluster consensus or divergence. " - "Keep every thesis and reason concise: one sentence only, no markdown, no repeated data tables." + "Return strict JSON only with: summary_zh, summary_en, city_forecasts, contract_notes. " + "city_forecasts items require city, predicted_max, range_low, range_high, unit, confidence, " + "peak_window_zh, peak_window_en, metar_read_zh, metar_read_en, reasoning_zh, reasoning_en, model_cluster_note. " + "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. " + "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." ), "snapshot": model_snapshot, } @@ -598,6 +833,43 @@ def _normalize_ai_city_theses(raw_items: Any) -> List[Dict[str, Any]]: return out +def _normalize_ai_city_forecasts(ai_raw: Dict[str, Any]) -> List[Dict[str, Any]]: + raw_items = ( + ai_raw.get("city_forecasts") + or ai_raw.get("city_predictions") + or ai_raw.get("city_max_forecasts") + or ai_raw.get("city_theses") + ) + if not isinstance(raw_items, list): + return [] + out: List[Dict[str, Any]] = [] + for item in raw_items: + if not isinstance(item, dict): + continue + city = str(item.get("city") or item.get("city_name") or "").strip() + if not city: + continue + predicted = ( + item.get("predicted_max") + if item.get("predicted_max") is not None + else item.get("max_temp") + if item.get("max_temp") is not None + else item.get("prediction") + ) + out.append( + { + **item, + "city": city, + "predicted_max": predicted, + "range_low": item.get("range_low") if item.get("range_low") is not None else item.get("low"), + "range_high": item.get("range_high") if item.get("range_high") is not None else item.get("high"), + "reasoning_zh": item.get("reasoning_zh") or item.get("thesis_zh") or item.get("summary_zh"), + "reasoning_en": item.get("reasoning_en") or item.get("thesis_en") or item.get("summary_en"), + } + ) + return out + + def _merge_scan_ai_result( payload: Dict[str, Any], ai_raw: Dict[str, Any], @@ -613,6 +885,8 @@ def _merge_scan_ai_result( downgraded = _normalize_ai_items(ai_raw.get("downgraded")) watchlist = _normalize_ai_items(ai_raw.get("watchlist")) city_theses = _normalize_ai_city_theses(ai_raw.get("city_theses")) + city_forecasts = _normalize_ai_city_forecasts(ai_raw) + contract_notes = _normalize_ai_items(ai_raw.get("contract_notes")) veto_ids = {str(item.get("row_id")) for item in vetoed} downgrade_ids = {str(item.get("row_id")) for item in downgraded} @@ -624,17 +898,45 @@ def _merge_scan_ai_result( key = _normalize_ai_city_key(item.get("city")) if key: thesis_by_city[key] = item + forecast_by_city: Dict[str, Dict[str, Any]] = {} + for item in city_forecasts: + key = _normalize_ai_city_key(item.get("city")) + if key: + forecast_by_city[key] = item for row in rows: - thesis = thesis_by_city.get(_normalize_ai_city_key(row.get("city"))) or thesis_by_city.get( - _normalize_ai_city_key(row.get("city_display_name")) - ) - if not thesis: + city_key = _normalize_ai_city_key(row.get("city")) + display_key = _normalize_ai_city_key(row.get("city_display_name")) + thesis = thesis_by_city.get(city_key) or thesis_by_city.get(display_key) + forecast = forecast_by_city.get(city_key) or forecast_by_city.get(display_key) + if thesis: + row["ai_city_thesis_zh"] = thesis.get("thesis_zh") or thesis.get("summary_zh") + row["ai_city_thesis_en"] = thesis.get("thesis_en") or thesis.get("summary_en") + row["ai_city_confidence"] = thesis.get("confidence") + row["ai_city_model_cluster_note"] = thesis.get("model_cluster_note") + if forecast: + row["ai_predicted_max"] = _safe_float(forecast.get("predicted_max")) + row["ai_predicted_low"] = _safe_float(forecast.get("range_low")) + row["ai_predicted_high"] = _safe_float(forecast.get("range_high")) + row["ai_forecast_unit"] = forecast.get("unit") or row.get("temp_symbol") + row["ai_forecast_confidence"] = forecast.get("confidence") + row["ai_peak_window_zh"] = forecast.get("peak_window_zh") + row["ai_peak_window_en"] = forecast.get("peak_window_en") + row["ai_airport_metar_read_zh"] = forecast.get("metar_read_zh") + row["ai_airport_metar_read_en"] = forecast.get("metar_read_en") + row["ai_forecast_reason_zh"] = forecast.get("reasoning_zh") + row["ai_forecast_reason_en"] = forecast.get("reasoning_en") + row["ai_city_model_cluster_note"] = forecast.get("model_cluster_note") or row.get("ai_city_model_cluster_note") + row["ai_city_thesis_zh"] = row.get("ai_city_thesis_zh") or forecast.get("reasoning_zh") + row["ai_city_thesis_en"] = row.get("ai_city_thesis_en") or forecast.get("reasoning_en") + + for item in contract_notes: + row = by_id.get(str(item.get("row_id"))) + if not row: continue - row["ai_city_thesis_zh"] = thesis.get("thesis_zh") or thesis.get("summary_zh") - row["ai_city_thesis_en"] = thesis.get("thesis_en") or thesis.get("summary_en") - row["ai_city_confidence"] = thesis.get("confidence") - row["ai_city_model_cluster_note"] = thesis.get("model_cluster_note") + row["ai_forecast_match"] = item.get("forecast_match") or item.get("match") + row["ai_forecast_match_reason_zh"] = item.get("reason_zh") or item.get("reason") + row["ai_forecast_match_reason_en"] = item.get("reason_en") for item in vetoed: row = by_id.get(str(item.get("row_id"))) @@ -677,6 +979,7 @@ def _merge_scan_ai_result( row["ai_decision"] = row.get("ai_decision") or "neutral" if row_id in watchlist_ids and row.get("ai_decision") == "neutral": row["ai_decision"] = "watchlist" + _apply_metar_gate_to_row(row) def _ai_sort_key(row: Dict[str, Any]) -> tuple: decision = str(row.get("ai_decision") or "").lower() @@ -721,6 +1024,8 @@ def _merge_scan_ai_result( "base_url": SCAN_AI_BASE_URL, "summary_zh": ai_raw.get("summary_zh"), "summary_en": ai_raw.get("summary_en"), + "city_forecasts": city_forecasts, + "contract_notes": contract_notes, "city_theses": city_theses, "watchlist": watchlist, "recommended_count": sum(1 for row in rows if row.get("ai_rank") is not None), @@ -834,6 +1139,7 @@ def _build_terminal_row( city_meta = CITIES.get(city) or {} tz_offset = _safe_int(city_meta.get("tz"), 0) market_region = _market_region_from_tz_offset(tz_offset) + metar_context = _build_metar_decision_context(data) return { **row, @@ -850,6 +1156,16 @@ def _build_terminal_row( "temp_symbol": data.get("temp_symbol"), "current_temp": current.get("temp"), "current_max_so_far": current.get("max_so_far"), + "metar_context": metar_context, + "metar_today_obs": metar_context.get("today_obs") or [], + "metar_recent_obs": metar_context.get("recent_obs") or [], + "settlement_today_obs": metar_context.get("settlement_today_obs") or [], + "metar_status": { + "available_for_today": metar_context.get("available_for_today"), + "stale_for_today": metar_context.get("stale_for_today"), + "last_observation_time": metar_context.get("last_observation_time"), + "last_temp": metar_context.get("last_temp"), + }, "deb_prediction": ((daily_entry.get("deb") or {}).get("prediction") if isinstance(daily_entry.get("deb"), dict) else None) or ((data.get("deb") or {}).get("prediction") if isinstance(data.get("deb"), dict) else None), "display_name": display_name,