Files
PolyWeather/frontend/components/dashboard/scan-terminal/city-card-decision-utils.ts
T
2569718930@qq.com 504efcbb49 Clarify weather-first decision card label
The prior label said the weather decision layer had no market price connected, which could be read as a broken market integration. The card now says weather-first read with market prices shown separately.

Constraint: Market price layer is already rendered below the weather decision band

Rejected: Keep 'no market price input' | accurate internally but misleading in user-facing Chinese copy

Confidence: high

Scope-risk: narrow

Tested: npm run build

Not-tested: Visual screenshot review
2026-04-27 09:10:57 +08:00

615 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { MarketScan, MarketTopBucket } from "@/lib/dashboard-types";
import {
formatTemperatureValue,
getTodayPaceView,
normalizeTemperatureLabel,
} from "@/lib/dashboard-utils";
import type { AiCityForecastPayload } from "@/components/dashboard/scan-terminal/types";
export type WeatherDecisionView = {
action: string;
confidence: string;
expectedHigh: string;
kicker: string;
reasons: string[];
risk: string;
targetRange: string;
tone: "cold" | "neutral" | "warm" | "watch";
};
export type MarketDecisionView = {
bucketLabel: string;
confidence: string;
edgeText: string;
impliedText: string;
marketUrl?: string | null;
modelText: string;
priceText: string;
reason: string;
status: "loading" | "ready" | "unavailable";
title: string;
tone: "cold" | "neutral" | "warm" | "watch";
};
export function normalizeMarketProbability(value: unknown) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) return null;
if (numeric > 1) return numeric / 100;
if (numeric < 0) return null;
return numeric;
}
function normalizeQuotePrice(value: unknown) {
const normalized = normalizeMarketProbability(value);
if (normalized == null || normalized <= 0) return null;
return normalized;
}
function toFiniteMarketNumber(value: unknown) {
if (value == null || value === "") return null;
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : null;
}
function hasReasonableTemperatureValue(value: number | null): value is number {
return value != null && Number.isFinite(value) && value > -130 && value < 150;
}
function isPlausibleExpectedHigh(
value: number | null,
references: Array<number | null>,
) {
if (!hasReasonableTemperatureValue(value)) return false;
const usableReferences = references.filter(hasReasonableTemperatureValue);
if (!usableReferences.length) return true;
const referenceMin = Math.min(...usableReferences);
const referenceMax = Math.max(...usableReferences);
return value >= referenceMin - 6 && value <= referenceMax + 6;
}
export function resolveExpectedHighCandidate({
aiPredictedMax,
currentTemp,
deb,
modelMax,
modelMin,
paceAdjustedHigh,
}: {
aiPredictedMax?: unknown;
currentTemp?: number | null;
deb?: number | null;
modelMax?: number | null;
modelMin?: number | null;
paceAdjustedHigh?: number | null;
}) {
const ai = toFiniteMarketNumber(aiPredictedMax);
const modelCenter =
modelMin != null && modelMax != null
? (modelMin + modelMax) / 2
: null;
const references = [deb ?? null, modelMin ?? null, modelMax ?? null, currentTemp ?? null];
const candidates = [ai, deb ?? null, paceAdjustedHigh ?? null, modelCenter, currentTemp ?? null];
for (const candidate of candidates) {
if (isPlausibleExpectedHigh(candidate, references)) {
return candidate;
}
}
return null;
}
export function formatMarketPercent(value: number | null, digits = 1) {
if (value == null || !Number.isFinite(value)) return "--";
return `${(value * 100).toFixed(digits)}%`;
}
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)}¢`;
}
export function formatSignedMarketPercent(value: number | null) {
if (value == null || !Number.isFinite(value)) return "--";
const sign = value > 0 ? "+" : "";
return `${sign}${(value * 100).toFixed(1)}%`;
}
export function normalizeMarketComparableTemp(
displayTemp: number | null,
tempSymbol: string,
bucket?: MarketTopBucket | null,
) {
if (displayTemp == null || !Number.isFinite(displayTemp)) return null;
const bucketUnit = String(bucket?.unit || "").trim().toUpperCase();
const isDisplayF = String(tempSymbol || "").toUpperCase().includes("F");
if (bucketUnit === "F" && !isDisplayF) return displayTemp * 1.8 + 32;
if (bucketUnit === "C" && isDisplayF) return (displayTemp - 32) / 1.8;
return displayTemp;
}
export function getMarketBucketLabel(bucket?: MarketTopBucket | null, tempSymbol = "°C") {
const direct = String(bucket?.label || "").trim();
if (direct && /[°]?[CF]\b|\d+\s*[+-]?$/i.test(direct) && !/[。紊]/.test(direct)) {
const labelSymbol = /°?\s*F\b/i.test(direct)
? "°F"
: /°?\s*C\b/i.test(direct)
? "°C"
: tempSymbol;
return normalizeTemperatureLabel(direct, labelSymbol).replace(/\s+°([CF])\b/g, "°$1");
}
const numeric = toFiniteMarketNumber(bucket?.temp ?? bucket?.value ?? bucket?.lower);
if (numeric != null) {
const unit = bucket?.unit
? ${String(bucket.unit).replace(/^°/, "").toUpperCase()}`
: tempSymbol;
return `${numeric.toFixed(0)}${unit}`;
}
return "--";
}
function getBucketAnchor(bucket: MarketTopBucket) {
return toFiniteMarketNumber(bucket.temp ?? bucket.value ?? bucket.lower);
}
function marketBucketLabelText(bucket?: MarketTopBucket | null) {
return String(`${bucket?.label || ""} ${bucket?.slug || ""} ${bucket?.question || ""}`)
.trim()
.toLowerCase();
}
function isMarketBucketAbove(bucket?: MarketTopBucket | null) {
return /\b(or[-\s]?higher|or[-\s]?above|above|higher|at least|greater than)\b/i.test(
marketBucketLabelText(bucket),
);
}
function isMarketBucketBelow(bucket?: MarketTopBucket | null) {
return /\b(or[-\s]?lower|or[-\s]?below|below|lower|at most|less than)\b/i.test(
marketBucketLabelText(bucket),
);
}
function getRoundedWeatherBucketValue(
expectedHigh: number | null,
tempSymbol: string,
bucket: MarketTopBucket,
) {
const comparable = normalizeMarketComparableTemp(expectedHigh, tempSymbol, bucket);
if (comparable == null || !Number.isFinite(comparable)) return null;
return Math.round(comparable);
}
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 getBucketDisplayUnit(bucket: MarketTopBucket, tempSymbol: string) {
return bucket.unit
? ${String(bucket.unit).replace(/^°/, "").toUpperCase()}`
: tempSymbol;
}
function buildBucketMappingExplanation({
bucket,
expectedHigh,
isEn,
tempSymbol,
}: {
bucket: MarketTopBucket;
expectedHigh: number | null;
isEn: boolean;
tempSymbol: string;
}) {
const comparable = normalizeMarketComparableTemp(expectedHigh, tempSymbol, bucket);
const rounded = getRoundedWeatherBucketValue(expectedHigh, tempSymbol, bucket);
if (comparable == null || rounded == null) return "";
const unit = getBucketDisplayUnit(bucket, tempSymbol);
const bucketLabel = getMarketBucketLabel(bucket, tempSymbol);
const expectedText = formatTemperatureValue(comparable, unit, { digits: 1 });
const hasRounding = Math.abs(comparable - rounded) >= 0.05;
if (isEn) {
const mapping = hasRounding
? `Expected high ${expectedText} maps to the ${bucketLabel} settlement bucket after rounding.`
: `Expected high ${expectedText} maps to the ${bucketLabel} bucket.`;
return `${mapping} Model probability is still the bucket-distribution probability, not 100% just because the center maps there.`;
}
const mapping = hasRounding
? `预计高点 ${expectedText} 按结算四舍五入映射到 ${bucketLabel} 桶。`
: `预计高点 ${expectedText} 对应 ${bucketLabel} 桶。`;
return `${mapping} 模型概率仍按温度分布计算,不等于把该桶视为 100%。`;
}
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,
tempSymbol: string,
) {
const buckets = (
Array.isArray(scan?.all_buckets)
? scan?.all_buckets
: Array.isArray(scan?.top_buckets)
? 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 roundedTarget = Math.round(comparable);
const roundedAnchor = Math.round(anchor);
const lower = bucket.lower != null ? Number(bucket.lower) : anchor;
const upper = bucket.upper != null ? Number(bucket.upper) : null;
if (upper != null && Number.isFinite(lower) && Number.isFinite(upper)) {
return roundedTarget >= lower - 0.01 && roundedTarget <= upper + 0.01;
}
if (isMarketBucketAbove(bucket)) return roundedTarget >= roundedAnchor;
if (isMarketBucketBelow(bucket)) return roundedTarget <= roundedAnchor;
return roundedAnchor === roundedTarget;
};
if (!buckets.length || expectedHigh == null || !Number.isFinite(expectedHigh)) {
return isReasonableFallback(selectedBucket) ? selectedBucket : null;
}
let roundedMatch: MarketTopBucket | null = null;
let nearest: MarketTopBucket | null = null;
let nearestDelta = Number.POSITIVE_INFINITY;
for (const bucket of buckets) {
const comparable = normalizeMarketComparableTemp(expectedHigh, tempSymbol, bucket);
if (comparable == null) continue;
const roundedTarget = getRoundedWeatherBucketValue(expectedHigh, tempSymbol, bucket);
const lower = bucket.lower != null ? Number(bucket.lower) : null;
const upper = bucket.upper != null ? Number(bucket.upper) : null;
const anchor = getBucketAnchor(bucket);
if (anchor != null && roundedTarget != null && Math.round(anchor) === roundedTarget) {
roundedMatch = bucket;
break;
}
if (
roundedMatch == null &&
lower != null &&
upper != null &&
Number.isFinite(lower) &&
Number.isFinite(upper) &&
roundedTarget != null &&
roundedTarget >= lower - 0.01 &&
roundedTarget <= upper + 0.01
) {
roundedMatch = bucket;
break;
}
if (anchor == null) continue;
const delta = Math.abs(anchor - comparable);
if (delta < nearestDelta) {
nearest = bucket;
nearestDelta = delta;
}
}
if (roundedMatch) return roundedMatch;
if (!nearest) return isReasonableFallback(selectedBucket) ? selectedBucket : null;
if (Number.isFinite(nearestDelta) && isReasonableFallback(nearest)) {
return nearest;
}
return isReasonableFallback(selectedBucket) ? selectedBucket : null;
}
export function buildMarketDecisionView({
expectedHigh,
isEn,
marketScan,
marketStatus,
tempSymbol,
}: {
expectedHigh: number | null;
isEn: boolean;
marketScan: MarketScan | null;
marketStatus: "idle" | "loading" | "ready" | "failed";
tempSymbol: string;
}): MarketDecisionView {
if (marketStatus === "loading") {
return {
bucketLabel: "--",
confidence: "--",
edgeText: "--",
impliedText: "--",
modelText: "--",
priceText: "--",
reason: isEn
? "Fetching the existing Polymarket quote layer for this city."
: "正在读取项目内已有的 Polymarket 价格层。",
status: "loading",
title: isEn ? "Syncing market price" : "正在同步市场价格",
tone: "watch",
};
}
if (!marketScan?.available) {
return {
bucketLabel: "--",
confidence: "--",
edgeText: "--",
impliedText: "--",
modelText: "--",
priceText: "--",
reason:
marketScan?.reason ||
(isEn
? "No active matched Polymarket temperature market is available for this city yet."
: "该城市暂未匹配到可用的 Polymarket 温度市场。"),
status: "unavailable",
title: isEn ? "No market quote" : "暂无市场报价",
tone: "watch",
};
}
const bucket = pickMarketBucketForWeatherCenter(marketScan, expectedHigh, tempSymbol);
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 todays expected high closely enough, so edge is withheld."
: "已找到市场,但温度桶与今日预计高点不够匹配,暂不计算概率差。",
status: "ready",
title: isEn ? "Market bucket needs rematch" : "市场温度桶需重新匹配",
tone: "watch",
};
}
const bucketLabel = getMarketBucketLabel(bucket, tempSymbol);
const bucketMappingExplanation = buildBucketMappingExplanation({
bucket,
expectedHigh,
isEn,
tempSymbol,
});
const bucketProbability = getBucketModelProbability(bucket);
const scanProbability = normalizeMarketProbability(marketScan.model_probability);
const modelProbability = bucketProbability ?? scanProbability;
const yesBuy =
normalizeQuotePrice(bucket?.yes_buy) ??
normalizeQuotePrice(marketScan.yes_buy);
const yesSell =
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 =
edge == null
? "neutral"
: edge >= 0.08
? "warm"
: edge <= -0.08
? "cold"
: "neutral";
const title =
edge == null
? isEn
? "Market quote matched"
: "已匹配市场报价"
: edge >= 0.08
? isEn
? "Weather probability above market"
: "天气概率高于市场报价"
: edge <= -0.08
? isEn
? "Market already prices this in"
: "市场价格已偏充分"
: isEn
? "Price near weather probability"
: "价格接近天气概率";
return {
bucketLabel,
confidence: marketScan.confidence || "--",
edgeText: formatSignedMarketPercent(edge),
impliedText: formatMarketPercent(implied),
marketUrl:
bucket?.market_url ||
(bucket?.slug
? `https://polymarket.com/market/${bucket.slug}`
: marketScan.market_url || marketScan.primary_market_url || null),
modelText: formatMarketPercent(modelProbability),
priceText: formatMarketCents(yesBuy),
reason:
edge == null
? isEn
? "Quote is available, but model probability or YES price is incomplete."
: "已获取报价,但模型概率或 YES 价格不完整。"
: isEn
? `Model probability is ${formatMarketPercent(modelProbability)} versus market-implied ${formatMarketPercent(implied)}.${bucketMappingExplanation ? ` ${bucketMappingExplanation}` : ""}`
: `模型概率 ${formatMarketPercent(modelProbability)},市场隐含约 ${formatMarketPercent(implied)}${bucketMappingExplanation ? ` ${bucketMappingExplanation}` : ""}`,
status: "ready",
title,
tone,
};
}
export function buildWeatherDecisionView({
aiCityForecast,
currentTemp,
deb,
isEn,
localModelSupportNote,
modelEntries,
modelMax,
modelMin,
paceTone,
paceView,
peakWindow,
tempSymbol,
}: {
aiCityForecast: AiCityForecastPayload["city_forecast"] | null;
currentTemp: number | null;
deb: number | null;
isEn: boolean;
localModelSupportNote: string;
modelEntries: Array<readonly [string, number]>;
modelMax: number | null;
modelMin: number | null;
paceTone: string;
paceView: ReturnType<typeof getTodayPaceView> | null;
peakWindow: string;
tempSymbol: string;
}): WeatherDecisionView {
const center = resolveExpectedHighCandidate({
aiPredictedMax: aiCityForecast?.predicted_max,
currentTemp,
deb,
modelMax,
modelMin,
paceAdjustedHigh: paceView?.paceAdjustedHigh ?? null,
});
const aiLow = toFiniteMarketNumber(aiCityForecast?.range_low);
const aiHigh = toFiniteMarketNumber(aiCityForecast?.range_high);
const low = aiLow != null
? aiLow
: modelMin != null
? modelMin
: center != null
? center - 1
: null;
const high = aiHigh != null
? aiHigh
: modelMax != null
? modelMax
: center != null
? center + 1
: null;
const spread = modelMax != null && modelMin != null ? modelMax - modelMin : null;
const modelCount = modelEntries.length;
const aiConfidence = String(aiCityForecast?.confidence || "").trim();
const confidence =
aiConfidence ||
(modelCount >= 4 && spread != null && spread <= 2
? isEn
? "High"
: "高"
: modelCount >= 2
? isEn
? "Medium"
: "中"
: isEn
? "Low"
: "低");
const tone =
modelCount <= 1
? "watch"
: paceTone === "warm" || paceTone === "cold" || paceTone === "neutral"
? paceTone
: "neutral";
const action =
modelCount <= 1
? isEn
? "Wait for model cluster"
: "等待模型补齐"
: paceTone === "warm"
? isEn
? "Watch hotter range"
: "关注偏高温区间"
: paceTone === "cold"
? isEn
? "Avoid chasing high"
: "暂不追高温"
: isEn
? "Wait for peak-window confirmation"
: "等待峰值窗口确认";
const expectedHigh =
center != null && Number.isFinite(Number(center))
? formatTemperatureValue(Number(center), tempSymbol, { digits: 1 })
: "--";
const targetRange =
low != null && high != null && Number.isFinite(Number(low)) && Number.isFinite(Number(high))
? `${formatTemperatureValue(Number(low), tempSymbol, { digits: 1 })} ~ ${formatTemperatureValue(Number(high), tempSymbol, { digits: 1 })}`
: expectedHigh;
const reasons = [
localModelSupportNote,
paceView?.summary || "",
currentTemp != null
? isEn
? `Latest observed anchor is ${formatTemperatureValue(currentTemp, tempSymbol, { digits: 1 })}.`
: `最新实测锚点为 ${formatTemperatureValue(currentTemp, tempSymbol, { digits: 1 })}。`
: "",
]
.filter(Boolean)
.slice(0, 3);
const risk =
paceTone === "warm"
? isEn
? "Risk trigger: if later METAR cools back toward the curve before the peak window, downgrade the hotter read."
: "风险触发:如果后续 METAR 在峰值窗口前回落到曲线附近,需要下调偏高温判断。"
: paceTone === "cold"
? isEn
? "Risk trigger: only restore higher buckets if observations recover before the peak window."
: "风险触发:只有实测在峰值窗口前修复,才重新考虑更高温区间。"
: isEn
? "Risk trigger: a clear METAR/path break before the peak window should decide direction."
: "风险触发:峰值窗口前若 METAR 或路径明显偏离,再决定方向。";
return {
action,
confidence,
expectedHigh,
kicker: isEn
? "Weather-first read · market price shown separately"
: "天气优先判断 · 市场价格另列",
reasons,
risk,
targetRange,
tone,
};
}