Align structural trade cues with market context
This commit is contained in:
@@ -503,6 +503,86 @@ export function FutureForecastModal() {
|
|||||||
marketScan.confidence ? ` / ${marketScan.confidence}` : ""
|
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 metarParsed = parseMetarTempDew(detail.current?.raw_metar);
|
||||||
const fallbackDewpoint =
|
const fallbackDewpoint =
|
||||||
detail.current?.dewpoint ??
|
detail.current?.dewpoint ??
|
||||||
@@ -566,6 +646,21 @@ export function FutureForecastModal() {
|
|||||||
: risk.airport
|
: risk.airport
|
||||||
? `${risk.airport}${risk.icao ? ` (${risk.icao})` : ""}`
|
? `${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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -1005,19 +1100,19 @@ export function FutureForecastModal() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{view.front.upperAirSummary ||
|
{displayedUpperAirSummary ||
|
||||||
(view.front.upperAirMetrics?.length || 0) > 0 ? (
|
displayedUpperAirMetrics.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<div className="future-subsection-title">
|
<div className="future-subsection-title">
|
||||||
{locale === "en-US" ? "Upper-Air Structure" : "高空结构信号"}
|
{locale === "en-US" ? "Upper-Air Structure" : "高空结构信号"}
|
||||||
</div>
|
</div>
|
||||||
{view.front.upperAirSummary ? (
|
{displayedUpperAirSummary ? (
|
||||||
<div className="future-trend-summary">
|
<div className="future-trend-summary">
|
||||||
{view.front.upperAirSummary}
|
{displayedUpperAirSummary}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="future-trend-grid">
|
<div className="future-trend-grid">
|
||||||
{(view.front.upperAirMetrics || []).map((metric) => (
|
{displayedUpperAirMetrics.map((metric) => (
|
||||||
<div key={metric.label} className="future-trend-card">
|
<div key={metric.label} className="future-trend-card">
|
||||||
<div className="future-trend-label">{metric.label}</div>
|
<div className="future-trend-label">{metric.label}</div>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ function isEnglish(locale: Locale) {
|
|||||||
return locale === "en-US";
|
return locale === "en-US";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function containsCjk(text: string) {
|
||||||
|
return /[\u3400-\u9fff]/.test(text);
|
||||||
|
}
|
||||||
|
|
||||||
function getObservationSourceCode(detail: CityDetail): string {
|
function getObservationSourceCode(detail: CityDetail): string {
|
||||||
const source = String(detail.current?.settlement_source || "")
|
const source = String(detail.current?.settlement_source || "")
|
||||||
.trim()
|
.trim()
|
||||||
@@ -580,6 +584,34 @@ export function computeFrontTrendSignal(
|
|||||||
locale: Locale = "zh-CN",
|
locale: Locale = "zh-CN",
|
||||||
) {
|
) {
|
||||||
const upperAirSignal = detail.vertical_profile_signal || {};
|
const upperAirSignal = detail.vertical_profile_signal || {};
|
||||||
|
const upperAirTradeCue = upperAirSignal.source
|
||||||
|
? upperAirSignal.heating_setup === "supportive"
|
||||||
|
? {
|
||||||
|
label: isEnglish(locale) ? "Trade cue" : "交易动作",
|
||||||
|
note: isEnglish(locale)
|
||||||
|
? "Do not fade lower buckets too early."
|
||||||
|
: "不宜过早做更低温区间。",
|
||||||
|
tone: "warm",
|
||||||
|
value: isEnglish(locale) ? "Lean warmer" : "偏暖侧",
|
||||||
|
}
|
||||||
|
: upperAirSignal.heating_setup === "suppressed"
|
||||||
|
? {
|
||||||
|
label: isEnglish(locale) ? "Trade cue" : "交易动作",
|
||||||
|
note: isEnglish(locale)
|
||||||
|
? "Be more careful chasing higher buckets."
|
||||||
|
: "追更高温区间要更谨慎。",
|
||||||
|
tone: "cold",
|
||||||
|
value: isEnglish(locale) ? "Lean cautious" : "偏谨慎",
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
label: isEnglish(locale) ? "Trade cue" : "交易动作",
|
||||||
|
note: isEnglish(locale)
|
||||||
|
? "Let surface structure and market price action decide first."
|
||||||
|
: "先看近地面结构和盘口变化,不急着站边。",
|
||||||
|
tone: "",
|
||||||
|
value: isEnglish(locale) ? "Wait / confirm" : "先观察",
|
||||||
|
}
|
||||||
|
: null;
|
||||||
const upperAirSummary = upperAirSignal.source
|
const upperAirSummary = upperAirSignal.source
|
||||||
? (() => {
|
? (() => {
|
||||||
const hasMetrics =
|
const hasMetrics =
|
||||||
@@ -609,6 +641,7 @@ export function computeFrontTrendSignal(
|
|||||||
: "";
|
: "";
|
||||||
const upperAirMetrics = upperAirSignal.source
|
const upperAirMetrics = upperAirSignal.source
|
||||||
? [
|
? [
|
||||||
|
...(upperAirTradeCue ? [upperAirTradeCue] : []),
|
||||||
{
|
{
|
||||||
label: isEnglish(locale) ? "Peak setup" : "冲高环境",
|
label: isEnglish(locale) ? "Peak setup" : "冲高环境",
|
||||||
note:
|
note:
|
||||||
@@ -731,15 +764,23 @@ export function computeFrontTrendSignal(
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
: [];
|
: [];
|
||||||
const backendSummary =
|
const rawBackendSummary =
|
||||||
dateStr === detail.local_date
|
dateStr === detail.local_date
|
||||||
? String(detail.dynamic_commentary?.summary || "").trim()
|
? 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
|
? detail.dynamic_commentary?.notes
|
||||||
?.map((item) => String(item || "").trim())
|
?.map((item) => String(item || "").trim())
|
||||||
.filter(Boolean) || []
|
.filter(Boolean) || []
|
||||||
: [];
|
: [];
|
||||||
|
const backendNotes = rawBackendNotes.filter(
|
||||||
|
(note) => !isEnglish(locale) || !containsCjk(note),
|
||||||
|
);
|
||||||
const slice = getFutureSlice(detail, dateStr);
|
const slice = getFutureSlice(detail, dateStr);
|
||||||
const currentTemp = Number(detail.current?.temp);
|
const currentTemp = Number(detail.current?.temp);
|
||||||
const currentDew = Number(detail.current?.dewpoint);
|
const currentDew = Number(detail.current?.dewpoint);
|
||||||
@@ -807,8 +848,21 @@ export function computeFrontTrendSignal(
|
|||||||
const isTargetToday = dateStr === detail.local_date;
|
const isTargetToday = dateStr === detail.local_date;
|
||||||
const currentHm = normalizeHm(detail.local_time);
|
const currentHm = normalizeHm(detail.local_time);
|
||||||
const sunsetHm = normalizeHm(detail.forecast?.sunset);
|
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 currentMinutes = hmToMinutes(currentHm);
|
||||||
const sunsetMinutes = hmToMinutes(sunsetHm);
|
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 =
|
const canUseSunsetWindow =
|
||||||
isTargetToday &&
|
isTargetToday &&
|
||||||
currentMinutes !== null &&
|
currentMinutes !== null &&
|
||||||
@@ -829,24 +883,42 @@ export function computeFrontTrendSignal(
|
|||||||
return minutes === null ? false : minutes <= sunsetMinutes;
|
return minutes === null ? false : minutes <= sunsetMinutes;
|
||||||
})
|
})
|
||||||
: futureSlice;
|
: 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 =
|
const workingSlice =
|
||||||
untilSunsetSlice.length >= 2
|
aroundPeakSlice.length >= 2
|
||||||
|
? aroundPeakSlice
|
||||||
|
: untilSunsetSlice.length >= 2
|
||||||
? untilSunsetSlice
|
? untilSunsetSlice
|
||||||
: futureSlice.length >= 2
|
: futureSlice.length >= 2
|
||||||
? futureSlice
|
? futureSlice
|
||||||
: slice;
|
: slice;
|
||||||
|
const usingPeakWindow = aroundPeakSlice.length >= 2;
|
||||||
const usingSunsetWindow = canUseSunsetWindow && untilSunsetSlice.length >= 2;
|
const usingSunsetWindow = canUseSunsetWindow && untilSunsetSlice.length >= 2;
|
||||||
const first = workingSlice[0] || slice[0];
|
const first = workingSlice[0] || slice[0];
|
||||||
const last = workingSlice[workingSlice.length - 1] || slice[slice.length - 1];
|
const last = workingSlice[workingSlice.length - 1] || slice[slice.length - 1];
|
||||||
const effectiveHours = Math.max(1, workingSlice.length);
|
const effectiveHours = Math.max(1, workingSlice.length);
|
||||||
const windowLabel = `${first?.label || "--"}-${last?.label || "--"}`;
|
const windowLabel = `${first?.label || "--"}-${last?.label || "--"}`;
|
||||||
const windowText = isEnglish(locale)
|
const windowText = isEnglish(locale)
|
||||||
? usingSunsetWindow
|
? usingPeakWindow
|
||||||
|
? `today ${windowLabel} (~${effectiveHours}h, around peak window)`
|
||||||
|
: usingSunsetWindow
|
||||||
? `today ${windowLabel} (~${effectiveHours}h, now -> sunset)`
|
? `today ${windowLabel} (~${effectiveHours}h, now -> sunset)`
|
||||||
: isTargetToday
|
: isTargetToday
|
||||||
? `today ${windowLabel} (~${effectiveHours}h)`
|
? `today ${windowLabel} (~${effectiveHours}h)`
|
||||||
: `daily ${windowLabel} (~${effectiveHours}h)`
|
: `daily ${windowLabel} (~${effectiveHours}h)`
|
||||||
: usingSunsetWindow
|
: usingPeakWindow
|
||||||
|
? `今日 ${windowLabel}(约 ${effectiveHours} 小时,围绕峰值窗口)`
|
||||||
|
: usingSunsetWindow
|
||||||
? `今日 ${windowLabel}(约 ${effectiveHours} 小时,当前至日落)`
|
? `今日 ${windowLabel}(约 ${effectiveHours} 小时,当前至日落)`
|
||||||
: isTargetToday
|
: isTargetToday
|
||||||
? `今日 ${windowLabel}(约 ${effectiveHours} 小时)`
|
? `今日 ${windowLabel}(约 ${effectiveHours} 小时)`
|
||||||
|
|||||||
@@ -43,13 +43,15 @@ def _build_vertical_profile_signal(
|
|||||||
hourly_next_48h: Dict[str, list],
|
hourly_next_48h: Dict[str, list],
|
||||||
local_date: str,
|
local_date: str,
|
||||||
local_hour: int,
|
local_hour: int,
|
||||||
|
first_peak_h: int,
|
||||||
|
last_peak_h: int,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
times = hourly_next_48h.get("times") or []
|
times = hourly_next_48h.get("times") or []
|
||||||
if not times:
|
if not times:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
preferred_start = max(local_hour, 12)
|
preferred_start = max(local_hour, max(0, first_peak_h - 2))
|
||||||
preferred_end = 19
|
preferred_end = min(23, last_peak_h + 1)
|
||||||
candidate_indexes = [
|
candidate_indexes = [
|
||||||
index
|
index
|
||||||
for index, ts in enumerate(times)
|
for index, ts in enumerate(times)
|
||||||
@@ -766,6 +768,8 @@ def _analyze(city: str, force_refresh: bool = False) -> Dict[str, Any]:
|
|||||||
next_48h_hourly,
|
next_48h_hourly,
|
||||||
local_date_str,
|
local_date_str,
|
||||||
local_hour,
|
local_hour,
|
||||||
|
first_peak_h,
|
||||||
|
last_peak_h,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── 13. Cloud description (METAR primary, MGM fallback) ──
|
# ── 13. Cloud description (METAR primary, MGM fallback) ──
|
||||||
|
|||||||
Reference in New Issue
Block a user