From 5796d380c5d013ae295df20b4ead5dc483c84921 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Tue, 24 Mar 2026 01:49:00 +0800 Subject: [PATCH] Reorganize intraday signal cards for clearer trade guidance --- .../components/dashboard/Dashboard.module.css | 37 +++++ .../dashboard/FutureForecastModal.tsx | 150 +++++++++--------- frontend/lib/dashboard-types.ts | 2 + frontend/lib/dashboard-utils.ts | 120 +++++++++----- web/analysis_service.py | 130 +++++++++++++-- 5 files changed, 306 insertions(+), 133 deletions(-) diff --git a/frontend/components/dashboard/Dashboard.module.css b/frontend/components/dashboard/Dashboard.module.css index ab3241aa..b4ad2066 100644 --- a/frontend/components/dashboard/Dashboard.module.css +++ b/frontend/components/dashboard/Dashboard.module.css @@ -2056,6 +2056,27 @@ letter-spacing: 0.02em; } +.root :global(.future-v2-stack) { + margin-top: 14px; + display: grid; + gap: 12px; +} + +.root :global(.future-v2-subpanel) { + border: 1px solid var(--border-subtle); + border-radius: 12px; + background: rgba(255, 255, 255, 0.025); + padding: 12px; +} + +.root :global(.future-v2-subpanel-title) { + margin: 0; + color: var(--text-primary); + font-size: 12px; + font-weight: 700; + letter-spacing: 0.02em; +} + .root :global(.future-v2-market-v3) { margin-top: 16px; display: flex; @@ -2308,6 +2329,13 @@ line-height: 1.45; } +.root :global(.future-subsection-title) { + color: var(--text-primary); + font-size: 0.95rem; + font-weight: 700; + margin-top: 18px; +} + .root :global(.future-trend-value.warm) { color: #34d399; } @@ -2444,6 +2472,10 @@ padding: 10px; } + .root :global(.future-v2-subpanel) { + padding: 10px; + } + .root :global(.future-trend-value) { font-size: 16px; } @@ -2454,6 +2486,11 @@ line-height: 1.6; } + .root :global(.future-subsection-title) { + font-size: 0.9rem; + margin-top: 14px; + } + .root :global(.future-front-meta) { gap: 8px; } diff --git a/frontend/components/dashboard/FutureForecastModal.tsx b/frontend/components/dashboard/FutureForecastModal.tsx index 2acf4927..113bca36 100644 --- a/frontend/components/dashboard/FutureForecastModal.tsx +++ b/frontend/components/dashboard/FutureForecastModal.tsx @@ -828,6 +828,76 @@ export function FutureForecastModal() { {marketSignal} + +
+

+ {locale === "en-US" + ? "City Risk Profile & Airport Narrative" + : "城市风险档案与机场报文解读"} +

+
+
+
+ {locale === "en-US" ? "City Risk Profile" : "城市风险档案"} +
+
+ {!risk.airport ? ( + + {t("section.noRiskProfile")} + + ) : ( + <> +
+ + {settlementProfileLabel} + + {settlementProfileValue} +
+
+ + {t("section.distance")} + + {risk.distance_km ?? "--"}km +
+ {risk.warning ? ( +
+ + {t("section.note")} + + {risk.warning} +
+ ) : null} + + )} +
+
+
+
+ {locale === "en-US" ? "Airport Narrative" : "机场报文解读"} +
+
+ {!ai.summary && ai.bullets.length === 0 ? ( + + {t("future.noAi")} + + ) : ( + <> + {ai.summary ? ( +
{ai.summary}
+ ) : null} + {ai.bullets.length > 0 ? ( +
    + {ai.bullets.map((item) => ( +
  • {item}
  • + ))} +
+ ) : null} + + )} +
+
+
+
@@ -915,6 +985,9 @@ export function FutureForecastModal() { ) : null} +
+ {locale === "en-US" ? "Surface Structure" : "近地面信号"} +
{view.front.metrics.slice(0, 6).map((metric) => (
@@ -935,14 +1008,7 @@ export function FutureForecastModal() { {view.front.upperAirSummary || (view.front.upperAirMetrics?.length || 0) > 0 ? ( <> -
+
{locale === "en-US" ? "Upper-Air Structure" : "高空结构信号"}
{view.front.upperAirSummary ? ( @@ -970,74 +1036,6 @@ export function FutureForecastModal() { ) : null} - -
-

- {locale === "en-US" - ? "City Risk Profile & Airport Narrative" - : "城市风险档案与机场报文解读"} -

-
-
-

- {locale === "en-US" ? "City Risk Profile" : "城市风险档案"} -

-
- {!risk.airport ? ( - - {t("section.noRiskProfile")} - - ) : ( - <> -
- - {settlementProfileLabel} - - {settlementProfileValue} -
-
- - {t("section.distance")} - - {risk.distance_km ?? "--"}km -
- {risk.warning ? ( -
- - {t("section.note")} - - {risk.warning} -
- ) : null} - - )} -
-
-
-

{t("future.ai")}

-
- {!ai.summary && ai.bullets.length === 0 ? ( - - {t("future.noAi")} - - ) : ( - <> - {ai.summary ? ( -
{ai.summary}
- ) : null} - {ai.bullets.length > 0 ? ( -
    - {ai.bullets.map((item) => ( -
  • {item}
  • - ))} -
- ) : null} - - )} -
-
-
-
) : ( diff --git a/frontend/lib/dashboard-types.ts b/frontend/lib/dashboard-types.ts index cee9fc2c..e2d13127 100644 --- a/frontend/lib/dashboard-types.ts +++ b/frontend/lib/dashboard-types.ts @@ -317,6 +317,8 @@ export interface CityDetail { trigger_risk?: string | null; mixing_strength?: string | null; shear_risk?: string | null; + heating_setup?: string | null; + heating_score?: number | null; summary_zh?: string | null; summary_en?: string | null; }; diff --git a/frontend/lib/dashboard-utils.ts b/frontend/lib/dashboard-utils.ts index e29b2762..d7f2ae43 100644 --- a/frontend/lib/dashboard-utils.ts +++ b/frontend/lib/dashboard-utils.ts @@ -580,17 +580,77 @@ export function computeFrontTrendSignal( locale: Locale = "zh-CN", ) { const upperAirSignal = detail.vertical_profile_signal || {}; + const upperAirSummary = upperAirSignal.source + ? (() => { + const hasMetrics = + upperAirSignal.cape_max != null || + upperAirSignal.cin_min != null || + upperAirSignal.boundary_layer_height_max != null || + upperAirSignal.shear_10m_180m_max != null; + if (!hasMetrics) { + return isEnglish(locale) + ? "Upper-air inputs are incomplete. For now, trade direction should rely more on surface structure." + : "高空输入还不完整,当前交易方向先更多参考近地面结构信号。"; + } + if (upperAirSignal.heating_setup === "supportive") { + return isEnglish(locale) + ? "Upper-air structure still favors further warming. Leaning too early against higher buckets is risky." + : "高空结构仍偏向继续增温,过早去押更低温区间风险较高。"; + } + if (upperAirSignal.heating_setup === "suppressed") { + return isEnglish(locale) + ? "Upper-air structure leans toward capping the afternoon high. Chasing higher buckets needs caution." + : "高空结构更偏向压住午后峰值,追更高温区间要更谨慎。"; + } + return isEnglish(locale) + ? "Upper-air structure is fairly neutral. It does not give a clean edge by itself, so surface trends still decide the trade." + : "高空结构整体偏中性,单看这层不给明确边,交易仍要让近地面走势来定。"; + })() + : ""; const upperAirMetrics = upperAirSignal.source ? [ { - label: isEnglish(locale) ? "Convective suppression" : "对流压温风险", + label: isEnglish(locale) ? "Peak setup" : "冲高环境", + note: + upperAirSignal.heating_setup === "supportive" + ? isEnglish(locale) + ? "Still supportive of more daytime heating. Fading stronger buckets too early is risky." + : "仍偏向白天继续冲高,过早去反着做更高温区间比较危险。" + : upperAirSignal.heating_setup === "suppressed" + ? isEnglish(locale) + ? "Leans toward capping the afternoon peak. Be careful chasing stronger buckets." + : "更偏向压住午后峰值,追更高温区间要谨慎。" + : isEnglish(locale) + ? "Neutral on its own. Surface structure still decides the side." + : "单看这层偏中性,最终还是要看近地面信号站哪边。", + tone: + upperAirSignal.heating_setup === "supportive" + ? "warm" + : upperAirSignal.heating_setup === "suppressed" + ? "cold" + : "", + value: + upperAirSignal.heating_setup === "supportive" + ? isEnglish(locale) + ? "Supportive" + : "偏支持" + : upperAirSignal.heating_setup === "suppressed" + ? isEnglish(locale) + ? "Suppressed" + : "偏压制" + : isEnglish(locale) + ? "Neutral" + : "中性", + }, + { + label: isEnglish(locale) ? "Peak suppression risk" : "压温风险", note: upperAirSignal.cape_max != null || upperAirSignal.cin_min != null ? isEnglish(locale) - ? `CAPE max ${Math.round(Number(upperAirSignal.cape_max ?? 0))}, CIN min ${Number(upperAirSignal.cin_min ?? 0).toFixed(0)}.` - : `CAPE 峰值 ${Math.round(Number(upperAirSignal.cape_max ?? 0))},CIN 最低 ${Number(upperAirSignal.cin_min ?? 0).toFixed(0)}。` + ? `How likely clouds or showers are to cap the high. CAPE ${Math.round(Number(upperAirSignal.cape_max ?? 0))}, CIN ${Number(upperAirSignal.cin_min ?? 0).toFixed(0)}.` + : `看云和阵雨有多大概率把峰值压住。CAPE ${Math.round(Number(upperAirSignal.cape_max ?? 0))},CIN ${Number(upperAirSignal.cin_min ?? 0).toFixed(0)}。` : isEnglish(locale) - ? "Derived from the next 48h upper-air profile." + ? "Estimated from the next 48h upper-air profile." : "根据未来 48 小时高空剖面估算。", tone: upperAirSignal.suppression_risk === "high" @@ -612,12 +672,12 @@ export function computeFrontTrendSignal( : "低", }, { - label: isEnglish(locale) ? "Trigger setup" : "午后触发性", + label: isEnglish(locale) ? "Afternoon disruption" : "午后扰动", note: upperAirSignal.lifted_index_min != null ? isEnglish(locale) - ? `Lifted index min ${Number(upperAirSignal.lifted_index_min).toFixed(1)}.` - : `Lifted Index 最低 ${Number(upperAirSignal.lifted_index_min).toFixed(1)}。` + ? `How easily the afternoon can turn noisy. Lifted Index ${Number(upperAirSignal.lifted_index_min).toFixed(1)}.` + : `看午后是否容易突然起云、起对流,把走势搅乱。Lifted Index ${Number(upperAirSignal.lifted_index_min).toFixed(1)}。` : isEnglish(locale) ? "Uses instability and lifted-index structure." : "结合不稳定能量与抬升指数判断。", @@ -641,15 +701,15 @@ export function computeFrontTrendSignal( : "低", }, { - label: isEnglish(locale) ? "Deep mixing" : "深层混合", + label: isEnglish(locale) ? "Heating efficiency" : "冲高效率", note: upperAirSignal.boundary_layer_height_max != null ? isEnglish(locale) - ? `Boundary-layer height peaks near ${Math.round(Number(upperAirSignal.boundary_layer_height_max))} m.` - : `边界层高度峰值约 ${Math.round(Number(upperAirSignal.boundary_layer_height_max))} 米。` + ? `How efficiently surface warmth can keep translating upward. Mixing depth peaks near ${Math.round(Number(upperAirSignal.boundary_layer_height_max))} m.` + : `看地面热量能不能持续往上送,决定冲高效率。混合层高度峰值约 ${Math.round(Number(upperAirSignal.boundary_layer_height_max))} 米。` : isEnglish(locale) - ? "Tracks daytime boundary-layer depth." - : "跟踪白天边界层混合深度。", + ? "Tracks daytime mixing depth." + : "跟踪白天混合层深度。", tone: upperAirSignal.mixing_strength === "strong" ? "warm" @@ -666,32 +726,8 @@ export function computeFrontTrendSignal( ? "Medium" : "中" : isEnglish(locale) - ? "Weak" - : "弱", - }, - { - label: isEnglish(locale) ? "Shear proxy" : "高空风切变", - note: - upperAirSignal.shear_10m_180m_max != null - ? isEnglish(locale) - ? `10m-180m shear proxy peaks near ${Number(upperAirSignal.shear_10m_180m_max).toFixed(1)}.` - : `10m-180m 风切变代理峰值约 ${Number(upperAirSignal.shear_10m_180m_max).toFixed(1)}。` - : isEnglish(locale) - ? "Uses 10m vs 180m wind-vector spread as a simple proxy." - : "使用 10m 与 180m 风矢量差做简化代理。", - tone: upperAirSignal.shear_risk === "high" ? "cold" : "", - value: - upperAirSignal.shear_risk === "high" - ? isEnglish(locale) - ? "High" - : "高" - : upperAirSignal.shear_risk === "medium" - ? isEnglish(locale) - ? "Medium" - : "中" - : isEnglish(locale) - ? "Low" - : "低", + ? "Weak" + : "弱", }, ] : []; @@ -719,9 +755,7 @@ export function computeFrontTrendSignal( value: string; }>, upperAirMetrics, - upperAirSummary: isEnglish(locale) - ? String(upperAirSignal.summary_en || "").trim() - : String(upperAirSignal.summary_zh || "").trim(), + upperAirSummary, precipMax: 0, score: 0, summary: @@ -1204,9 +1238,7 @@ export function computeFrontTrendSignal( label, metrics, upperAirMetrics, - upperAirSummary: isEnglish(locale) - ? String(upperAirSignal.summary_en || "").trim() - : String(upperAirSignal.summary_zh || "").trim(), + upperAirSummary, precipMax, score, summary: backendSummary || summary, diff --git a/web/analysis_service.py b/web/analysis_service.py index 9021e375..050934a5 100644 --- a/web/analysis_service.py +++ b/web/analysis_service.py @@ -77,6 +77,16 @@ def _build_vertical_profile_signal( valid = [_sf(value) for value in values if _sf(value) is not None] return min(valid) if valid else None + def _level_label(level: str, locale: str) -> str: + mapping = { + "high": {"zh": "高", "en": "high"}, + "medium": {"zh": "中", "en": "medium"}, + "low": {"zh": "低", "en": "low"}, + "strong": {"zh": "强", "en": "strong"}, + "weak": {"zh": "弱", "en": "weak"}, + } + return mapping.get(level, {}).get(locale, level) + cape_max = _max_numeric(_series("cape")) cin_min = _min_numeric(_series("convective_inhibition")) lifted_index_min = _min_numeric(_series("lifted_index")) @@ -102,43 +112,78 @@ def _build_vertical_profile_signal( shear_10m_180m_max = max(shear_values) if shear_values else None suppression_risk = "low" - if (cape_max is not None and cape_max >= 900) or ( - cin_min is not None and cin_min <= -60 + if (cape_max is not None and cape_max >= 700) or ( + cin_min is not None and cin_min <= -50 ): suppression_risk = "high" - elif (cape_max is not None and cape_max >= 300) or ( - cin_min is not None and cin_min <= -20 + elif (cape_max is not None and cape_max >= 150) or ( + cin_min is not None and cin_min <= -15 ): suppression_risk = "medium" trigger_risk = "low" if ( cape_max is not None - and cape_max >= 800 + and cape_max >= 550 and lifted_index_min is not None - and lifted_index_min <= -2 + and lifted_index_min <= -1.5 ): trigger_risk = "high" elif ( cape_max is not None - and cape_max >= 250 + and cape_max >= 120 and lifted_index_min is not None - and lifted_index_min <= 0 + and lifted_index_min <= 0.5 ): trigger_risk = "medium" mixing_strength = "weak" - if boundary_layer_height_max is not None and boundary_layer_height_max >= 1800: + if boundary_layer_height_max is not None and boundary_layer_height_max >= 1400: mixing_strength = "strong" - elif boundary_layer_height_max is not None and boundary_layer_height_max >= 1000: + elif boundary_layer_height_max is not None and boundary_layer_height_max >= 700: mixing_strength = "medium" shear_risk = "low" - if shear_10m_180m_max is not None and shear_10m_180m_max >= 12: + if shear_10m_180m_max is not None and shear_10m_180m_max >= 8: shear_risk = "high" - elif shear_10m_180m_max is not None and shear_10m_180m_max >= 6: + elif shear_10m_180m_max is not None and shear_10m_180m_max >= 4: shear_risk = "medium" + heating_setup = "neutral" + heating_score = 0 + if suppression_risk == "high": + heating_score -= 2 + elif suppression_risk == "medium": + heating_score -= 1 + if trigger_risk == "high": + heating_score -= 2 + elif trigger_risk == "medium": + heating_score -= 1 + if mixing_strength == "strong": + heating_score += 2 + elif mixing_strength == "medium": + heating_score += 1 + else: + heating_score -= 1 + if shear_risk == "high": + heating_score -= 1 + + if heating_score >= 2: + heating_setup = "supportive" + elif heating_score <= -2: + heating_setup = "suppressed" + + has_profile_data = any( + value is not None + for value in ( + cape_max, + cin_min, + lifted_index_min, + boundary_layer_height_max, + shear_10m_180m_max, + ) + ) + zh_parts = [] en_parts = [] if suppression_risk == "high": @@ -147,26 +192,83 @@ def _build_vertical_profile_signal( elif suppression_risk == "medium": zh_parts.append("存在一定云雨压温风险。") en_parts.append("There is some cloud and shower suppression risk.") + elif has_profile_data: + zh_parts.append("高空对流压温风险暂时不高。") + en_parts.append("Upper-air suppression risk remains limited for now.") if mixing_strength == "strong": zh_parts.append("边界层混合较深,若无云雨打断仍有冲高空间。") en_parts.append("Deep boundary-layer mixing still supports additional warming if convection stays limited.") elif mixing_strength == "medium": zh_parts.append("白天混合条件中等。") en_parts.append("Daytime mixing potential is moderate.") + elif has_profile_data: + zh_parts.append("边界层混合偏浅。") + en_parts.append("Boundary-layer mixing remains shallow.") if shear_risk == "high": zh_parts.append("高空风切变较强,午后结构波动可能加大。") en_parts.append("Upper-level shear is relatively strong and may increase afternoon volatility.") + elif shear_risk == "medium": + zh_parts.append("高空风切变有一定存在感。") + en_parts.append("Upper-level shear is noticeable.") + elif has_profile_data: + zh_parts.append("高空风切变扰动有限。") + en_parts.append("Upper-level shear disruption remains limited.") if trigger_risk == "high": zh_parts.append("抬升触发条件较好,需警惕午后云团发展。") en_parts.append("Trigger conditions are favorable enough to watch for afternoon convective development.") elif trigger_risk == "medium": zh_parts.append("午后具备一定触发条件。") en_parts.append("There is some afternoon trigger potential.") - if not zh_parts: + elif has_profile_data: + zh_parts.append("午后触发条件偏弱。") + en_parts.append("Afternoon trigger potential remains weak.") + if not has_profile_data: + zh_parts.append("高空剖面字段暂缺,当前仅保留基础默认信号。") + en_parts.append("Upper-air profile fields are currently unavailable, so only a fallback signal is shown.") + elif not zh_parts: zh_parts.append("高空结构整体平稳,暂未看到明显压温信号。") if not en_parts: en_parts.append("The upper-air structure looks fairly stable, without a strong suppression signal yet.") + if has_profile_data: + summary_tokens_zh = [] + summary_tokens_en = [] + window_start = str(times[candidate_indexes[0]]).split("T")[1][:5] + window_end = str(times[candidate_indexes[-1]]).split("T")[1][:5] + zh_parts.append(f"判断窗口:{window_start}-{window_end}。") + en_parts.append(f"Signal window: {window_start}-{window_end}.") + if cape_max is not None: + summary_tokens_zh.append(f"CAPE≈{round(cape_max)}") + summary_tokens_en.append(f"CAPE≈{round(cape_max)}") + if cin_min is not None: + summary_tokens_zh.append(f"CIN≈{round(cin_min)}") + summary_tokens_en.append(f"CIN≈{round(cin_min)}") + if boundary_layer_height_max is not None: + summary_tokens_zh.append(f"混合层≈{round(boundary_layer_height_max)}m") + summary_tokens_en.append(f"mixing≈{round(boundary_layer_height_max)}m") + if shear_10m_180m_max is not None: + summary_tokens_zh.append(f"切变≈{shear_10m_180m_max:.1f}") + summary_tokens_en.append(f"shear≈{shear_10m_180m_max:.1f}") + zh_parts.append( + f"压温{_level_label(suppression_risk, 'zh')}、触发{_level_label(trigger_risk, 'zh')}、混合{_level_label(mixing_strength, 'zh')}、切变{_level_label(shear_risk, 'zh')}。" + ) + en_parts.append( + f"Suppression { _level_label(suppression_risk, 'en') }, trigger { _level_label(trigger_risk, 'en') }, mixing { _level_label(mixing_strength, 'en') }, shear { _level_label(shear_risk, 'en') }." + ) + if heating_setup == "supportive": + zh_parts.append("整体更偏向支持白天冲高。") + en_parts.append("Overall, the profile is more supportive of daytime heating.") + elif heating_setup == "suppressed": + zh_parts.append("整体更偏向抑制午后冲高。") + en_parts.append("Overall, the profile leans more toward suppressing the afternoon peak.") + else: + zh_parts.append("整体更像中性环境,仍需结合地面信号。") + en_parts.append("Overall, the profile looks fairly neutral and still needs surface confirmation.") + if summary_tokens_zh: + zh_parts.append(" / ".join(summary_tokens_zh) + "。") + if summary_tokens_en: + en_parts.append(" / ".join(summary_tokens_en) + ".") + return { "source": "open-meteo-gfs", "window_start": times[candidate_indexes[0]] if candidate_indexes else None, @@ -180,6 +282,8 @@ def _build_vertical_profile_signal( "trigger_risk": trigger_risk, "mixing_strength": mixing_strength, "shear_risk": shear_risk, + "heating_setup": heating_setup, + "heating_score": heating_score, "summary_zh": "".join(zh_parts), "summary_en": " ".join(en_parts), }