From fc72b3539de7d5f97d6b6c828de0124338c6cfd5 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Tue, 24 Mar 2026 02:07:15 +0800 Subject: [PATCH] Align structural trade cues with market context --- .../dashboard/FutureForecastModal.tsx | 105 +++++++++++++++++- frontend/lib/dashboard-utils.ts | 82 +++++++++++++- web/analysis_service.py | 8 +- 3 files changed, 183 insertions(+), 12 deletions(-) diff --git a/frontend/components/dashboard/FutureForecastModal.tsx b/frontend/components/dashboard/FutureForecastModal.tsx index 113bca36..7b9b6d89 100644 --- a/frontend/components/dashboard/FutureForecastModal.tsx +++ b/frontend/components/dashboard/FutureForecastModal.tsx @@ -503,6 +503,86 @@ export function FutureForecastModal() { marketScan.confidence ? ` / ${marketScan.confidence}` : "" }` : "--"; + const upperAirSignal = detail.vertical_profile_signal || {}; + const topBucketProbability = normalizeMarketValue(topBucket?.probability); + const hottestMatchesSettlement = + hottestBucketLabel !== "--" && + settlementBucketLabel !== "--" && + hottestBucketLabel === settlementBucketLabel; + const marketAwareUpperAirCue = useMemo(() => { + if (!isToday || !upperAirSignal.source) return null; + + const crowded = hottestMatchesSettlement && (topBucketProbability || 0) >= 0.3; + const setup = String(upperAirSignal.heating_setup || "neutral").toLowerCase(); + + if (setup === "supportive") { + return { + summary: locale === "en-US" + ? crowded + ? "Upper-air structure still leans warmer, but the target bucket is already crowded. Do not fade lower buckets too early, and do not chase blindly either." + : "Upper-air structure still leans warmer, and the market is not fully crowded into the target bucket yet. Do not fade lower buckets too early." + : crowded + ? "高空结构仍偏支持冲高,但当前目标区间已经较拥挤。不宜过早做更低温区间,也不要盲目继续追价。" + : "高空结构仍偏支持冲高,市场最热桶还没完全挤到目标区间。不宜过早做更低温区间。", + note: locale === "en-US" + ? crowded + ? "Warmer side still has structural support, but price is no longer cheap. Wait for surface follow-through before adding." + : "Warmer side still has structural support and is not fully overcrowded yet. Fading lower buckets too early is risky." + : crowded + ? "暖侧仍有结构支撑,但价格已经不算便宜,继续加仓前先等近地面兑现。" + : "暖侧仍有结构支撑,且还没有完全过热,过早去做更低温区间风险较高。", + tone: "warm", + value: locale === "en-US" ? "Lean warmer" : "偏暖侧", + }; + } + + if (setup === "suppressed") { + return { + summary: locale === "en-US" + ? crowded + ? "Upper-air structure leans toward capping the high, and the target bucket is already crowded. Chasing higher buckets needs extra caution." + : "Upper-air structure leans toward capping the high. Be more careful chasing higher buckets." + : crowded + ? "高空结构更偏向压住峰值,而且目标区间已经较拥挤。追更高温区间要更谨慎。" + : "高空结构更偏向压住峰值,追更高温区间要更谨慎。", + note: locale === "en-US" + ? crowded + ? "Both structure and crowding argue for caution on the hotter side." + : "The hotter side needs stronger surface confirmation before adding." + : crowded + ? "结构和拥挤度都不利于继续追热。" + : "更高温区间需要更强的近地面确认后再考虑追价。", + tone: "cold", + value: locale === "en-US" ? "Lean cautious" : "偏谨慎", + }; + } + + return { + summary: locale === "en-US" + ? crowded + ? "Upper-air structure is neutral, while the target bucket is already crowded. Let surface structure and price action decide before taking a side." + : "Upper-air structure is neutral. Let surface structure and price action decide before taking a side." + : crowded + ? "高空结构偏中性,但目标区间已经较拥挤。先看近地面结构和盘口变化,不急着继续站边。" + : "高空结构偏中性,先看近地面结构和盘口变化,不急着站边。", + note: locale === "en-US" + ? crowded + ? "There is no clean edge from the upper-air layer alone, and the market is already leaning. Wait for confirmation." + : "There is no clean edge from the upper-air layer alone. Wait for confirmation." + : crowded + ? "单看高空层没有明确边,而且市场已经先站位,先等确认。" + : "单看高空层没有明确边,先等确认。", + tone: "", + value: locale === "en-US" ? "Wait / confirm" : "先观察", + }; + }, [ + hottestMatchesSettlement, + isToday, + locale, + topBucketProbability, + upperAirSignal.heating_setup, + upperAirSignal.source, + ]); const metarParsed = parseMetarTempDew(detail.current?.raw_metar); const fallbackDewpoint = detail.current?.dewpoint ?? @@ -566,6 +646,21 @@ export function FutureForecastModal() { : risk.airport ? `${risk.airport}${risk.icao ? ` (${risk.icao})` : ""}` : "--"; + const displayedUpperAirSummary = + marketAwareUpperAirCue?.summary || view.front.upperAirSummary; + const displayedUpperAirMetrics = (view.front.upperAirMetrics || []).map( + (metric, index) => + index === 0 && + (metric.label === "Trade cue" || metric.label === "交易动作") && + marketAwareUpperAirCue + ? { + ...metric, + note: marketAwareUpperAirCue.note, + tone: marketAwareUpperAirCue.tone, + value: marketAwareUpperAirCue.value, + } + : metric, + ); return (
))}
- {view.front.upperAirSummary || - (view.front.upperAirMetrics?.length || 0) > 0 ? ( + {displayedUpperAirSummary || + displayedUpperAirMetrics.length > 0 ? ( <>
{locale === "en-US" ? "Upper-Air Structure" : "高空结构信号"}
- {view.front.upperAirSummary ? ( + {displayedUpperAirSummary ? (
- {view.front.upperAirSummary} + {displayedUpperAirSummary}
) : null}
- {(view.front.upperAirMetrics || []).map((metric) => ( + {displayedUpperAirMetrics.map((metric) => (
{metric.label}
{ const hasMetrics = @@ -609,6 +641,7 @@ export function computeFrontTrendSignal( : ""; const upperAirMetrics = upperAirSignal.source ? [ + ...(upperAirTradeCue ? [upperAirTradeCue] : []), { label: isEnglish(locale) ? "Peak setup" : "冲高环境", note: @@ -731,15 +764,23 @@ export function computeFrontTrendSignal( }, ] : []; - const backendSummary = + const rawBackendSummary = dateStr === detail.local_date ? String(detail.dynamic_commentary?.summary || "").trim() : ""; - const backendNotes = Array.isArray(detail.dynamic_commentary?.notes) + const backendSummary = + rawBackendSummary && + (!isEnglish(locale) || !containsCjk(rawBackendSummary)) + ? rawBackendSummary + : ""; + const rawBackendNotes = Array.isArray(detail.dynamic_commentary?.notes) ? detail.dynamic_commentary?.notes ?.map((item) => String(item || "").trim()) .filter(Boolean) || [] : []; + const backendNotes = rawBackendNotes.filter( + (note) => !isEnglish(locale) || !containsCjk(note), + ); const slice = getFutureSlice(detail, dateStr); const currentTemp = Number(detail.current?.temp); const currentDew = Number(detail.current?.dewpoint); @@ -807,8 +848,21 @@ export function computeFrontTrendSignal( const isTargetToday = dateStr === detail.local_date; const currentHm = normalizeHm(detail.local_time); const sunsetHm = normalizeHm(detail.forecast?.sunset); + const peakFirstHour = Number(detail.peak?.first_h); + const peakLastHour = Number(detail.peak?.last_h); + const hasPeakWindow = + Number.isFinite(peakFirstHour) && + Number.isFinite(peakLastHour) && + peakFirstHour >= 0 && + peakLastHour >= peakFirstHour; const currentMinutes = hmToMinutes(currentHm); const sunsetMinutes = hmToMinutes(sunsetHm); + const peakWindowStartMinutes = hasPeakWindow + ? Math.max(0, (peakFirstHour - 2) * 60) + : null; + const peakWindowEndMinutes = hasPeakWindow + ? Math.min(23 * 60 + 59, (peakLastHour + 1) * 60) + : null; const canUseSunsetWindow = isTargetToday && currentMinutes !== null && @@ -829,24 +883,42 @@ export function computeFrontTrendSignal( return minutes === null ? false : minutes <= sunsetMinutes; }) : futureSlice; + const aroundPeakSlice = + isTargetToday && + peakWindowStartMinutes !== null && + peakWindowEndMinutes !== null + ? futureSlice.filter((point) => { + const minutes = pointMinutes(point); + return minutes === null + ? false + : minutes >= peakWindowStartMinutes && minutes <= peakWindowEndMinutes; + }) + : []; const workingSlice = - untilSunsetSlice.length >= 2 + aroundPeakSlice.length >= 2 + ? aroundPeakSlice + : untilSunsetSlice.length >= 2 ? untilSunsetSlice : futureSlice.length >= 2 ? futureSlice : slice; + const usingPeakWindow = aroundPeakSlice.length >= 2; const usingSunsetWindow = canUseSunsetWindow && untilSunsetSlice.length >= 2; const first = workingSlice[0] || slice[0]; const last = workingSlice[workingSlice.length - 1] || slice[slice.length - 1]; const effectiveHours = Math.max(1, workingSlice.length); const windowLabel = `${first?.label || "--"}-${last?.label || "--"}`; const windowText = isEnglish(locale) - ? usingSunsetWindow + ? usingPeakWindow + ? `today ${windowLabel} (~${effectiveHours}h, around peak window)` + : usingSunsetWindow ? `today ${windowLabel} (~${effectiveHours}h, now -> sunset)` : isTargetToday ? `today ${windowLabel} (~${effectiveHours}h)` : `daily ${windowLabel} (~${effectiveHours}h)` - : usingSunsetWindow + : usingPeakWindow + ? `今日 ${windowLabel}(约 ${effectiveHours} 小时,围绕峰值窗口)` + : usingSunsetWindow ? `今日 ${windowLabel}(约 ${effectiveHours} 小时,当前至日落)` : isTargetToday ? `今日 ${windowLabel}(约 ${effectiveHours} 小时)` diff --git a/web/analysis_service.py b/web/analysis_service.py index 050934a5..cbb6c3b8 100644 --- a/web/analysis_service.py +++ b/web/analysis_service.py @@ -43,13 +43,15 @@ def _build_vertical_profile_signal( hourly_next_48h: Dict[str, list], local_date: str, local_hour: int, + first_peak_h: int, + last_peak_h: int, ) -> Dict[str, Any]: times = hourly_next_48h.get("times") or [] if not times: return {} - preferred_start = max(local_hour, 12) - preferred_end = 19 + preferred_start = max(local_hour, max(0, first_peak_h - 2)) + preferred_end = min(23, last_peak_h + 1) candidate_indexes = [ index for index, ts in enumerate(times) @@ -766,6 +768,8 @@ def _analyze(city: str, force_refresh: bool = False) -> Dict[str, Any]: next_48h_hourly, local_date_str, local_hour, + first_peak_h, + last_peak_h, ) # ── 13. Cloud description (METAR primary, MGM fallback) ──