"use client"; import clsx from "clsx"; import { useMemo } from "react"; import { useI18n } from "@/hooks/useI18n"; import { CityDetail, MarketScan, MarketTopBucket, ProbabilityBucket, } from "@/lib/dashboard-types"; import { getModelView, getProbabilityView } from "@/lib/model-utils"; function EmptyState({ text }: { text: string }) { return (
{text}
); } function toPercent(value?: number | null) { if (value == null) return null; const numeric = Number(value); if (!Number.isFinite(numeric)) return null; return `${(numeric * 100).toFixed(1)}%`; } function toPriceCents(value?: number | null) { if (value == null) return null; const numeric = Number(value); if (!Number.isFinite(numeric)) return null; const normalized = numeric > 1 ? numeric / 100 : numeric; const cents = normalized * 100; const rounded = Math.round(cents * 10) / 10; const text = Number.isInteger(rounded) ? String(rounded.toFixed(0)) : String(rounded); return `${text}c`; } function parseTempFromText(value: unknown) { const text = String(value || ""); const match = text.match(/(-?\d+(?:\.\d+)?)/); if (!match) return null; const numeric = Number(match[1]); return Number.isFinite(numeric) ? numeric : null; } function getBucketTemp(bucket: ProbabilityBucket) { if (bucket.value != null) { const byValue = Number(bucket.value); if (Number.isFinite(byValue)) return byValue; } return parseTempFromText(bucket.label || bucket.bucket || bucket.range); } function getMarketYesPrice(scan?: MarketScan | null) { if (scan?.market_price != null) { const preferred = Number(scan.market_price); if (Number.isFinite(preferred)) return preferred; } if (scan?.yes_token?.implied_probability != null) { const implied = Number(scan.yes_token.implied_probability); if (Number.isFinite(implied)) return implied; } return null; } function isFahrenheitSymbol(symbol?: string | null) { return String(symbol || "") .toUpperCase() .includes("F"); } function displayTempToMarketCelsius( value: number | null, detail: Pick, ) { if (value == null || !Number.isFinite(value)) return null; if (isFahrenheitSymbol(detail.temp_symbol)) { return ((value - 32) * 5) / 9; } return value; } function formatBucketDisplayLabel( bucket: ProbabilityBucket, detail: Pick, ) { let bucketLabel = bucket.label || `${bucket.value}${detail.temp_symbol}`; if (!bucketLabel) return ""; let str = String(bucketLabel).toUpperCase().replace(/\s+/g, ""); const symbol = detail.temp_symbol || "°C"; if (isFahrenheitSymbol(symbol)) { str = str.replace(/℃/g, "°F").replace(/°C/g, "°F"); } else { str = str.replace(/℃/g, "°C").replace(/°F/g, "°C"); } str = str.replace(/°?C($|\+|-)/g, "°C$1"); str = str.replace(/°?F($|\+|-)/g, "°F$1"); if (!/[°℃][CF]/.test(str) && /[0-9]/.test(str)) { str += symbol; } return str; } function getMarketBucketUnit(bucket?: MarketTopBucket | null) { return String(bucket?.unit || "").toUpperCase(); } function isMarketBucketAbove(bucket?: MarketTopBucket | null) { const text = `${bucket?.label || ""} ${bucket?.slug || ""} ${bucket?.question || ""}` .toLowerCase() .replace(/\s+/g, ""); return ( text.includes("+") || text.includes("orhigher") || text.includes("or-higher") ); } function isMarketBucketBelow(bucket?: MarketTopBucket | null) { const text = `${bucket?.label || ""} ${bucket?.slug || ""} ${bucket?.question || ""}` .toLowerCase() .replace(/\s+/g, ""); return ( text.includes("<=") || text.includes("orlower") || text.includes("or-lower") ); } function findMarketBucketForDisplayTemp( buckets: MarketTopBucket[], displayTemp: number | null, detail: Pick, ) { if (displayTemp == null || !Number.isFinite(displayTemp)) return null; let best: MarketTopBucket | null = null; let bestDelta = Number.POSITIVE_INFINITY; for (const bucket of buckets) { const bucketUnit = String(bucket.unit || "").toUpperCase(); const compareTemp = bucketUnit === "F" ? displayTemp : displayTempToMarketCelsius(displayTemp, detail); if (compareTemp == null) continue; const lower = bucket.lower != null ? Number(bucket.lower) : null; const upper = bucket.upper != null ? Number(bucket.upper) : null; if ( lower != null && upper != null && Number.isFinite(lower) && Number.isFinite(upper) && compareTemp >= lower - 0.01 && compareTemp <= upper + 0.01 ) { return bucket; } const rawTemp = bucket.temp ?? bucket.value ?? null; if (rawTemp == null) continue; const candidateTemp = Number(rawTemp); if (!Number.isFinite(candidateTemp)) continue; const delta = Math.abs(candidateTemp - compareTemp); if (delta < bestDelta) { best = bucket; bestDelta = delta; } } const tolerance = isFahrenheitSymbol(detail.temp_symbol) ? 0.56 : 0.26; return best && bestDelta <= tolerance ? best : null; } function marketBucketContainsDisplayTemp( bucket: MarketTopBucket | null, displayTemp: number | null, detail: Pick, ) { if (!bucket || displayTemp == null || !Number.isFinite(displayTemp)) return false; const bucketUnit = getMarketBucketUnit(bucket); const compareTemp = bucketUnit === "F" ? displayTemp : displayTempToMarketCelsius(displayTemp, detail); if (compareTemp == null) return false; const lower = bucket.lower != null ? Number(bucket.lower) : null; const upper = bucket.upper != null ? Number(bucket.upper) : null; if (lower != null && !Number.isFinite(lower)) return false; if (upper != null && !Number.isFinite(upper)) return false; if (lower != null && upper != null) { return compareTemp >= lower - 0.01 && compareTemp <= upper + 0.01; } if (lower != null && isMarketBucketAbove(bucket)) { return compareTemp >= lower - 0.01; } if (lower != null && isMarketBucketBelow(bucket)) { return compareTemp <= lower + 0.01; } const reference = bucket.temp ?? bucket.value ?? lower; const numeric = reference != null ? Number(reference) : null; if (numeric == null || !Number.isFinite(numeric)) return false; const tolerance = bucketUnit === "F" ? 0.56 : 0.26; return Math.abs(compareTemp - numeric) <= tolerance; } function getAggregatedModelProbabilityForMarketBucket( probabilities: ProbabilityBucket[], bucket: MarketTopBucket | null, detail: Pick, ) { if (!bucket) return null; let total = 0; let matched = 0; for (const probabilityBucket of probabilities) { const temp = getBucketTemp(probabilityBucket); if (!marketBucketContainsDisplayTemp(bucket, temp, detail)) continue; const probability = Number(probabilityBucket.probability); if (!Number.isFinite(probability)) continue; total += probability; matched += 1; } return matched > 0 ? Math.max(0, Math.min(1, total)) : null; } type ProbabilityDisplayRow = { key: string; label: string; probability: number; marketBucket?: MarketTopBucket | null; }; function formatMarketBucketDisplayLabel( bucket: MarketTopBucket, detail: Pick, ) { const label = String(bucket.label || "").trim(); if (label) { const unit = getMarketBucketUnit(bucket); let normalized = label.toUpperCase().replace(/\s+/g, ""); if (unit === "F" || isFahrenheitSymbol(detail.temp_symbol)) { normalized = normalized .replace(/ORHIGHER/g, "+") .replace(/ORLOWER/g, "-") .replace(/℃/g, "°F") .replace(/°C/g, "°F") .replace(/(?<=\d)F/g, "°F"); } else { normalized = normalized .replace(/ORHIGHER/g, "+") .replace(/ORLOWER/g, "-") .replace(/℃/g, "°C") .replace(/°F/g, "°C") .replace(/(?<=\d)C/g, "°C"); } return normalized.replace(/\+/g, "+"); } const unit = getMarketBucketUnit(bucket) === "F" || isFahrenheitSymbol(detail.temp_symbol) ? "°F" : "°C"; const lower = bucket.lower != null ? Number(bucket.lower) : null; const upper = bucket.upper != null ? Number(bucket.upper) : null; if ( lower != null && upper != null && Number.isFinite(lower) && Number.isFinite(upper) ) { return `${lower}-${upper}${unit}`; } const value = bucket.value ?? bucket.temp ?? lower; const numeric = value != null ? Number(value) : null; if (numeric != null && Number.isFinite(numeric)) { return isMarketBucketAbove(bucket) ? `${numeric}${unit}+` : `${numeric}${unit}`; } return "--"; } type ModelMetadata = NonNullable< NonNullable["open_meteo_multi_model"] >["model_metadata"]; function normalizeModelNameForVote(name: string) { return String(name || "") .trim() .toLowerCase() .replace(/[\s_/-]/g, ""); } function getModelVoteFamily(name: string) { const normalized = normalizeModelNameForVote(name); if (["icon", "iconeu", "icond2"].includes(normalized)) return "dwd_icon"; if (["gem", "gdps", "rdps", "hrdps"].includes(normalized)) return "eccc_gem"; if (["ecmwfaifs", "aifs"].includes(normalized)) return "ecmwf_aifs"; if (normalized === "ecmwf") return "ecmwf_ifs"; return normalized || name; } function getModelVotePriority(name: string) { const normalized = normalizeModelNameForVote(name); return ( { icond2: 40, iconeu: 30, icon: 20, hrdps: 40, rdps: 35, gdps: 30, gem: 20, ecmwfaifs: 30, ecmwf: 30, gfs: 30, jma: 30, mgm: 45, nws: 45, openmeteo: 15, }[normalized] || 10 ); } function getRoundedModelVoteDistribution( detail: CityDetail, targetDate?: string | null, ) { const view = getModelView(detail, targetDate); const representatives = new Map< string, { name: string; priority: number; value: number } >(); Object.entries(view.models || {}).forEach(([name, rawValue]) => { const normalized = normalizeModelNameForVote(name); const value = Number(rawValue); if (!Number.isFinite(value)) return; const family = getModelVoteFamily(name); const priority = getModelVotePriority(name); const current = representatives.get(family); if (!current || priority > current.priority) { representatives.set(family, { name, priority, value }); } }); const bucketMap = new Map(); representatives.forEach(({ name, value }) => { const rounded = Math.round(value); const row = bucketMap.get(rounded) || { count: 0, models: [] }; row.count += 1; row.models.push(name); bucketMap.set(rounded, row); }); const total = representatives.size; const rows = Array.from(bucketMap.entries()) .map(([value, row]) => ({ count: row.count, models: row.models, percent: total > 0 ? row.count / total : 0, value, })) .sort((a, b) => b.count - a.count || b.value - a.value); return { rows, total, }; } function normalizeMarketProbability(value?: number | null) { if (value == null) return null; const numeric = Number(value); if (!Number.isFinite(numeric)) return null; if (numeric > 1) return Math.max(0, Math.min(1, numeric / 100)); return Math.max(0, Math.min(1, numeric)); } function normalizeSignedProbability(value?: number | null) { if (value == null) return null; const numeric = Number(value); if (!Number.isFinite(numeric)) return null; if (Math.abs(numeric) > 1) return numeric / 100; return numeric; } function formatSignedPercent(value?: number | null, digits = 1) { const normalized = normalizeSignedProbability(value); if (normalized == null) return "--"; const percent = normalized * 100; const sign = percent > 0 ? "+" : ""; return `${sign}${percent.toFixed(digits)}%`; } function getMarketTopBuckets(scan?: MarketScan | null) { const buckets = Array.isArray(scan?.top_buckets) ? scan.top_buckets : []; if (!buckets.length) return []; return buckets .map((item) => ({ ...item, probability: normalizeMarketProbability(item.probability), })) .filter( (item): item is MarketTopBucket & { probability: number } => item.probability != null, ); } function getMarketAllBuckets(scan?: MarketScan | null) { const buckets = Array.isArray(scan?.all_buckets) ? scan.all_buckets : Array.isArray(scan?.top_buckets) ? scan.top_buckets : []; if (!buckets.length) return []; return buckets .map((item) => ({ ...item, probability: normalizeMarketProbability(item.probability), })) .filter( (item): item is MarketTopBucket & { probability: number } => item.probability != null, ); } function getMarketTopBucketKey(bucket: MarketTopBucket) { if (bucket?.value != null) { const valueNum = Number(bucket.value); if (Number.isFinite(valueNum)) return `v:${valueNum.toFixed(2)}`; } if (bucket?.temp != null) { const tempNum = Number(bucket.temp); if (Number.isFinite(tempNum)) return `t:${tempNum.toFixed(2)}`; } const parsed = parseTempFromText(bucket?.label); if (parsed != null) return `l:${parsed.toFixed(2)}`; 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(); const calibrationMode = String(view.calibrationMode || "") .trim() .toLowerCase(); if (engine === "emos" || calibrationMode.includes("emos")) { return locale === "en-US" ? "EMOS-calibrated probability" : "EMOS 校准概率"; } return locale === "en-US" ? "Model probability" : "模型概率"; } export function ProbabilityDistribution({ detail, hideTitle = false, targetDate, marketScan, }: { detail: CityDetail; hideTitle?: boolean; targetDate?: string | null; marketScan?: MarketScan | null; }) { 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; const probabilityEngineLabel = formatProbabilityEngineLabel( detail, targetDate, locale, ); const hasLgbmProbability = hasLgbmModel(detail, targetDate); const modelVoteView = useMemo( () => getRoundedModelVoteDistribution(detail, targetDate), [detail, targetDate], ); const modelVoteHint = modelVoteView.rows .slice(0, 2) .map( (row) => `${row.value}${detail.temp_symbol} ${row.count}/${modelVoteView.total}`, ) .join(" · "); const marketTopBuckets = isToday ? getMarketTopBuckets(marketScan) : []; const marketAllBuckets = isToday ? getMarketAllBuckets(marketScan) : []; const sortedMarketTopBuckets = useMemo(() => { const sorted = [...marketTopBuckets].sort( (a, b) => Number(b.probability || 0) - Number(a.probability || 0), ); const deduped: Array = []; const seenKeys = new Set(); for (const row of sorted) { const key = getMarketTopBucketKey(row); if (seenKeys.has(key)) continue; seenKeys.add(key); deduped.push(row); if (deduped.length >= 4) break; } return deduped; }, [marketTopBuckets]); 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 ? formatBucketDisplayLabel(topProbability, detail) : null; const topProbabilityTemp = topProbability ? getBucketTemp(topProbability) : null; const probabilitiesForMarketContracts = view.probabilitiesAll?.length > 0 ? view.probabilitiesAll : view.probabilities || []; const marketContractRows = useMemo(() => { if (!isToday || !marketScan?.available || marketAllBuckets.length === 0) { return []; } const rows: ProbabilityDisplayRow[] = []; const seenKeys = new Set(); for (const marketBucket of marketAllBuckets) { const probability = getAggregatedModelProbabilityForMarketBucket( probabilitiesForMarketContracts, marketBucket, detail, ); const key = marketBucket.slug || marketBucket.label || `${marketBucket.lower ?? marketBucket.value ?? marketBucket.temp}-${marketBucket.upper ?? ""}`; if (seenKeys.has(key)) continue; seenKeys.add(key); rows.push({ key, label: formatMarketBucketDisplayLabel(marketBucket, detail), probability: probability ?? 0, marketBucket, }); } return rows; }, [ detail, isToday, marketAllBuckets, marketScan?.available, probabilitiesForMarketContracts, ]); const modelProbabilityRows = useMemo( () => (view.probabilities || []).slice(0, 6).map((bucket, index) => { const bucketTemp = getBucketTemp(bucket); return { key: `${bucket.label || bucket.value || index}`, label: formatBucketDisplayLabel(bucket, detail), probability: Number(bucket.probability || 0), marketBucket: findMarketBucketForDisplayTemp( marketAllBuckets, bucketTemp, detail, ), }; }), [detail, marketAllBuckets, view.probabilities], ); const probabilityRows = marketContractRows.length > 0 ? marketContractRows.slice(0, 8) : modelProbabilityRows; const topContractRow = marketContractRows.length > 0 ? marketContractRows.reduce((best, row) => row.probability > best.probability ? row : best, ) : null; const displayTopLabel = topContractRow?.label || topProbabilityLabel || null; const displayTopProbability = topContractRow?.probability ?? (topProbability?.probability != null ? Number(topProbability.probability) : null); const displayTopProbabilityText = toPercent(displayTopProbability); const displayUsesMarketBuckets = marketContractRows.length > 0; const linkedMarketBucket = useMemo(() => { if (topContractRow?.marketBucket) return topContractRow.marketBucket; if (topProbabilityTemp == null) return null; return findMarketBucketForDisplayTemp( marketAllBuckets, topProbabilityTemp, detail, ); }, [detail, marketAllBuckets, topContractRow, topProbabilityTemp]); const priceAnalysis = marketScan?.price_analysis; const yesPriceView = priceAnalysis?.yes; const noPriceView = priceAnalysis?.no; const linkedMarketAsk = linkedMarketBucket?.yes_buy ?? linkedMarketBucket?.market_price ?? yesPriceView?.ask ?? null; const linkedNoAsk = linkedMarketBucket?.no_buy ?? noPriceView?.ask ?? null; const linkedContractLabel = topContractRow?.label || (linkedMarketBucket ? formatMarketBucketDisplayLabel(linkedMarketBucket, detail) : null) || topProbabilityLabel || null; const aggregatedMarketProbability = getAggregatedModelProbabilityForMarketBucket( probabilitiesForMarketContracts, linkedMarketBucket, detail, ); const linkedMarketProbability = topContractRow?.probability ?? aggregatedMarketProbability ?? (topProbability?.probability != null ? Number(topProbability.probability) : null); const linkedMarketProbabilityText = toPercent(linkedMarketProbability); const linkedMarketEdge = linkedMarketProbability != null && linkedMarketAsk != null ? linkedMarketProbability - Number(linkedMarketAsk) : null; const linkedNoEdge = linkedMarketProbability != null && linkedNoAsk != null ? 1 - linkedMarketProbability - Number(linkedNoAsk) : null; const linkedBestSide = linkedMarketBucket && linkedNoEdge != null && linkedMarketEdge != null ? linkedNoEdge > linkedMarketEdge ? "no" : "yes" : null; const linkedBestAsk = linkedBestSide === "no" ? linkedNoAsk : linkedMarketAsk; const linkedBestEdge = linkedBestSide === "no" ? linkedNoEdge : linkedMarketEdge; const preferredPriceView = linkedMarketBucket ? { ask: linkedBestAsk, edge: linkedBestEdge, } : priceAnalysis?.best_side === "no" ? noPriceView : yesPriceView; const preferredSideLabel = linkedMarketBucket ? linkedBestSide === "no" ? "NO" : "YES" : priceAnalysis?.best_side === "no" ? locale === "en-US" ? "NO" : "NO" : locale === "en-US" ? "YES" : "YES"; const yesDisplayPrice = linkedMarketBucket ? linkedMarketAsk : yesPriceView?.ask; const noDisplayPrice = linkedMarketBucket ? linkedNoAsk : noPriceView?.ask; const yesDisplayEdge = linkedMarketBucket ? linkedMarketEdge : yesPriceView?.edge; const noDisplayEdge = linkedMarketBucket ? linkedNoEdge : noPriceView?.edge; const hasPriceAnalysis = isToday && (Boolean(priceAnalysis?.available) || Boolean(marketScan) || Boolean(topProbability)); const lockEdge = normalizeSignedProbability(priceAnalysis?.lock?.edge); const lockAvailable = Boolean( priceAnalysis?.lock?.available && lockEdge != null, ); const quoteSource = linkedMarketBucket?.quote_source || marketScan?.yes_token?.quote_source || marketScan?.no_token?.quote_source || null; const quoteAgeMs = linkedMarketBucket?.quote_age_ms ?? marketScan?.yes_token?.quote_age_ms ?? marketScan?.no_token?.quote_age_ms; const quoteSourceLabel = quoteSource === "polymarket_ws" ? locale === "en-US" ? `WS live${quoteAgeMs != null ? ` · ${Math.max(0, Math.round(Number(quoteAgeMs) / 1000))}s` : ""}` : `WS 实时${quoteAgeMs != null ? ` · ${Math.max(0, Math.round(Number(quoteAgeMs) / 1000))}秒` : ""}` : locale === "en-US" ? "CLOB fallback" : "CLOB 兜底"; const actionableEdge = normalizeSignedProbability(preferredPriceView?.edge); const linkedContractOverpriced = Boolean(linkedMarketBucket) && linkedBestSide === "no" && linkedMarketProbability != null && linkedMarketAsk != null && linkedMarketEdge != null && linkedMarketEdge < 0 && linkedNoEdge != null && linkedNoEdge > 0; const linkedContractOverpay = linkedContractOverpriced && linkedMarketProbability != null && linkedMarketAsk != null ? Number(linkedMarketAsk) - linkedMarketProbability : null; const actionText = !marketScan ? locale === "en-US" ? "Waiting" : "等待" : !marketScan.available ? locale === "en-US" ? "No market" : "无盘口" : actionableEdge == null ? locale === "en-US" ? "No quote" : "无报价" : actionableEdge >= 0.02 ? linkedContractOverpriced ? locale === "en-US" ? "Overpriced" : "市场偏贵" : locale === "en-US" ? `Watch ${preferredSideLabel}` : `可关注 ${preferredSideLabel}` : actionableEdge > 0 ? linkedContractOverpriced ? locale === "en-US" ? "Slightly overpriced" : "略偏贵" : locale === "en-US" ? `Small ${preferredSideLabel}` : `${preferredSideLabel} 优势较小` : locale === "en-US" ? "No clear edge" : "暂无优势"; const actionNote = linkedContractOverpriced && linkedContractOverpay != null ? locale === "en-US" ? `YES above model by ${formatSignedPercent(linkedContractOverpay)}` : `YES 高于模型 ${formatSignedPercent(linkedContractOverpay)}` : actionableEdge != null && actionableEdge >= 0.02 ? locale === "en-US" ? `${formatSignedPercent(actionableEdge)} vs ask` : `相对买价 ${formatSignedPercent(actionableEdge)}` : locale === "en-US" ? `${preferredSideLabel} ${formatSignedPercent(actionableEdge)}` : `${preferredSideLabel} ${formatSignedPercent(actionableEdge)}`; return (
{!hideTitle &&

{t("section.probability")}

}
{probabilityEngineLabel} {displayTopLabel && displayTopProbabilityText ? locale === "en-US" ? displayUsesMarketBuckets ? `${displayTopLabel} is the top displayed contract bucket at ${displayTopProbabilityText}` : `${displayTopLabel} is the top single bucket at ${displayTopProbabilityText}` : displayUsesMarketBuckets ? `${displayTopLabel} 为当前显示分布最高,${displayTopProbabilityText}` : `${displayTopLabel} 单点最高,${displayTopProbabilityText}` : locale === "en-US" ? "Awaiting calibrated buckets" : "等待校准概率桶"}

{hasLgbmProbability ? locale === "en-US" ? "LGBM is the learned intraday adjustment; raw model points below are only diagnostic." : "LGBM 作为日内学习校准项;下方原始模型落点仅用于诊断。" : locale === "en-US" ? "Using the calibrated probability distribution; raw model points below are not probabilities." : "使用校准后的概率分布;下方原始模型落点不是概率。"}

{marketScan?.available && (topMarketBucketText || marketYesText) && (
{useMarketTopBuckets ? locale === "en-US" ? `Market reference only: top traded bucket ${topMarketBucketText}` : `市场仅作参考:最高交易温度桶 ${topMarketBucketText}` : locale === "en-US" ? `Market reference only: this bucket ${marketYesText}` : `市场仅作参考:该温度桶 ${marketYesText}`}
)}
{locale === "en-US" ? "Probability distribution" : "概率分布"} {marketContractRows.length > 0 ? locale === "en-US" ? "market buckets are aggregated from single-degree probability buckets" : "市场合约桶由单点概率分布聚合" : locale === "en-US" ? "temperature probability buckets" : "温度概率桶"}
{probabilityRows.length === 0 ? ( ) : ( probabilityRows.map((row, index) => { const probability = Math.round( Number(row.probability || 0) * 100, ); return (
{row.label}
{probability}%
); }) )}
{hasPriceAnalysis && (
{locale === "en-US" ? "Win-rate reference" : "胜率参考"} {!marketScan ? locale === "en-US" ? "Waiting for market context" : "等待市场参照" : !marketScan.available ? locale === "en-US" ? "No matched active market" : "未匹配到活跃盘口" : locale === "en-US" ? `${linkedContractLabel || topProbabilityLabel || "Temperature bucket"} · model ${linkedMarketProbabilityText || topProbabilityText || "--"}` : `${linkedContractLabel || topProbabilityLabel || "温度桶"} · 模型 ${linkedMarketProbabilityText || topProbabilityText || "--"}`}
{locale === "en-US" ? "Bucket" : "温度桶"} {linkedContractLabel || topProbabilityLabel || "--"} {linkedMarketProbabilityText || topProbabilityText || "--"}
{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" ? "Model support" : "模型支持"} {modelVoteHint || "--"} {locale === "en-US" ? "raw model agreement" : "原始模型一致性"}
{locale === "en-US" ? "Market role" : "盘口角色"} {locale === "en-US" ? "Reference only" : "仅作参考"} {quoteSourceLabel}

{locale === "en-US" ? "This card follows the same rule as AI forecast: DEB first, model agreement second, METAR conflict check before settlement." : "该卡片与 AI 预测口径一致:先看 DEB,再看模型支持,最后检查 METAR 是否冲突。"}

)} {modelVoteHint && (
{locale === "en-US" ? "Raw model points" : "原始模型落点"} {modelVoteHint} {locale === "en-US" ? "diagnostic only; EMOS and contract rows use calibrated probabilities" : "仅作诊断;EMOS 与合约行使用校准概率"}
)}
); }