P0-1 术语统一: - WeatherDecisionBand 改用"上修/下修/维持"替代"偏高温/暂不追/等待确认" - 与 AI 后端提示词使用的术语体系一致 P0-9 图表"现在"标记: - chart-utils.ts 导出 currentIndex - AiCityTemperatureChart 在 currentIndex 处绘制竖线(蓝色虚线) P0-13 专业术语解释: - DataFreshnessBar 新增 labelTitle 属性(hover tooltip) - METAR → "机场气象观测报文" / "Meteorological Aerodrome Report" - HKO → "香港天文台官方实测" / "Hong Kong Observatory official readings" P0-16 异常行动建议: - primaryReason 追加行动指引(实测突破→建议关注偏高温区间等待确认 / 峰值已过→建议避免追高 / 观测过旧→建议等待新报文) P1-8 DEB 路径分段样式: - 过去部分实线(已确定),未来部分虚线(预测不确定) P1-11 HistoryChart 单位: - tooltip 使用 temp_symbol 替代硬编码 ° Tested: npx tsc --noEmit
613 lines
21 KiB
TypeScript
613 lines
21 KiB
TypeScript
import type { MarketScan, MarketTopBucket } from "@/lib/dashboard-types";
|
||
import { getTodayPaceView } from "@/lib/pace-utils";
|
||
import {
|
||
formatTemperatureValue,
|
||
normalizeTemperatureLabel,
|
||
} from "@/lib/temperature-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: isEn
|
||
? "No tradable quote is available yet; weather evidence is still shown."
|
||
: "暂无可交易价格,仅展示天气证据。天气判断仍可参考。",
|
||
status: "unavailable",
|
||
title: isEn ? "Market price temporarily unavailable" : "市场价格暂不可用",
|
||
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 today’s 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
|
||
? "Revise upward"
|
||
: "预计最高温上修"
|
||
: paceTone === "cold"
|
||
? isEn
|
||
? "Revise downward"
|
||
: "预计最高温下修"
|
||
: isEn
|
||
? "Stay with model base"
|
||
: "维持模型基准";
|
||
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,
|
||
};
|
||
}
|
||
|