From 0f23d8af9dd5c27453d6106121744544282db573 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Sun, 26 Apr 2026 09:40:02 +0800 Subject: [PATCH] feat: implement dashboard types, Polymarket data collection, and decision utilities for AI-driven city weather analysis --- .../scan-terminal/city-card-decision-utils.ts | 132 ++++++++++++++++-- .../dashboard/scan-terminal/decision-utils.ts | 77 ++++++++-- .../scan-terminal/use-ai-city-card-data.ts | 50 ++++++- frontend/hooks/useChart.ts | 16 ++- frontend/lib/dashboard-types.ts | 3 + src/data_collection/polymarket_readonly.py | 6 +- 6 files changed, 249 insertions(+), 35 deletions(-) diff --git a/frontend/components/dashboard/scan-terminal/city-card-decision-utils.ts b/frontend/components/dashboard/scan-terminal/city-card-decision-utils.ts index 7ced4671..e6905ad6 100644 --- a/frontend/components/dashboard/scan-terminal/city-card-decision-utils.ts +++ b/frontend/components/dashboard/scan-terminal/city-card-decision-utils.ts @@ -35,6 +35,12 @@ export function normalizeMarketProbability(value: unknown) { return numeric; } +function normalizeQuotePrice(value: unknown) { + const normalized = normalizeMarketProbability(value); + if (normalized == null || normalized <= 0) return null; + return normalized; +} + export function formatMarketPercent(value: number | null, digits = 1) { if (value == null || !Number.isFinite(value)) return "--"; return `${(value * 100).toFixed(digits)}%`; @@ -42,6 +48,7 @@ export function formatMarketPercent(value: number | null, digits = 1) { export function formatMarketCents(value: number | null) { if (value == null || !Number.isFinite(value)) return "--"; + if (value > 0 && value < 0.01) return "<1¢"; return `${Math.round(value * 100)}¢`; } @@ -66,7 +73,9 @@ export function normalizeMarketComparableTemp( export function getMarketBucketLabel(bucket?: MarketTopBucket | null, tempSymbol = "°C") { const direct = String(bucket?.label || "").trim(); - if (direct) return direct; + if (direct && /[°]?[CF]\b|\d+\s*[+-]?$/i.test(direct) && !/[�。紊]/.test(direct)) { + return direct.replace(/\bC\b/g, "°C").replace(/\bF\b/g, "°F"); + } const value = bucket?.temp ?? bucket?.value ?? bucket?.lower ?? null; const numeric = Number(value); if (Number.isFinite(numeric)) { @@ -78,6 +87,53 @@ export function getMarketBucketLabel(bucket?: MarketTopBucket | null, tempSymbol return "--"; } +function getBucketAnchor(bucket: MarketTopBucket) { + const anchor = Number(bucket.temp ?? bucket.value ?? bucket.lower); + return Number.isFinite(anchor) ? anchor : null; +} + +function getBucketModelProbability(bucket?: MarketTopBucket | null) { + const model = normalizeMarketProbability(bucket?.model_probability); + const probability = normalizeMarketProbability(bucket?.probability); + const market = normalizeMarketProbability(bucket?.market_price); + // Some persisted market_scan payloads from older builds overwrote bucket + // probability with the market price. Treat an exact price clone as missing + // model probability so the caller can fall back to scan.model_probability. + if ( + model != null && + market != null && + Math.abs(model - market) <= 0.000_001 + ) { + return null; + } + if ( + probability != null && + market != null && + Math.abs(probability - market) <= 0.000_001 + ) { + return null; + } + return model ?? probability; +} + +function getMarketSelectedBucket(scan: MarketScan | null | undefined): MarketTopBucket | null { + const selected = scan?.temperature_bucket; + if (!selected) return null; + const value = Number(selected.value); + return { + label: selected.label || selected.bucket || selected.range || null, + value: Number.isFinite(value) ? value : null, + temp: Number.isFinite(value) ? value : null, + unit: selected.unit || null, + probability: selected.probability ?? scan?.model_probability ?? null, + model_probability: selected.probability ?? scan?.model_probability ?? null, + market_price: scan?.market_price ?? null, + yes_buy: scan?.yes_buy ?? null, + yes_sell: scan?.yes_sell ?? null, + slug: scan?.selected_slug ?? scan?.primary_market?.slug ?? null, + }; +} + export function pickMarketBucketForWeatherCenter( scan: MarketScan | null | undefined, expectedHigh: number | null, @@ -90,8 +146,18 @@ export function pickMarketBucketForWeatherCenter( ? scan?.top_buckets : [] ) as MarketTopBucket[]; + const selectedBucket = getMarketSelectedBucket(scan); + const isReasonableFallback = (bucket: MarketTopBucket | null) => { + if (!bucket) return false; + const comparable = normalizeMarketComparableTemp(expectedHigh, tempSymbol, bucket); + const anchor = getBucketAnchor(bucket); + if (comparable == null || anchor == null) return false; + const unit = String(bucket.unit || "").toUpperCase(); + const maxReasonableDelta = unit === "F" ? 16 : 8; + return Math.abs(anchor - comparable) <= maxReasonableDelta; + }; if (!buckets.length || expectedHigh == null || !Number.isFinite(expectedHigh)) { - return null; + return selectedBucket; } let nearest: MarketTopBucket | null = null; @@ -111,15 +177,26 @@ export function pickMarketBucketForWeatherCenter( ) { return bucket; } - const anchor = Number(bucket.temp ?? bucket.value ?? bucket.lower); - if (!Number.isFinite(anchor)) continue; + const anchor = getBucketAnchor(bucket); + if (anchor == null) continue; const delta = Math.abs(anchor - comparable); if (delta < nearestDelta) { nearest = bucket; nearestDelta = delta; } } - return nearest; + if (!nearest) return isReasonableFallback(selectedBucket) ? selectedBucket : null; + const comparable = normalizeMarketComparableTemp(expectedHigh, tempSymbol, nearest); + const unit = String(nearest.unit || "").toUpperCase(); + const maxReasonableDelta = unit === "F" ? 16 : 8; + if ( + comparable != null && + Number.isFinite(nearestDelta) && + nearestDelta <= maxReasonableDelta + ) { + return nearest; + } + return isReasonableFallback(selectedBucket) ? selectedBucket : null; } export function buildMarketDecisionView({ @@ -171,18 +248,42 @@ export function buildMarketDecisionView({ } const bucket = pickMarketBucketForWeatherCenter(marketScan, expectedHigh, tempSymbol); - const bucketProbability = normalizeMarketProbability(bucket?.probability); + if (!bucket) { + return { + bucketLabel: "--", + confidence: marketScan.confidence || "--", + edgeText: "--", + impliedText: formatMarketPercent( + normalizeMarketProbability(marketScan.market_price) ?? + normalizeMarketProbability(marketScan.midpoint) ?? + normalizeMarketProbability(marketScan.yes_midpoint), + ), + marketUrl: marketScan.market_url || marketScan.primary_market_url || null, + modelText: formatMarketPercent(normalizeMarketProbability(marketScan.model_probability)), + priceText: formatMarketCents(normalizeQuotePrice(marketScan.yes_buy)), + reason: isEn + ? "A market was found, but its temperature bucket does not match today’s expected high closely enough, so edge is withheld." + : "已找到市场,但温度桶与今日预计高点不够匹配,暂不计算概率差。", + status: "ready", + title: isEn ? "Market bucket needs rematch" : "市场温度桶需重新匹配", + tone: "watch", + }; + } + const bucketProbability = getBucketModelProbability(bucket); const scanProbability = normalizeMarketProbability(marketScan.model_probability); const modelProbability = bucketProbability ?? scanProbability; const yesBuy = - normalizeMarketProbability(bucket?.yes_buy) ?? - normalizeMarketProbability(bucket?.market_price) ?? - normalizeMarketProbability(marketScan.yes_buy) ?? - normalizeMarketProbability(marketScan.market_price); + normalizeQuotePrice(bucket?.yes_buy) ?? + normalizeQuotePrice(marketScan.yes_buy); const yesSell = - normalizeMarketProbability(bucket?.yes_sell) ?? - normalizeMarketProbability(marketScan.yes_sell); - const implied = yesBuy ?? yesSell ?? null; + normalizeQuotePrice(bucket?.yes_sell) ?? + normalizeQuotePrice(marketScan.yes_sell); + const marketMid = + normalizeMarketProbability(bucket?.market_price) ?? + normalizeMarketProbability(marketScan.market_price) ?? + normalizeMarketProbability(marketScan.midpoint) ?? + normalizeMarketProbability(marketScan.yes_midpoint); + const implied = marketMid ?? yesBuy ?? yesSell ?? null; const edge = modelProbability != null && implied != null ? modelProbability - implied : null; const tone = @@ -216,9 +317,10 @@ export function buildMarketDecisionView({ edgeText: formatSignedMarketPercent(edge), impliedText: formatMarketPercent(implied), marketUrl: - bucket?.slug + bucket?.market_url || + (bucket?.slug ? `https://polymarket.com/market/${bucket.slug}` - : marketScan.market_url || marketScan.primary_market_url || null, + : marketScan.market_url || marketScan.primary_market_url || null), modelText: formatMarketPercent(modelProbability), priceText: formatMarketCents(yesBuy), reason: diff --git a/frontend/components/dashboard/scan-terminal/decision-utils.ts b/frontend/components/dashboard/scan-terminal/decision-utils.ts index 8b3373b9..e2e9ed1f 100644 --- a/frontend/components/dashboard/scan-terminal/decision-utils.ts +++ b/frontend/components/dashboard/scan-terminal/decision-utils.ts @@ -195,6 +195,50 @@ export function normalizeCityKey(value?: string | null) { .replace(/[\s_-]+/g, ""); } +function getOpportunityCardKey(row: ScanOpportunityRow) { + const city = + normalizeCityKey(row.city) || + normalizeCityKey(row.city_display_name) || + normalizeCityKey(row.display_name); + const date = String(row.selected_date || row.local_date || "").trim(); + if (city || date) { + return `${city || row.id}:${date || "date-unknown"}`; + } + return row.id; +} + +function getOpportunitySortScore(row: ScanOpportunityRow) { + return Number(row.final_score || 0) * 1000 + Number(row.edge_percent || 0); +} + +function dedupeOpportunityCards(rows: ScanOpportunityRow[]) { + const bestByCard = new Map(); + for (const row of rows) { + const key = getOpportunityCardKey(row); + const current = bestByCard.get(key); + if (!current || getOpportunitySortScore(row) > getOpportunitySortScore(current)) { + bestByCard.set(key, row); + } + } + return [...bestByCard.values()]; +} + +function takeUniqueOpportunityRows( + candidates: ScanOpportunityRow[], + usedCardKeys: Set, + limit: number, +) { + const picked: ScanOpportunityRow[] = []; + for (const row of candidates) { + const key = getOpportunityCardKey(row); + if (usedCardKeys.has(key)) continue; + usedCardKeys.add(key); + picked.push(row); + if (picked.length >= limit) break; + } + return picked; +} + export function prettifyCityName(value?: string | null) { return String(value || "") .trim() @@ -323,25 +367,32 @@ export function getRowDecisionMeta(row: ScanOpportunityRow, locale = "zh-CN") { export function pickOpportunitySections(rows: ScanOpportunityRow[], locale = "zh-CN") { const isEn = locale === "en-US"; - const top = [...rows] + const usedCardKeys = new Set(); + const uniqueRows = dedupeOpportunityCards(rows); + const top = takeUniqueOpportunityRows([...uniqueRows] .sort((left, right) => { const scoreDelta = Number(right.final_score || 0) - Number(left.final_score || 0); if (scoreDelta !== 0) return scoreDelta; return Number(right.edge_percent || 0) - Number(left.edge_percent || 0); - }) - .slice(0, 4); - const peak = rows - .filter((row) => { + }), usedCardKeys, 4); + const peak = takeUniqueOpportunityRows( + uniqueRows.filter((row) => { const meta = getPeakCountdownMeta(row, locale); return meta.key === "active" || meta.key === "next"; - }) - .slice(0, 4); - const model = rows - .filter((row) => Number(row.cluster_model_count || 0) >= 4 || Number(row.consensus_score || 0) >= 0.65) - .slice(0, 4); - const risk = rows - .filter((row) => row.risk_level === "high" || ["veto", "downgrade"].includes(String(row.v4_metar_decision || row.ai_decision || "").toLowerCase())) - .slice(0, 4); + }), + usedCardKeys, + 4, + ); + const model = takeUniqueOpportunityRows( + uniqueRows.filter((row) => Number(row.cluster_model_count || 0) >= 4 || Number(row.consensus_score || 0) >= 0.65), + usedCardKeys, + 4, + ); + const risk = takeUniqueOpportunityRows( + uniqueRows.filter((row) => row.risk_level === "high" || ["veto", "downgrade"].includes(String(row.v4_metar_decision || row.ai_decision || "").toLowerCase())), + usedCardKeys, + 4, + ); return [ { key: "top", diff --git a/frontend/components/dashboard/scan-terminal/use-ai-city-card-data.ts b/frontend/components/dashboard/scan-terminal/use-ai-city-card-data.ts index 4e06c179..50a9d03c 100644 --- a/frontend/components/dashboard/scan-terminal/use-ai-city-card-data.ts +++ b/frontend/components/dashboard/scan-terminal/use-ai-city-card-data.ts @@ -99,6 +99,13 @@ export function useAiCityForecast({ let buffer = ""; let rawStream = ""; let finalPayload: AiCityForecastPayload | null = null; + let latestReadableText = ""; + const rememberReadableText = (value?: string | null) => { + const text = String(value || "").trim(); + if (text) { + latestReadableText = text; + } + }; const handleBlock = (block: string) => { const message = parseSseBlock(block); if (!message || !message.data || typeof message.data !== "object") { @@ -111,6 +118,7 @@ export function useAiCityForecast({ locale === "en-US" ? data.message_en || "" : data.message_zh || "", ).trim() || String(data.message || "").trim(); if (progressText && !cancelled) { + rememberReadableText(progressText); setAiForecast((current) => current.status === "loading" ? { ...current, streamText: current.streamText || progressText } @@ -132,6 +140,7 @@ export function useAiCityForecast({ ).trim() || String(data.final_judgment_zh || data.final_judgment_en || "").trim(); if (previewText && !cancelled) { + rememberReadableText(previewText); setAiForecast((current) => current.status === "loading" ? { @@ -153,6 +162,7 @@ export function useAiCityForecast({ ? "AI has started streaming; parsing the METAR read field…" : "AI 已开始流式输出,正在解析机场报文字段…" : ""); + rememberReadableText(airportRead || streamingText); if (!cancelled) { setAiForecast((current) => current.status === "loading" @@ -184,7 +194,45 @@ export function useAiCityForecast({ handleBlock(buffer); } if (!finalPayload) { - throw new Error("AI stream ended before final payload"); + const fallbackText = + extractStreamingAirportRead(rawStream, locale) || + latestReadableText || + (isEn + ? "The AI airport read stream was interrupted after partial output." + : "AI 机场报文解读已输出部分内容,但最终载荷未返回。"); + const retryHint = isEn + ? "The streaming connection ended before the final structured payload. The partial airport read above is preserved; refresh once if you need the full JSON-backed conclusion." + : "流式连接在最终结构化载荷返回前结束。上方已保留已输出的机场报文解读;如需完整 JSON 结论可刷新一次。"; + return { + city_forecast: { + confidence: "low", + final_judgment_en: isEn + ? fallbackText + : "Partial AI airport read was preserved after the stream ended early.", + final_judgment_zh: isEn + ? "AI 机场报文解读已保留部分输出,但流式连接提前结束。" + : fallbackText, + metar_read_en: isEn ? fallbackText : "", + metar_read_zh: isEn ? "" : fallbackText, + model_cluster_note_en: "", + model_cluster_note_zh: "", + predicted_max: null, + range_high: null, + range_low: null, + reasoning_en: retryHint, + reasoning_zh: retryHint, + risks_en: isEn ? [retryHint] : [], + risks_zh: isEn ? [] : [retryHint], + unit: detail?.temp_symbol || "°C", + }, + raw_reason: "AI stream ended before final payload", + reason: retryHint, + reason_en: isEn + ? retryHint + : "AI stream ended before the final payload; partial text was preserved.", + reason_zh: isEn ? "AI 流在最终载荷前结束;已保留部分文本。" : retryHint, + status: "partial_stream", + }; } return finalPayload; }), diff --git a/frontend/hooks/useChart.ts b/frontend/hooks/useChart.ts index f2a72aab..1d5ce02d 100644 --- a/frontend/hooks/useChart.ts +++ b/frontend/hooks/useChart.ts @@ -29,19 +29,25 @@ export function useChart( if (disposed) return; const config = createConfig(); - if (chartRef.current) { - chartRef.current.destroy(); - chartRef.current = null; + const nextType = (config as { type?: ChartType }).type; + const currentType = chartRef.current + ? (chartRef.current.config as { type?: ChartType }).type + : null; + if (chartRef.current && currentType === nextType) { + chartRef.current.data = config.data as ChartInstance["data"]; + chartRef.current.options = + (config.options || {}) as ChartInstance["options"]; + chartRef.current.update("none"); + return; } + chartRef.current?.destroy(); chartRef.current = new Chart(canvas, config); }; void setupChart(); return () => { disposed = true; - chartRef.current?.destroy(); - chartRef.current = null; }; }, dependencies); diff --git a/frontend/lib/dashboard-types.ts b/frontend/lib/dashboard-types.ts index 00920174..e10f5161 100644 --- a/frontend/lib/dashboard-types.ts +++ b/frontend/lib/dashboard-types.ts @@ -315,7 +315,9 @@ export interface MarketTopBucket { upper?: number | null; unit?: string | null; probability?: number | null; + model_probability?: number | null; market_price?: number | null; + edge_percent?: number | null; yes_buy?: number | null; yes_sell?: number | null; no_buy?: number | null; @@ -325,6 +327,7 @@ export interface MarketTopBucket { quote_source?: string | null; quote_age_ms?: number | null; slug?: string | null; + market_url?: string | null; question?: string | null; is_primary?: boolean; } diff --git a/src/data_collection/polymarket_readonly.py b/src/data_collection/polymarket_readonly.py index 0eb18ce0..681ff89c 100644 --- a/src/data_collection/polymarket_readonly.py +++ b/src/data_collection/polymarket_readonly.py @@ -901,7 +901,11 @@ class PolymarketReadOnlyLayer: if reference_price is not None: reference_price = max(0.0, min(1.0, float(reference_price))) bucket["market_price"] = reference_price - bucket["probability"] = reference_price + # Keep model probability separate from market-implied price. + # Older code overwrote ``probability`` with the quote, which made + # downstream UI compare a market price against itself or display + # stale bucket probabilities as weather probabilities. + bucket.setdefault("model_probability", bucket.get("probability")) if yes_prices.get("quote_source"): bucket["quote_source"] = yes_prices.get("quote_source") if yes_prices.get("quote_age_ms") is not None: