diff --git a/frontend/components/dashboard/scan-terminal/ModelSummaryDashboard.tsx b/frontend/components/dashboard/scan-terminal/ModelSummaryDashboard.tsx index ed069d4c..ed3e2978 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: "高斯 μ" }, - detailedProbability: { en: "Detailed Probability", zh: "详细概率分布" }, + detailedProbability: { en: "Market Option Probability", zh: "市场选项概率" }, noProbabilityDistribution: { en: "No probability distribution", zh: "暂无详细概率" }, marketMatch: { en: "Market Match", zh: "市场匹配" }, median: { en: "Median", zh: "模型中位数" }, diff --git a/frontend/components/dashboard/scan-terminal/__tests__/modelSummaryDashboard.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/modelSummaryDashboard.test.ts index 271fe8f0..823d78b2 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/modelSummaryDashboard.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/modelSummaryDashboard.test.ts @@ -54,19 +54,26 @@ export function runTests() { "AROME HD": 32.1, }, distribution_full: [ - { value: 31, model_probability: 0.16, range: "[30.5~31.5)" }, - { value: 32, model_probability: 0.42, range: "[31.5~32.5)" }, - { value: 33, model_probability: 0.31, range: "[32.5~33.5)" }, + { value: 32, model_probability: 0.41, range: "[31.5~32.5)" }, + { value: 33, model_probability: 0.56, range: "[32.5~33.5)" }, + { value: 34, model_probability: 0.03, range: "[33.5~34.5)" }, ], probability_engine: "legacy", all_buckets: [ { - label: "31.5-32.5°C", - model_probability: 0.42, + label: "32°C", + lower: 31.5, + upper: 32.5, }, { - label: "33.5-34.5°C", - model_probability: 0.08, + label: "33°C", + lower: 32.5, + upper: 33.5, + }, + { + label: "34°C", + lower: 33.5, + upper: 34.5, }, ], }, @@ -89,6 +96,27 @@ export function runTests() { { value: 36, probability: 0.52 }, ], }, + { + city: "houston", + city_display_name: "Houston", + trading_region_label: "North America", + trading_region_label_zh: "北美", + trading_region_sort: 7, + temp_symbol: "°F", + current_max_so_far: 90, + deb_prediction: 94.2, + local_time: "09:30", + model_cluster_sources: { + ECMWF: 94, + GFS: 96, + }, + distribution_full: [ + { value: 94, probability: 0.4 }, + { value: 95, probability: 0.25 }, + { value: 96, probability: 0.08 }, + { value: 97, probability: 0.02 }, + ], + }, { city: "amsterdam", city_display_name: "Amsterdam", @@ -111,6 +139,7 @@ export function runTests() { const beijingRow = summaryRows.find((row) => row.cityName === "Beijing"); const madridRow = summaryRows.find((row) => row.cityName === "Madrid"); const parisRow = summaryRows.find((row) => row.cityName === "Paris"); + const houstonRow = summaryRows.find((row) => row.cityName === "Houston"); assert( MODEL_SUMMARY_MODEL_COLUMNS.map((column) => column.key).includes("AROME HD") && @@ -118,7 +147,7 @@ export function runTests() { MODEL_SUMMARY_MODEL_COLUMNS.map((column) => column.key).includes("NAM"), "model summary must expose the fixed model columns including optional short-range models", ); - assert(summaryRows.length === 4, "model summary should keep one row per city"); + assert(summaryRows.length === 5, "model summary should keep one row per city"); assert(summaryRows[0].cityName === "Beijing", "model summary should sort by resolved region then city name"); assert(amsterdamRow?.regionLabel === "欧洲 / 非洲", "model summary should override stale backend timezone regions for known European cities"); if (!madridRow || !parisRow) throw new Error("model summary should keep European rows"); @@ -133,24 +162,32 @@ export function runTests() { assert(parisRow.models.HRRR === null, "missing models should be normalized to null"); assert(parisRow.modelMedian === 32.1, "model median should use available model values only"); assert(parisRow.modelSpread === 2.5, "model spread should use available model min/max only"); - assert(parisRow.gaussianMu === 32.2, "model summary should compute Gaussian mu from probability buckets"); + assert(parisRow.gaussianMu === 32.6, "model summary should compute Gaussian mu from probability buckets"); assert(parisRow.probabilityEngine === "legacy", "model summary should preserve probability engine metadata"); - assert(parisRow.probabilityBuckets.length === 3, "model summary should keep every probability bucket"); + assert(parisRow.probabilityBuckets.length === 3, "model summary should keep every market-option probability bucket"); assert( - parisRow.probabilityBucketMap["31.5-32.5°C"]?.probability === 0.42 && - parisRow.topProbabilityBucketKey === "31.5-32.5°C", - "model summary should map probability buckets and identify the top bucket", + parisRow.probabilityBucketMap["32°C"]?.probability === 0.41 && + parisRow.probabilityBucketMap["33°C"]?.probability === 0.56 && + parisRow.topProbabilityBucketKey === "33°C" && + !parisRow.probabilityBucketMap["31.5-32.5°C"], + "model summary should map Celsius probabilities to market option labels instead of half-degree ranges", ); - 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 && + houstonRow?.probabilityBucketMap["94-95°F"]?.probability === 0.65 && + houstonRow.probabilityBucketMap["96-97°F"]?.probability === 0.1 && + houstonRow.topProbabilityBucketKey === "94-95°F", + "model summary should aggregate Fahrenheit probabilities into two-degree market option labels", + ); + assert(parisRow.marketMatches.length === 3, "model summary should keep every Polymarket tradable bucket"); + assert( + parisRow.marketMatches[0].label === "32°C" && + parisRow.marketMatches[0].modelProbability === null && 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, + parisRow.marketMatches[1].label === "33°C" && + parisRow.marketMatches[1].modelProbability === null, "model summary should keep low-probability tradable buckets for manual NO review", ); assert(formatModelSummaryProbability(null) === "—", "missing probability should render as an em dash"); @@ -188,7 +225,9 @@ export function runTests() { wideSpreadOnly: true, }); assert( - wideSpread.length === 1 && wideSpread[0].cityName === "Paris", + wideSpread.length === 2 && + wideSpread.some((row) => row.cityName === "Paris") && + wideSpread.some((row) => row.cityName === "Houston"), "wide-spread filter should only keep rows with model spread >= 2°C", ); assert(rows[0].model_cluster_sources === originalFirstModelSources, "model summary filters must not mutate source rows"); @@ -221,13 +260,15 @@ export function runTests() { modelSummarySource.includes("hasModelSummaryForecastData") && modelSummarySource.includes("Gaussian μ") && modelSummarySource.includes("高斯 μ") && - modelSummarySource.includes("Detailed Probability") && - modelSummarySource.includes("详细概率分布") && + modelSummarySource.includes("Market Option Probability") && + modelSummarySource.includes("市场选项概率") && modelSummarySource.includes("expandedCityKeys") && modelSummarySource.includes("toggleExpandedCity") && modelSummarySource.includes("aria-expanded") && modelSummarySource.includes("ChevronRight") && modelSummarySource.includes("probabilityBuckets.map") && + !modelSummarySource.includes("Detailed Probability") && + !modelSummarySource.includes("详细概率分布") && modelSummarySource.includes("Market Match") && modelSummarySource.includes("市场匹配") && modelSummarySource.includes("marketMatches.map") && diff --git a/frontend/lib/model-summary.ts b/frontend/lib/model-summary.ts index bf367fbe..61192780 100644 --- a/frontend/lib/model-summary.ts +++ b/frontend/lib/model-summary.ts @@ -96,22 +96,6 @@ function probabilityFromBucket(bucket: Record) { return raw > 1 ? raw / 100 : raw; } -function rangeFromBucket(bucket: Record, value: number) { - const rawRange = String(bucket.range || bucket.bucket || bucket.label || "").trim(); - const rangeMatch = rawRange.match(/(-?\d+(?:\.\d+)?)\s*(?:~|-|to)\s*(-?\d+(?:\.\d+)?)/i); - if (rangeMatch) { - const lower = Number(rangeMatch[1]); - const upper = Number(rangeMatch[2]); - if (Number.isFinite(lower) && Number.isFinite(upper) && upper > lower) { - return { lower, upper }; - } - } - return { - lower: Number((value - 0.5).toFixed(2)), - upper: Number((value + 0.5).toFixed(2)), - }; -} - function formatBucketBound(value: number) { return Number(value.toFixed(1)).toString(); } @@ -124,6 +108,101 @@ function probabilityBucketLabel(lower: number, upper: number, unit: string) { return probabilityBucketKey(lower, upper, unit); } +function isFahrenheitUnit(unit: string) { + return unit.toUpperCase().includes("F"); +} + +function marketOptionBucketForValue(value: number, unit: string) { + const settledValue = Math.round(value); + if (isFahrenheitUnit(unit)) { + const lowerValue = settledValue % 2 === 0 ? settledValue : settledValue - 1; + const upperValue = lowerValue + 1; + return { + key: `${lowerValue}-${upperValue}${unit || "°F"}`, + label: `${lowerValue}-${upperValue}${unit || "°F"}`, + lower: lowerValue - 0.5, + upper: upperValue + 0.5, + }; + } + return { + key: `${settledValue}${unit || "°C"}`, + label: `${settledValue}${unit || "°C"}`, + lower: settledValue - 0.5, + upper: settledValue + 0.5, + }; +} + +function roundProbability(value: number) { + return Math.round(value * 1000) / 1000; +} + +function sourceMarketBuckets(row: ScanOpportunityRow) { + const marketRow = row as ScanOpportunityRow & { + all_buckets?: Array> | null; + top_buckets?: Array> | null; + }; + return ( + Array.isArray(marketRow.all_buckets) && marketRow.all_buckets.length + ? marketRow.all_buckets + : Array.isArray(marketRow.top_buckets) + ? marketRow.top_buckets + : [] + ) as Array>; +} + +function parseMarketOptionBucket(bucket: Record, unit: string) { + const rawLabel = String(bucket.label || bucket.bucket || bucket.range || "").trim(); + const labelNumbers = rawLabel.match(/-?\d+(?:\.\d+)?/g)?.map(Number) || []; + const lower = finiteNumber(bucket.lower); + const upper = finiteNumber(bucket.upper); + let lowerValue: number | null = null; + let upperValue: number | null = null; + + if (/below/i.test(rawLabel) && labelNumbers.length) { + upperValue = Math.round(labelNumbers[0]); + } else if (/higher/i.test(rawLabel) && labelNumbers.length) { + lowerValue = Math.round(labelNumbers[0]); + } else if (labelNumbers.length >= 2) { + lowerValue = Math.round(labelNumbers[0]); + upperValue = Math.round(labelNumbers[1]); + } else if (labelNumbers.length === 1) { + lowerValue = Math.round(labelNumbers[0]); + upperValue = Math.round(labelNumbers[0]); + } else if (lower != null && upper != null && upper > lower) { + lowerValue = Math.ceil(lower); + upperValue = Math.ceil(upper) - 1; + } else { + const value = finiteNumber(bucket.value ?? bucket.temp ?? bucket.temperature); + if (value == null) return null; + const option = marketOptionBucketForValue(value, unit); + lowerValue = Math.ceil(option.lower); + upperValue = Math.ceil(option.upper) - 1; + } + + const finiteLower = lowerValue ?? Number.NEGATIVE_INFINITY; + const finiteUpper = upperValue ?? Number.POSITIVE_INFINITY; + if (finiteUpper < finiteLower) return null; + + let label = rawLabel; + if (!label || /\.5\b/.test(label)) { + const representative = + Number.isFinite(finiteLower) && Number.isFinite(finiteUpper) + ? (finiteLower + finiteUpper) / 2 + : Number.isFinite(finiteLower) + ? finiteLower + : finiteUpper; + label = marketOptionBucketForValue(representative, unit).label; + } + + return { + key: label, + label, + lowerValue: finiteLower, + upperValue: finiteUpper, + sortValue: Number.isFinite(finiteLower) ? finiteLower : finiteUpper, + }; +} + function buildProbabilityBuckets(row: ScanOpportunityRow): ModelSummaryProbabilityBucket[] { const rawBuckets = ( Array.isArray(row.distribution_full) && row.distribution_full.length @@ -133,24 +212,88 @@ function buildProbabilityBuckets(row: ScanOpportunityRow): ModelSummaryProbabili : [] ) as Array>; const unit = row.temp_symbol || "°C"; - - return rawBuckets + const rawProbabilityPoints = rawBuckets .map((bucket) => { const value = finiteNumber(bucket.value ?? bucket.temp ?? bucket.temperature); const probability = probabilityFromBucket(bucket); if (value == null || probability == null || probability <= 0) return null; - const { lower, upper } = rangeFromBucket(bucket, value); - const key = probabilityBucketKey(lower, upper, unit); - return { - key, - label: probabilityBucketLabel(lower, upper, unit), - value, - lower, - upper, - probability, - }; + return { value, settledValue: Math.round(value), probability }; }) - .filter((bucket): bucket is ModelSummaryProbabilityBucket => bucket !== null) + .filter((point): point is { value: number; settledValue: number; probability: number } => point !== null); + + const marketBuckets = sourceMarketBuckets(row) + .map((bucket) => parseMarketOptionBucket(bucket, unit)) + .filter((bucket): bucket is NonNullable => bucket !== null); + + if (marketBuckets.length && rawProbabilityPoints.length) { + const fromMarketBuckets = marketBuckets + .map((bucket) => { + const matchingPoints = rawProbabilityPoints.filter( + (point) => + point.settledValue >= bucket.lowerValue && + point.settledValue <= bucket.upperValue, + ); + const probability = matchingPoints.reduce((sum, point) => sum + point.probability, 0); + if (probability <= 0) return null; + const weightedValue = matchingPoints.reduce( + (sum, point) => sum + point.value * point.probability, + 0, + ); + return { + key: bucket.key, + label: bucket.label, + value: roundToOneDecimal(weightedValue / probability), + lower: bucket.sortValue, + upper: bucket.sortValue, + probability: roundProbability(probability), + }; + }) + .filter((bucket): bucket is ModelSummaryProbabilityBucket => bucket !== null) + .sort((a, b) => a.lower - b.lower || a.upper - b.upper); + + if (fromMarketBuckets.length) return fromMarketBuckets; + } + + const grouped = new Map< + string, + { + label: string; + lower: number; + upper: number; + probability: number; + weightedValue: number; + } + >(); + + rawProbabilityPoints.forEach(({ value, probability }) => { + const option = marketOptionBucketForValue(value, unit); + const existing = grouped.get(option.key); + if (existing) { + existing.probability += probability; + existing.weightedValue += value * probability; + return; + } + grouped.set(option.key, { + label: option.label, + lower: option.lower, + upper: option.upper, + probability, + weightedValue: value * probability, + }); + }); + + return [...grouped.entries()] + .map(([key, bucket]) => ({ + key, + label: bucket.label, + value: + bucket.probability > 0 + ? roundToOneDecimal(bucket.weightedValue / bucket.probability) + : roundToOneDecimal((bucket.lower + bucket.upper) / 2), + lower: bucket.lower, + upper: bucket.upper, + probability: roundProbability(bucket.probability), + })) .sort((a, b) => a.lower - b.lower || a.upper - b.upper); }