2026-04-26 09:13:18 +08:00
import type { MarketScan , MarketTopBucket } from "@/lib/dashboard-types" ;
2026-04-28 11:45:34 +08:00
import { getTodayPaceView } from "@/lib/pace-utils" ;
2026-04-27 01:14:31 +08:00
import {
formatTemperatureValue ,
normalizeTemperatureLabel ,
2026-04-28 11:32:03 +08:00
} from "@/lib/temperature-utils" ;
2026-04-26 09:13:18 +08:00
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 12:39:16 +08:00
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 ;
}
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 )) {
2026-04-27 01:14:31 +08:00
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" );
2026-04-26 09:40:02 +08:00
}
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-27 00:50:14 +08:00
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 ),
);
}
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 ;
}
2026-04-27 02:18:34 +08:00
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%。` ;
}
2026-04-26 09:40:02 +08:00
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 ;
2026-04-27 00:50:14 +08:00
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 ;
2026-04-26 09:40:02 +08:00
};
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 ;
2026-04-27 00:54:24 +08:00
if ( Number . isFinite ( nearestDelta ) && isReasonableFallback ( nearest )) {
2026-04-26 09:40:02 +08:00
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 : "--" ,
2026-04-28 07:48:47 +08:00
reason : isEn
? "No tradable quote is available yet; weather evidence is still shown."
: "暂无可交易价格,仅展示天气证据。天气判断仍可参考。" ,
2026-04-26 09:13:18 +08:00
status : "unavailable" ,
2026-04-28 07:48:47 +08:00
title : isEn ? "Market price temporarily unavailable" : "市场价格暂不可用" ,
2026-04-26 09:13:18 +08:00
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" ,
};
}
2026-04-27 02:18:34 +08:00
const bucketLabel = getMarketBucketLabel ( bucket , tempSymbol );
const bucketMappingExplanation = buildBucketMappingExplanation ({
bucket ,
expectedHigh ,
isEn ,
tempSymbol ,
});
2026-04-26 09:40:02 +08:00
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 {
2026-04-27 02:18:34 +08:00
bucketLabel ,
2026-04-26 09:13:18 +08:00
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
2026-04-27 02:18:34 +08:00
? `Model probability is ${ formatMarketPercent ( modelProbability ) } versus market-implied ${ formatMarketPercent ( implied ) } . ${ bucketMappingExplanation ? ` ${ bucketMappingExplanation } ` : "" } `
: `模型概率 ${ formatMarketPercent ( modelProbability ) } ,市场隐含约 ${ formatMarketPercent ( implied ) } 。 ${ bucketMappingExplanation ? ` ${ bucketMappingExplanation } ` : "" } ` ,
2026-04-26 09:13:18 +08:00
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 12:39:16 +08:00
const center = resolveExpectedHighCandidate ({
aiPredictedMax : aiCityForecast?.predicted_max ,
currentTemp ,
deb ,
modelMax ,
modelMin ,
paceAdjustedHigh : paceView?.paceAdjustedHigh ?? null ,
});
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
2026-05-10 17:33:15 +08:00
? "Revise upward"
: "预计最高温上修"
2026-04-26 09:13:18 +08:00
: paceTone === "cold"
? isEn
2026-05-10 17:33:15 +08:00
? "Revise downward"
: "预计最高温下修"
2026-04-26 09:13:18 +08:00
: isEn
2026-05-10 17:33:15 +08:00
? "Stay with model base"
: "维持模型基准" ;
2026-04-26 09:13:18 +08:00
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
2026-04-27 09:10:57 +08:00
? "Weather-first read · market price shown separately"
: "天气优先判断 · 市场价格另列" ,
2026-04-26 09:13:18 +08:00
reasons ,
risk ,
targetRange ,
tone ,
};
}