From bbf2db4da13a9f976b72fc9395792e1b5c400d77 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Mon, 29 Jun 2026 22:27:22 +0800 Subject: [PATCH] Show market buckets with model probability --- .../scan-terminal/ModelSummaryDashboard.tsx | 37 ++++++----- .../__tests__/modelSummaryDashboard.test.ts | 34 ++++++++-- frontend/lib/model-summary.ts | 66 ++++++++++++++++++- 3 files changed, 116 insertions(+), 21 deletions(-) diff --git a/frontend/components/dashboard/scan-terminal/ModelSummaryDashboard.tsx b/frontend/components/dashboard/scan-terminal/ModelSummaryDashboard.tsx index 80360ffa..c0d992ad 100644 --- a/frontend/components/dashboard/scan-terminal/ModelSummaryDashboard.tsx +++ b/frontend/components/dashboard/scan-terminal/ModelSummaryDashboard.tsx @@ -32,7 +32,7 @@ const SUMMARY_TEXT = { region: { en: "Region", zh: "区域" }, localTime: { en: "Local Time", zh: "当地时间" }, gaussianMu: { en: "Gaussian μ", zh: "高斯 μ" }, - probabilityDistribution: { en: "Probability Distribution", zh: "概率分布" }, + marketMatch: { en: "Market Match", zh: "市场匹配" }, median: { en: "Median", zh: "模型中位数" }, spread: { en: "Spread", zh: "分歧范围" }, empty: { en: "No model summary rows match the current filters.", zh: "当前筛选下没有模型汇总数据。" }, @@ -100,9 +100,11 @@ function FilterToggle({ function ModelSummaryRowView({ row, nowMs, + isEn, }: { row: ModelSummaryRow; nowMs: number | null; + isEn: boolean; }) { return ( @@ -136,24 +138,26 @@ function ModelSummaryRowView({ - {row.probabilityBuckets.length ? ( + {row.marketMatches.length ? (
- {row.probabilityBuckets.map((bucket) => { - const isTopBucket = bucket.key === row.topProbabilityBucketKey; + {row.marketMatches.map((match) => { return ( - = 0.2 + ? "border-emerald-200 bg-emerald-50 text-emerald-800" + : "border-slate-200 bg-slate-50 text-slate-600", )} - title={bucket.label} + title={`${match.label} model ${formatModelSummaryProbability(match.modelProbability)}`} > - {bucket.label} - {formatModelSummaryProbability(bucket.probability)} - + {match.label} + {isEn ? "M" : "模"} {formatModelSummaryProbability(match.modelProbability)} + ); })}
@@ -315,8 +319,8 @@ export function ModelSummaryDashboard({ {copy("gaussianMu", isEn)} - - {copy("probabilityDistribution", isEn)} + + {copy("marketMatch", isEn)} @@ -326,6 +330,7 @@ export function ModelSummaryDashboard({ key={row.cityKey} row={row} nowMs={nowMs} + isEn={isEn} /> ))} diff --git a/frontend/components/dashboard/scan-terminal/__tests__/modelSummaryDashboard.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/modelSummaryDashboard.test.ts index 7a3a8670..d2f1f161 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/modelSummaryDashboard.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/modelSummaryDashboard.test.ts @@ -59,6 +59,16 @@ export function runTests() { { value: 33, model_probability: 0.31, range: "[32.5~33.5)" }, ], probability_engine: "legacy", + all_buckets: [ + { + label: "31.5-32.5°C", + model_probability: 0.42, + }, + { + label: "33.5-34.5°C", + model_probability: 0.08, + }, + ], }, { city: "madrid", @@ -131,6 +141,18 @@ export function runTests() { parisRow.topProbabilityBucketKey === "31.5-32.5°C", "model summary should map probability buckets and identify the top bucket", ); + assert(parisRow.marketMatches.length === 2, "model summary should keep every Polymarket tradable bucket"); + assert( + parisRow.marketMatches[0].label === "31.5-32.5°C" && + parisRow.marketMatches[0].modelProbability === 0.42 && + parisRow.marketMatches[0].marketUrl === null, + "model summary should expose model probability for market-matched buckets without requiring market price", + ); + assert( + parisRow.marketMatches[1].label === "33.5-34.5°C" && + parisRow.marketMatches[1].modelProbability === 0.08, + "model summary should keep low-probability tradable buckets for manual NO review", + ); assert(formatModelSummaryProbability(null) === "—", "missing probability should render as an em dash"); assert(formatModelSummaryProbability(0.424) === "42%", "probability buckets should render as rounded percentages"); assert(formatModelSummaryTemp(null, "°C") === "—", "missing model temperatures should render as an em dash"); @@ -199,10 +221,14 @@ export function runTests() { modelSummarySource.includes("hasModelSummaryForecastData") && modelSummarySource.includes("Gaussian μ") && modelSummarySource.includes("高斯 μ") && - modelSummarySource.includes("Probability Distribution") && - modelSummarySource.includes("概率分布") && - modelSummarySource.includes("topProbabilityBucketKey") && - modelSummarySource.includes("probabilityBuckets.map") && + modelSummarySource.includes("Market Match") && + modelSummarySource.includes("市场匹配") && + modelSummarySource.includes("marketMatches.map") && + !modelSummarySource.includes("formatModelSummaryEdge") && + !modelSummarySource.includes("marketProbability") && + !modelSummarySource.includes("edgePercent") && + !modelSummarySource.includes("Probability Distribution") && + !modelSummarySource.includes("概率分布") && !modelSummarySource.includes("probabilityColumns") && modelSummarySource.includes("min-w-[96px]") && modelSummarySource.includes("Local Time") && diff --git a/frontend/lib/model-summary.ts b/frontend/lib/model-summary.ts index a958437d..bf367fbe 100644 --- a/frontend/lib/model-summary.ts +++ b/frontend/lib/model-summary.ts @@ -29,6 +29,13 @@ export type ModelSummaryProbabilityBucket = { probability: number; }; +export type ModelSummaryMarketMatch = { + key: string; + label: string; + modelProbability: number | null; + marketUrl: string | null; +}; + export type ModelSummaryRow = { cityKey: string; cityName: string; @@ -47,6 +54,7 @@ export type ModelSummaryRow = { gaussianMu: number | null; probabilityEngine: string | null; topProbabilityBucketKey: string | null; + marketMatches: ModelSummaryMarketMatch[]; searchText: string; }; @@ -156,6 +164,54 @@ function weightedProbabilityMu(buckets: ModelSummaryProbabilityBucket[]) { return roundToOneDecimal(weightedValue / totalProbability); } +function normalizeProbability(value: unknown) { + const numericValue = finiteNumber(value); + if (numericValue == null) return null; + return numericValue > 1 ? numericValue / 100 : numericValue; +} + +function marketBucketLabel(bucket: Record, tempSymbol: string) { + const textLabel = String(bucket.label || bucket.bucket || bucket.range || "").trim(); + if (textLabel) return textLabel; + const lower = finiteNumber(bucket.lower); + const upper = finiteNumber(bucket.upper); + if (lower != null && upper != null && upper > lower) { + return probabilityBucketLabel(lower, upper, String(bucket.unit || tempSymbol || "°C")); + } + const value = finiteNumber(bucket.value ?? bucket.temp ?? bucket.temperature); + return value == null ? "—" : `${formatBucketBound(value)}${bucket.unit || tempSymbol || "°C"}`; +} + +function buildMarketMatches(row: ScanOpportunityRow): ModelSummaryMarketMatch[] { + const marketRow = row as ScanOpportunityRow & { + all_buckets?: Array> | null; + top_buckets?: Array> | null; + }; + const sourceBuckets = ( + Array.isArray(marketRow.all_buckets) && marketRow.all_buckets.length + ? marketRow.all_buckets + : Array.isArray(marketRow.top_buckets) + ? marketRow.top_buckets + : [] + ) as Array>; + const tempSymbol = row.temp_symbol || "°C"; + + return sourceBuckets + .map((bucket, index) => { + const label = marketBucketLabel(bucket, tempSymbol); + const modelProbability = normalizeProbability(bucket.model_probability ?? bucket.probability); + return { + key: `${label}-${index}`, + label, + modelProbability, + marketUrl: typeof bucket.market_url === "string" ? bucket.market_url : null, + }; + }) + .sort((a, b) => { + return (b.modelProbability ?? -1) - (a.modelProbability ?? -1); + }); +} + function normalizeCityKey(row: ScanOpportunityRow, index: number) { const rawKey = row.city || row.city_display_name || row.display_name || `row-${index}`; return String(rawKey).trim().toLowerCase(); @@ -259,6 +315,13 @@ export function buildModelSummaryRows( const probabilitySearchText = probabilityBuckets .map((bucket) => `${bucket.label} ${formatModelSummaryProbability(bucket.probability)}`) .join(" "); + const marketMatches = buildMarketMatches(row); + const marketSearchText = marketMatches + .map( + (match) => + `${match.label} ${formatModelSummaryProbability(match.modelProbability)}`, + ) + .join(" "); byCity.set(cityKey, { cityKey, @@ -278,8 +341,9 @@ export function buildModelSummaryRows( gaussianMu: weightedProbabilityMu(probabilityBuckets), probabilityEngine: row.probability_engine || (probabilityBuckets.length ? "legacy" : null), topProbabilityBucketKey: topProbabilityBucket?.key || null, + marketMatches, searchText: - `${cityName} ${row.city || ""} ${region.labelEn} ${region.labelZh} ${modelSearchText} ${probabilitySearchText}`.toLowerCase(), + `${cityName} ${row.city || ""} ${region.labelEn} ${region.labelZh} ${modelSearchText} ${probabilitySearchText} ${marketSearchText}`.toLowerCase(), }); });