diff --git a/frontend/components/dashboard/Dashboard.module.css b/frontend/components/dashboard/Dashboard.module.css index 828a1136..ae0f8380 100644 --- a/frontend/components/dashboard/Dashboard.module.css +++ b/frontend/components/dashboard/Dashboard.module.css @@ -923,6 +923,49 @@ gap: 8px; } +.root :global(.prob-calibration-head) { + display: grid; + gap: 6px; + margin-bottom: 4px; + padding: 10px; + border: 1px solid rgba(34, 211, 238, 0.16); + border-radius: 8px; + background: rgba(15, 23, 42, 0.26); +} + +.root :global(.prob-calibration-head > div) { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; +} + +.root :global(.prob-calibration-head strong) { + color: var(--text-primary); + font-size: 13px; + font-weight: 800; +} + +.root :global(.prob-calibration-head p) { + margin: 0; + color: var(--text-muted); + font-size: 11px; + line-height: 1.45; +} + +.root :global(.prob-source-chip) { + display: inline-flex; + align-items: center; + min-height: 22px; + padding: 3px 8px; + border: 1px solid rgba(34, 211, 238, 0.28); + border-radius: 8px; + color: #67e8f9; + background: rgba(34, 211, 238, 0.08); + font-size: 11px; + font-weight: 900; +} + .root :global(.prob-row) { display: flex; align-items: center; diff --git a/frontend/components/dashboard/FutureForecastModal.tsx b/frontend/components/dashboard/FutureForecastModal.tsx index 7d145fbe..52a44d71 100644 --- a/frontend/components/dashboard/FutureForecastModal.tsx +++ b/frontend/components/dashboard/FutureForecastModal.tsx @@ -742,6 +742,13 @@ export function FutureForecastModal() { [dateStr, detail], ); const modelView = useMemo(() => getModelView(detail, dateStr), [dateStr, detail]); + const hasLgbmProbability = useMemo( + () => + Object.keys(modelView?.models || {}).some((name) => + String(name || "").toLowerCase().replace(/[\s_/-]/g, "").includes("lgbm"), + ), + [modelView], + ); const topProbabilityBucket = useMemo(() => { const buckets = Array.isArray(probabilityView?.probabilities) ? probabilityView.probabilities @@ -914,9 +921,14 @@ export function FutureForecastModal() { } const bucketLabel = formatBucketLabel(topProbabilityBucket); const bucketProb = formatMarketPercent(topProbabilityBucket.probability); + if (hasLgbmProbability) { + return locale === "en-US" + ? `LGBM-calibrated read puts the leading bucket at ${bucketLabel} (${bucketProb}). Treat this as the base case, not the final settlement.` + : `LGBM 校准后领先温度桶为 ${bucketLabel}(${bucketProb})。可作为基准情形,但不要直接等同于最终结算。`; + } return locale === "en-US" - ? `Highest current hit probability is ${bucketLabel} at ${bucketProb}. Treat this as the base case, not the final settlement.` - : `当前命中概率最高的是 ${bucketLabel}(${bucketProb}),可把它当作基准情形,但不要直接等同于最终结算。`; + ? `Calibrated model probability puts the leading bucket at ${bucketLabel} (${bucketProb}). Treat this as the base case, not the final settlement.` + : `校准模型概率显示领先温度桶为 ${bucketLabel}(${bucketProb})。可作为基准情形,但不要直接等同于最终结算。`; })(); const modelSummary = (() => { if (!modelSpreadView) { @@ -1823,10 +1835,16 @@ export function FutureForecastModal() {
- {locale === "en-US" ? "Auxiliary probability" : "辅助概率"} + {locale === "en-US" ? "Probability read" : "概率判断"}

- {locale === "en-US" ? "Model & Market Reference" : "模型与市场参考"} + {hasLgbmProbability + ? locale === "en-US" + ? "LGBM-Calibrated Probability" + : "LGBM 校准概率" + : locale === "en-US" + ? "Calibrated Model Probability" + : "校准模型概率"}

diff --git a/frontend/components/dashboard/PanelSections.tsx b/frontend/components/dashboard/PanelSections.tsx index ade0f3f2..1bac3ab0 100644 --- a/frontend/components/dashboard/PanelSections.tsx +++ b/frontend/components/dashboard/PanelSections.tsx @@ -310,6 +310,31 @@ function getMarketTopBucketKey(bucket: MarketTopBucket) { return `s:${String(bucket?.slug || bucket?.question || bucket?.label || "")}`; } +function hasLgbmModel(detail: CityDetail, targetDate?: string | null) { + const view = getModelView(detail, targetDate); + return Object.keys(view.models || {}).some((name) => + normalizeModelNameForVote(name).includes("lgbm"), + ); +} + +function formatProbabilityEngineLabel( + detail: CityDetail, + targetDate: string | null | undefined, + locale: string, +) { + const view = getProbabilityView(detail, targetDate); + if (hasLgbmModel(detail, targetDate)) { + return locale === "en-US" + ? "LGBM-calibrated probability" + : "LGBM 校准概率"; + } + const engine = String(view.engine || "").trim().toLowerCase(); + if (engine === "emos") { + return locale === "en-US" ? "EMOS-calibrated probability" : "EMOS 校准概率"; + } + return locale === "en-US" ? "Model probability" : "模型概率"; +} + export function HeroSummary() { const { data } = useCityData(); const { locale } = useI18n(); @@ -580,6 +605,12 @@ export function ProbabilityDistribution({ const marketYesText = toPercent(marketYesPrice); const marketNoText = toPercent(marketNoPrice); const isToday = !targetDate || targetDate === detail.local_date; + const probabilityEngineLabel = formatProbabilityEngineLabel( + detail, + targetDate, + locale, + ); + const hasLgbmProbability = hasLgbmModel(detail, targetDate); const modelVoteView = useMemo( () => getRoundedModelVoteDistribution(detail, targetDate), [detail, targetDate], @@ -610,11 +641,41 @@ export function ProbabilityDistribution({ const useMarketTopBuckets = marketScan?.available && sortedMarketTopBuckets.length >= 2; const topMarketBucketText = toPercent(sortedMarketTopBuckets[0]?.probability); + const topProbability = [...(view.probabilities || [])].sort( + (a, b) => Number(b.probability || 0) - Number(a.probability || 0), + )[0]; + const topProbabilityText = toPercent(topProbability?.probability); + const topProbabilityLabel = topProbability + ? topProbability.label || `${topProbability.value}${detail.temp_symbol}` + : null; return (
{!hideTitle &&

{t("section.probability")}

}
+
+
+ {probabilityEngineLabel} + + {topProbability && topProbabilityText + ? locale === "en-US" + ? `${topProbabilityLabel} leads at ${topProbabilityText}` + : `${topProbabilityLabel} 当前最高,${topProbabilityText}` + : locale === "en-US" + ? "Awaiting calibrated buckets" + : "等待校准概率桶"} + +
+

+ {hasLgbmProbability + ? locale === "en-US" + ? "LGBM is the learned intraday adjustment; model consensus below remains an explanation layer." + : "LGBM 作为日内学习校准项;下方模型共识只保留为解释层。" + : locale === "en-US" + ? "Using the calibrated model distribution; model consensus below is for explanation only." + : "使用校准后的模型分布;下方模型共识仅用于解释。"} +

+
{view.mu != null && (
{useMarketTopBuckets ? locale === "en-US" - ? `Market top-4 buckets (top): ${topMarketBucketText}` - : `市场概率(前4温度桶):最高 ${topMarketBucketText}` + ? `Market reference only: top traded bucket ${topMarketBucketText}` + : `市场仅作参考:最高交易温度桶 ${topMarketBucketText}` : locale === "en-US" - ? `Market probability (this bucket): ${marketYesText}` - : `市场概率(该温度桶): ${marketYesText}`} + ? `Market reference only: this bucket ${marketYesText}` + : `市场仅作参考:该温度桶 ${marketYesText}`}
)} {modelVoteHint && (
- {locale === "en-US" ? "Model vote reference" : "模型投票参考"} + {locale === "en-US" ? "Model consensus" : "模型共识参考"} {modelVoteHint} {locale === "en-US" - ? "shown for explanation only" - : "仅用于解释,不作为结算概率"} + ? "explains clustering, not calibrated probability" + : "解释模型聚集,不等同于校准概率"}
)} - {useMarketTopBuckets ? ( - sortedMarketTopBuckets.map((bucket, index) => { - const probability = Math.round( - Number(bucket.probability || 0) * 100, - ); - let bucketLabel = - bucket.label || - (bucket.value != null - ? `${bucket.value}${detail.temp_symbol}` - : `${bucket.temp ?? "--"}${detail.temp_symbol}`); - - if (bucketLabel) { - let str = String(bucketLabel).toUpperCase().replace(/\s+/g, ""); - str = str.replace(/°?C($|\+|-)/g, "℃$1"); - if (!str.includes("℃") && /[0-9]/.test(str)) { - str += "℃"; - } - bucketLabel = str; - } - const buyYesText = toPriceCents( - bucket.yes_buy ?? bucket.market_price ?? bucket.probability, - ); - const buyNoText = toPriceCents(bucket.no_buy); - const marketTag = buyYesText - ? locale === "en-US" - ? `Market ref: ${buyYesText}` - : `市场参考: ${buyYesText}` - : buyNoText - ? locale === "en-US" - ? `Market hedge: ${buyNoText}` - : `市场反向: ${buyNoText}` - : null; - - return ( -
-
{bucketLabel}
-
-
- {probability}% -
-
- {marketTag && ( -
- {marketTag} -
- )} -
- ); - }) - ) : view.probabilities.length === 0 ? ( + {view.probabilities.length === 0 ? ( ) : ( view.probabilities.slice(0, 6).map((bucket, index) => { diff --git a/frontend/lib/dashboard-types.ts b/frontend/lib/dashboard-types.ts index da3f8299..93488365 100644 --- a/frontend/lib/dashboard-types.ts +++ b/frontend/lib/dashboard-types.ts @@ -429,6 +429,14 @@ export interface CityDetail { probabilities?: { mu?: number | null; distribution?: ProbabilityBucket[]; + engine?: string | null; + calibration_mode?: string | null; + calibration_version?: string | null; + raw_mu?: number | null; + raw_sigma?: number | null; + calibrated_mu?: number | null; + calibrated_sigma?: number | null; + shadow_distribution?: ProbabilityBucket[]; }; hourly?: { times?: string[]; diff --git a/frontend/lib/dashboard-utils.ts b/frontend/lib/dashboard-utils.ts index 11bb63c0..6f9fae59 100644 --- a/frontend/lib/dashboard-utils.ts +++ b/frontend/lib/dashboard-utils.ts @@ -1195,15 +1195,23 @@ export function getProbabilityView(detail: CityDetail, targetDate?: string | nul const date = targetDate || detail.local_date; if (date === detail.local_date) { return { + calibrationMode: detail.probabilities?.calibration_mode ?? null, + calibrationVersion: detail.probabilities?.calibration_version ?? null, + engine: detail.probabilities?.engine ?? null, mu: detail.probabilities?.mu ?? null, probabilities: detail.probabilities?.distribution || [], + shadowProbabilities: detail.probabilities?.shadow_distribution || [], }; } const daily = detail.multi_model_daily?.[date]; return { + calibrationMode: null, + calibrationVersion: null, + engine: null, mu: daily?.deb?.prediction ?? null, probabilities: daily?.probabilities || [], + shadowProbabilities: [], }; } diff --git a/frontend/lib/i18n.ts b/frontend/lib/i18n.ts index 773e9a73..e0a46e82 100644 --- a/frontend/lib/i18n.ts +++ b/frontend/lib/i18n.ts @@ -88,7 +88,7 @@ const MESSAGES: Record> = { "future.score": "趋势评分", "future.todayTempTrend": "今日温度走势", "future.targetTempTrend": "目标日小时走势", - "future.probability": "模型结算概率分布", + "future.probability": "校准模型概率", "future.models": "多模型预报", "future.structureToday": "今日日内结构信号", "future.structureDate": "未来 6-48 小时趋势", @@ -108,7 +108,7 @@ const MESSAGES: Record> = { "section.todayTempTrend": "今日温度走势", "section.chartEmpty": "暂无小时级数据", - "section.probability": "模型结算概率分布", + "section.probability": "校准模型概率", "section.mu": "动态分布中心 μ = {value}{unit}", "section.noProb": "暂无概率数据", "section.models": "多模型预报", @@ -253,7 +253,7 @@ const MESSAGES: Record> = { "future.score": "Trend Score", "future.todayTempTrend": "Today's Temperature Trend", "future.targetTempTrend": "Target-day Hourly Trend", - "future.probability": "Model Settlement Probabilities", + "future.probability": "Calibrated Model Probability", "future.models": "Multi-model Forecast", "future.structureToday": "Intraday Structural Signal", "future.structureDate": "6-48h Structural Trend", @@ -275,7 +275,7 @@ const MESSAGES: Record> = { "section.todayTempTrend": "Today's Temperature Trend", "section.chartEmpty": "No hourly data available", - "section.probability": "Model Settlement Probabilities", + "section.probability": "Calibrated Model Probability", "section.mu": "Dynamic center μ = {value}{unit}", "section.noProb": "No probability data available", "section.models": "Multi-model Forecast",