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) ──