2026-04-26 09:13:18 +08:00
|
|
|
|
import type { MarketScan, MarketTopBucket } from "@/lib/dashboard-types";
|
|
|
|
|
|
import { formatTemperatureValue, getTodayPaceView } 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-26 09:40:02 +08:00
|
|
|
|
function normalizeQuotePrice(value: unknown) {
|
|
|
|
|
|
const normalized = normalizeMarketProbability(value);
|
|
|
|
|
|
if (normalized == null || normalized <= 0) return null;
|
|
|
|
|
|
return normalized;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-26 10:02:54 +08:00
|
|
|
|
function toFiniteMarketNumber(value: unknown) {
|
|
|
|
|
|
if (value == null || value === "") return null;
|
|
|
|
|
|
const numeric = Number(value);
|
|
|
|
|
|
return Number.isFinite(numeric) ? numeric : null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-26 09:13:18 +08:00
|
|
|
|
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 "--";
|
2026-04-26 09:40:02 +08:00
|
|
|
|
if (value > 0 && value < 0.01) return "<1¢";
|
2026-04-26 09:13:18 +08:00
|
|
|
|
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();
|
2026-04-26 09:40:02 +08:00
|
|
|
|
if (direct && /[°]?[CF]\b|\d+\s*[+-]?$/i.test(direct) && !/[�。紊]/.test(direct)) {
|
|
|
|
|
|
return direct.replace(/\bC\b/g, "°C").replace(/\bF\b/g, "°F");
|
|
|
|
|
|
}
|
2026-04-26 10:02:54 +08:00
|
|
|
|
const numeric = toFiniteMarketNumber(bucket?.temp ?? bucket?.value ?? bucket?.lower);
|
|
|
|
|
|
if (numeric != null) {
|
2026-04-26 09:13:18 +08:00
|
|
|
|
const unit = bucket?.unit
|
|
|
|
|
|
? `°${String(bucket.unit).replace(/^°/, "").toUpperCase()}`
|
|
|
|
|
|
: tempSymbol;
|
|
|
|
|
|
return `${numeric.toFixed(0)}${unit}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
return "--";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-26 09:40:02 +08:00
|
|
|
|
function getBucketAnchor(bucket: MarketTopBucket) {
|
2026-04-26 10:02:54 +08:00
|
|
|
|
return toFiniteMarketNumber(bucket.temp ?? bucket.value ?? bucket.lower);
|
2026-04-26 09:40:02 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-26 10:13:44 +08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-26 09:40:02 +08:00
|
|
|
|
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 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,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-26 09:13:18 +08:00
|
|
|
|
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[];
|
2026-04-26 09:40:02 +08:00
|
|
|
|
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 unit = String(bucket.unit || "").toUpperCase();
|
|
|
|
|
|
const maxReasonableDelta = unit === "F" ? 16 : 8;
|
|
|
|
|
|
return Math.abs(anchor - comparable) <= maxReasonableDelta;
|
|
|
|
|
|
};
|
2026-04-26 09:13:18 +08:00
|
|
|
|
if (!buckets.length || expectedHigh == null || !Number.isFinite(expectedHigh)) {
|
2026-04-26 10:02:54 +08:00
|
|
|
|
return isReasonableFallback(selectedBucket) ? selectedBucket : null;
|
2026-04-26 09:13:18 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-26 10:13:44 +08:00
|
|
|
|
let roundedMatch: MarketTopBucket | null = null;
|
2026-04-26 09:13:18 +08:00
|
|
|
|
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;
|
2026-04-26 10:13:44 +08:00
|
|
|
|
const roundedTarget = getRoundedWeatherBucketValue(expectedHigh, tempSymbol, bucket);
|
2026-04-26 09:13:18 +08:00
|
|
|
|
const lower = bucket.lower != null ? Number(bucket.lower) : null;
|
|
|
|
|
|
const upper = bucket.upper != null ? Number(bucket.upper) : null;
|
2026-04-26 10:13:44 +08:00
|
|
|
|
const anchor = getBucketAnchor(bucket);
|
|
|
|
|
|
if (anchor != null && roundedTarget != null && Math.round(anchor) === roundedTarget) {
|
|
|
|
|
|
roundedMatch = bucket;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-04-26 09:13:18 +08:00
|
|
|
|
if (
|
2026-04-26 10:13:44 +08:00
|
|
|
|
roundedMatch == null &&
|
2026-04-26 09:13:18 +08:00
|
|
|
|
lower != null &&
|
|
|
|
|
|
upper != null &&
|
|
|
|
|
|
Number.isFinite(lower) &&
|
|
|
|
|
|
Number.isFinite(upper) &&
|
2026-04-26 10:13:44 +08:00
|
|
|
|
roundedTarget != null &&
|
|
|
|
|
|
roundedTarget >= lower - 0.01 &&
|
|
|
|
|
|
roundedTarget <= upper + 0.01
|
2026-04-26 09:13:18 +08:00
|
|
|
|
) {
|
2026-04-26 10:13:44 +08:00
|
|
|
|
roundedMatch = bucket;
|
|
|
|
|
|
break;
|
2026-04-26 09:13:18 +08:00
|
|
|
|
}
|
2026-04-26 09:40:02 +08:00
|
|
|
|
if (anchor == null) continue;
|
2026-04-26 09:13:18 +08:00
|
|
|
|
const delta = Math.abs(anchor - comparable);
|
|
|
|
|
|
if (delta < nearestDelta) {
|
|
|
|
|
|
nearest = bucket;
|
|
|
|
|
|
nearestDelta = delta;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-26 10:13:44 +08:00
|
|
|
|
if (roundedMatch) return roundedMatch;
|
2026-04-26 09:40:02 +08:00
|
|
|
|
if (!nearest) return isReasonableFallback(selectedBucket) ? selectedBucket : null;
|
|
|
|
|
|
const comparable = normalizeMarketComparableTemp(expectedHigh, tempSymbol, nearest);
|
|
|
|
|
|
const unit = String(nearest.unit || "").toUpperCase();
|
|
|
|
|
|
const maxReasonableDelta = unit === "F" ? 16 : 8;
|
|
|
|
|
|
if (
|
|
|
|
|
|
comparable != null &&
|
|
|
|
|
|
Number.isFinite(nearestDelta) &&
|
|
|
|
|
|
nearestDelta <= maxReasonableDelta
|
|
|
|
|
|
) {
|
|
|
|
|
|
return nearest;
|
|
|
|
|
|
}
|
|
|
|
|
|
return isReasonableFallback(selectedBucket) ? selectedBucket : null;
|
2026-04-26 09:13:18 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
2026-04-26 09:40:02 +08:00
|
|
|
|
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 bucketProbability = getBucketModelProbability(bucket);
|
2026-04-26 09:13:18 +08:00
|
|
|
|
const scanProbability = normalizeMarketProbability(marketScan.model_probability);
|
|
|
|
|
|
const modelProbability = bucketProbability ?? scanProbability;
|
|
|
|
|
|
const yesBuy =
|
2026-04-26 09:40:02 +08:00
|
|
|
|
normalizeQuotePrice(bucket?.yes_buy) ??
|
|
|
|
|
|
normalizeQuotePrice(marketScan.yes_buy);
|
2026-04-26 09:13:18 +08:00
|
|
|
|
const yesSell =
|
2026-04-26 09:40:02 +08:00
|
|
|
|
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;
|
2026-04-26 09:13:18 +08:00
|
|
|
|
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: getMarketBucketLabel(bucket, tempSymbol),
|
|
|
|
|
|
confidence: marketScan.confidence || "--",
|
|
|
|
|
|
edgeText: formatSignedMarketPercent(edge),
|
|
|
|
|
|
impliedText: formatMarketPercent(implied),
|
|
|
|
|
|
marketUrl:
|
2026-04-26 09:40:02 +08:00
|
|
|
|
bucket?.market_url ||
|
|
|
|
|
|
(bucket?.slug
|
2026-04-26 09:13:18 +08:00
|
|
|
|
? `https://polymarket.com/market/${bucket.slug}`
|
2026-04-26 09:40:02 +08:00
|
|
|
|
: marketScan.market_url || marketScan.primary_market_url || null),
|
2026-04-26 09:13:18 +08:00
|
|
|
|
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)}.`
|
|
|
|
|
|
: `模型概率 ${formatMarketPercent(modelProbability)},市场隐含约 ${formatMarketPercent(implied)}。`,
|
|
|
|
|
|
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 {
|
2026-04-26 10:02:54 +08:00
|
|
|
|
const aiPredictedMax = toFiniteMarketNumber(aiCityForecast?.predicted_max);
|
|
|
|
|
|
const center = aiPredictedMax != null
|
|
|
|
|
|
? aiPredictedMax
|
2026-04-26 09:13:18 +08:00
|
|
|
|
: paceView?.paceAdjustedHigh != null
|
|
|
|
|
|
? paceView.paceAdjustedHigh
|
|
|
|
|
|
: deb;
|
2026-04-26 10:02:54 +08:00
|
|
|
|
const aiLow = toFiniteMarketNumber(aiCityForecast?.range_low);
|
|
|
|
|
|
const aiHigh = toFiniteMarketNumber(aiCityForecast?.range_high);
|
|
|
|
|
|
const low = aiLow != null
|
2026-04-26 09:13:18 +08:00
|
|
|
|
? aiLow
|
|
|
|
|
|
: modelMin != null
|
|
|
|
|
|
? modelMin
|
|
|
|
|
|
: center != null
|
|
|
|
|
|
? center - 1
|
|
|
|
|
|
: null;
|
2026-04-26 10:02:54 +08:00
|
|
|
|
const high = aiHigh != null
|
2026-04-26 09:13:18 +08:00
|
|
|
|
? 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 decision layer · no market price input"
|
|
|
|
|
|
: "天气决策层 · 未接入市场价格",
|
|
|
|
|
|
reasons,
|
|
|
|
|
|
risk,
|
|
|
|
|
|
targetRange,
|
|
|
|
|
|
tone,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|