From 449eb48d8b2834fd5b23bf36bd3cf30469599923 Mon Sep 17 00:00:00 2001
From: "2569718930@qq.com" <2569718930@qq.com>
Date: Fri, 17 Apr 2026 19:49:01 +0800
Subject: [PATCH] Add history model context and widen signal push window
---
.env.example | 3 +
.../components/dashboard/Dashboard.module.css | 101 ++++++++++++++++++
.../components/dashboard/HistoryModal.tsx | 88 +++++++++++++++
frontend/lib/dashboard-types.ts | 16 +++
src/bot/runtime_coordinator.py | 22 +++-
src/utils/telegram_push.py | 44 ++++++--
tests/test_market_alert_engine.py | 65 ++++++++++-
web/routes.py | 52 +++++++++
8 files changed, 380 insertions(+), 11 deletions(-)
diff --git a/.env.example b/.env.example
index ff3fa9a8..a5903a7c 100644
--- a/.env.example
+++ b/.env.example
@@ -88,6 +88,9 @@ TELEGRAM_ALERT_MISPRICING_INTERVAL_SEC=7200
TELEGRAM_MARKET_FOCUS_DIGEST_ENABLED=true
TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC=1800
TELEGRAM_MARKET_FOCUS_DIGEST_TOP_N=5
+TELEGRAM_MARKET_FOCUS_PUSH_START_LOCAL_HOUR=8
+TELEGRAM_MARKET_FOCUS_PUSH_BEFORE_PEAK_MIN=480
+TELEGRAM_MARKET_FOCUS_PUSH_AFTER_PEAK_MIN=300
TELEGRAM_ALERT_CITIES=ankara,london,paris,seoul,hong kong,shanghai,singapore,tokyo,tel aviv,toronto,buenos aires,wellington,new york,chicago,dallas,miami,atlanta,seattle,lucknow,sao paulo,munich
POLYWEATHER_MONITORING_ALERT_CHAT_IDS=
diff --git a/frontend/components/dashboard/Dashboard.module.css b/frontend/components/dashboard/Dashboard.module.css
index b2721fc9..828a1136 100644
--- a/frontend/components/dashboard/Dashboard.module.css
+++ b/frontend/components/dashboard/Dashboard.module.css
@@ -2769,6 +2769,14 @@
background: rgba(15, 23, 42, 0.42);
}
+.root :global(.history-modal .history-model-reference) {
+ margin-top: 18px;
+ padding: 14px 16px;
+ border: 1px solid rgba(34, 211, 238, 0.18);
+ border-radius: 16px;
+ background: rgba(8, 47, 73, 0.2);
+}
+
.root :global(.history-modal .history-peak-reference-title) {
color: var(--text-primary);
font-size: 14px;
@@ -2776,6 +2784,16 @@
margin-bottom: 10px;
}
+.root :global(.history-modal .history-model-reference-scroll) {
+ display: grid;
+ gap: 8px;
+ max-height: 360px;
+ overflow-y: auto;
+ padding-right: 6px;
+ scrollbar-width: thin;
+ scrollbar-color: rgba(34, 211, 238, 0.5) rgba(15, 23, 42, 0.32);
+}
+
.root :global(.history-modal .history-peak-reference-scroll) {
display: grid;
gap: 8px;
@@ -2820,12 +2838,69 @@
background: rgba(255, 255, 255, 0.03);
}
+.root :global(.history-modal .history-model-reference-row) {
+ display: grid;
+ grid-template-columns: minmax(72px, 88px) 1fr;
+ gap: 10px;
+ padding: 10px 12px;
+ border-radius: 12px;
+ background: rgba(255, 255, 255, 0.03);
+}
+
.root :global(.history-modal .history-peak-reference-date) {
color: var(--text-secondary);
font-size: 12px;
font-weight: 700;
}
+.root :global(.history-modal .history-model-reference-body) {
+ display: grid;
+ gap: 8px;
+ min-width: 0;
+}
+
+.root :global(.history-modal .history-model-reference-summary) {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px 14px;
+ color: var(--text-secondary);
+ font-size: 12px;
+}
+
+.root :global(.history-modal .history-model-reference-summary strong) {
+ color: var(--text-primary);
+}
+
+.root :global(.history-modal .history-model-reference-models) {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 6px;
+}
+
+.root :global(.history-modal .history-model-reference-model) {
+ display: grid;
+ gap: 3px;
+ min-width: 0;
+ padding: 8px 10px;
+ border: 1px solid rgba(148, 163, 184, 0.12);
+ border-radius: 8px;
+ background: rgba(15, 23, 42, 0.38);
+ color: var(--text-secondary);
+ font-size: 11px;
+}
+
+.root :global(.history-modal .history-model-name) {
+ overflow: hidden;
+ color: var(--accent-cyan);
+ font-weight: 700;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.root :global(.history-modal .history-model-error) {
+ color: var(--text-muted);
+}
+
.root :global(.history-modal .history-peak-reference-meta) {
color: var(--text-secondary);
font-size: 12px;
@@ -2857,6 +2932,14 @@
.root :global(.history-modal .history-peak-reference-scroll) {
max-height: 320px;
}
+
+ .root :global(.history-modal .history-model-reference-scroll) {
+ max-height: 320px;
+ }
+
+ .root :global(.history-modal .history-model-reference-models) {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
}
@media (max-width: 640px) {
@@ -2921,15 +3004,33 @@
padding: 12px;
}
+ .root :global(.history-modal .history-model-reference) {
+ padding: 12px;
+ }
+
.root :global(.history-modal .history-peak-reference-scroll) {
max-height: 260px;
padding-right: 2px;
}
+ .root :global(.history-modal .history-model-reference-scroll) {
+ max-height: 260px;
+ padding-right: 2px;
+ }
+
.root :global(.history-modal .history-peak-reference-row) {
grid-template-columns: 1fr;
gap: 6px;
}
+
+ .root :global(.history-modal .history-model-reference-row) {
+ grid-template-columns: 1fr;
+ gap: 6px;
+ }
+
+ .root :global(.history-modal .history-model-reference-models) {
+ grid-template-columns: 1fr;
+ }
}
/* ── Info Button ── */
diff --git a/frontend/components/dashboard/HistoryModal.tsx b/frontend/components/dashboard/HistoryModal.tsx
index 627926b9..80428423 100644
--- a/frontend/components/dashboard/HistoryModal.tsx
+++ b/frontend/components/dashboard/HistoryModal.tsx
@@ -199,6 +199,19 @@ export function HistoryModal() {
.reverse(),
[summary.recentData],
);
+ const modelReferenceRows = useMemo(
+ () =>
+ summary.recentData
+ .filter(
+ (row) =>
+ row.actual != null &&
+ row.model_reference?.available &&
+ (row.model_reference.models?.length || 0) > 0,
+ )
+ .slice(-5)
+ .reverse(),
+ [summary.recentData],
+ );
if (!isOpen) return null;
@@ -383,6 +396,81 @@ export function HistoryModal() {
)}
{!error && }
+ {!error && modelReferenceRows.length > 0 && (
+
diff --git a/frontend/lib/dashboard-types.ts b/frontend/lib/dashboard-types.ts
index 6400f853..dfa4da25 100644
--- a/frontend/lib/dashboard-types.ts
+++ b/frontend/lib/dashboard-types.ts
@@ -527,6 +527,22 @@ export interface HistoryPoint {
mu?: number | null;
mgm?: number | null;
forecasts?: Record;
+ model_reference?: {
+ available?: boolean;
+ truth_layer?: string | null;
+ reference_layer?: string | null;
+ deb?: {
+ value?: number | null;
+ error?: number | null;
+ };
+ models?: Array<{
+ model?: string | null;
+ value?: number | null;
+ error?: number | null;
+ participates_in_deb?: boolean;
+ }>;
+ model_count?: number | null;
+ };
settlement_source?: string | null;
settlement_station_code?: string | null;
settlement_station_label?: string | null;
diff --git a/src/bot/runtime_coordinator.py b/src/bot/runtime_coordinator.py
index 4a8c2642..3dfb7b3f 100644
--- a/src/bot/runtime_coordinator.py
+++ b/src/bot/runtime_coordinator.py
@@ -162,10 +162,24 @@ class StartupCoordinator:
"cities_count": cities_count,
"chat_targets": len(chat_ids),
"focus_digest_enabled": _env_bool("TELEGRAM_MARKET_FOCUS_DIGEST_ENABLED", True),
- "focus_digest_hours": str(
- os.getenv("TELEGRAM_MARKET_FOCUS_DIGEST_HOURS") or "11,18"
- ).strip(),
- "schedule_basis": "server_local_time",
+ "focus_digest_interval_sec": max(
+ 300,
+ _env_int("TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC", 1800),
+ ),
+ "focus_push_start_local_hour": min(
+ 23,
+ max(0, _env_int("TELEGRAM_MARKET_FOCUS_PUSH_START_LOCAL_HOUR", 8)),
+ ),
+ "focus_push_before_peak_min": max(
+ 0,
+ _env_int("TELEGRAM_MARKET_FOCUS_PUSH_BEFORE_PEAK_MIN", 480),
+ ),
+ "focus_push_after_peak_min": max(
+ 0,
+ _env_int("TELEGRAM_MARKET_FOCUS_PUSH_AFTER_PEAK_MIN", 300),
+ ),
+ "schedule_basis": "city_local_time_for_push_window",
+ "daily_signal_cap": "none",
}
validation_error = None if chat_ids else "missing_TELEGRAM_CHAT_IDS"
return self._start_with_validation(
diff --git a/src/utils/telegram_push.py b/src/utils/telegram_push.py
index 17a5e565..12a0915e 100644
--- a/src/utils/telegram_push.py
+++ b/src/utils/telegram_push.py
@@ -317,6 +317,32 @@ def _format_interval_brief(seconds: int) -> str:
return f"{total}秒"
+def _focus_push_window_config() -> Dict[str, int]:
+ return {
+ "start_local_hour": min(
+ 23,
+ max(0, _env_int("TELEGRAM_MARKET_FOCUS_PUSH_START_LOCAL_HOUR", 8)),
+ ),
+ "before_peak_min": max(
+ 0,
+ _env_int("TELEGRAM_MARKET_FOCUS_PUSH_BEFORE_PEAK_MIN", 480),
+ ),
+ "after_peak_min": max(
+ 0,
+ _env_int("TELEGRAM_MARKET_FOCUS_PUSH_AFTER_PEAK_MIN", 300),
+ ),
+ }
+
+
+def _focus_push_window_label() -> str:
+ config = _focus_push_window_config()
+ return (
+ f"当地 {config['start_local_hour']:02d}:00 后,"
+ f"峰值前 {_format_minutes_window(config['before_peak_min'])} / "
+ f"峰值后 {_format_minutes_window(config['after_peak_min'])} 内"
+ )
+
+
def _local_peak_context(alert_payload: Dict[str, Any]) -> Dict[str, Any]:
evidence = alert_payload.get("evidence") or {}
generated_local_time = str(evidence.get("generated_local_time") or "").strip()
@@ -456,15 +482,16 @@ def _shortlist_focus_payloads(
if not _market_price_cap_ok(item, require_actionable_quote=True):
continue
if for_push:
+ window_config = _focus_push_window_config()
peak_context = _local_peak_context(item)
local_min = _minute_of_day(peak_context.get("local_time"))
minutes_to_peak = peak_context.get("minutes_to_peak")
- if local_min is not None and local_min < 9 * 60:
+ if local_min is not None and local_min < window_config["start_local_hour"] * 60:
continue
- if isinstance(minutes_to_peak, int | float):
- if minutes_to_peak > 360:
+ if isinstance(minutes_to_peak, (int, float)):
+ if minutes_to_peak > window_config["before_peak_min"]:
continue
- if minutes_to_peak < -90:
+ if minutes_to_peak < -window_config["after_peak_min"]:
continue
shortlisted.append(item)
if len(shortlisted) >= top_n:
@@ -542,6 +569,8 @@ def _build_focus_digest_message(
frequency_parts = [
f"后台扫描:约每{scan_interval}一次",
f"主动推送:约每{digest_interval}一次",
+ f"观察窗口:{_focus_push_window_label()}",
+ "没有每日信号次数上限",
]
lines.append("更新频率:" + ";".join(frequency_parts))
return "\n".join(lines).strip()
@@ -576,17 +605,20 @@ def _maybe_send_focus_digest(
if bool((item.get("market_snapshot") or {}).get("available"))
and _market_price_cap_ok(item, require_actionable_quote=True)
)
- shortlisted = _shortlist_focus_payloads(payloads, top_n=top_n)
+ shortlisted = _shortlist_focus_payloads(payloads, top_n=top_n, for_push=True)
+ reference_shortlisted = _shortlist_focus_payloads(payloads, top_n=top_n, for_push=False)
high_priority_count = sum(1 for item in shortlisted if _market_monitor_score(item) >= 72)
logger.info(
- "market focus digest evaluate payloads={} available={} actionable={} shortlisted={} high_priority={} interval_sec={} top_n={}",
+ "market focus digest evaluate payloads={} available={} actionable={} shortlisted={} reference_shortlisted={} high_priority={} interval_sec={} top_n={} push_window={}",
len(payloads),
available_count,
actionable_count,
len(shortlisted),
+ len(reference_shortlisted),
high_priority_count,
digest_interval_sec,
top_n,
+ _focus_push_window_label(),
)
if last_digest_ts and now_ts - last_digest_ts < digest_interval_sec:
logger.info(
diff --git a/tests/test_market_alert_engine.py b/tests/test_market_alert_engine.py
index 9112a08c..9a90d22a 100644
--- a/tests/test_market_alert_engine.py
+++ b/tests/test_market_alert_engine.py
@@ -1,5 +1,9 @@
from src.analysis.market_alert_engine import build_trading_alerts
-from src.utils.telegram_push import build_market_monitor_digest
+from src.utils.telegram_push import (
+ _build_focus_digest_message,
+ _shortlist_focus_payloads,
+ build_market_monitor_digest,
+)
def _sample_weather_payload():
@@ -195,3 +199,62 @@ def test_market_monitor_digest_skips_non_tradable_market(monkeypatch):
digest = build_market_monitor_digest({}, slot_label="当前市场概览")
assert digest == "ℹ️ 当前没有可用的市场监控摘要。"
+
+
+def _focus_payload(*, local_time: str = "14:00", peak_time: str = "12:00"):
+ return {
+ "city": "madrid",
+ "severity": "medium",
+ "trigger_count": 1,
+ "rules": {},
+ "market_snapshot": {
+ "available": True,
+ "forecast_bucket": {
+ "label": "30°C",
+ "value": 30,
+ "yes_buy": 0.18,
+ "market_url": "https://example.com/market",
+ },
+ "market_url": "https://example.com/market",
+ "confidence": "medium",
+ "edge_percent": 5.0,
+ },
+ "evidence": {
+ "generated_local_time": local_time,
+ "inputs": {
+ "current_temp": 29.0,
+ "deb_prediction": 30.2,
+ },
+ "trigger_summary": {
+ "suppression_snapshot": {
+ "max_temp_time": peak_time,
+ },
+ },
+ },
+ }
+
+
+def test_focus_digest_push_window_does_not_cut_off_after_2pm_spanish_time(monkeypatch):
+ monkeypatch.delenv("TELEGRAM_MARKET_FOCUS_PUSH_AFTER_PEAK_MIN", raising=False)
+
+ shortlisted = _shortlist_focus_payloads(
+ [_focus_payload(local_time="14:00", peak_time="12:00")],
+ top_n=5,
+ for_push=True,
+ )
+
+ assert len(shortlisted) == 1
+
+
+def test_focus_digest_footer_explains_no_daily_signal_cap(monkeypatch):
+ monkeypatch.setenv("TELEGRAM_ALERT_PUSH_INTERVAL_SEC", "300")
+ monkeypatch.setenv("TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC", "1800")
+
+ message = _build_focus_digest_message(
+ [_focus_payload(local_time="14:00", peak_time="12:00")],
+ slot_label="白天关注",
+ top_n=5,
+ )
+
+ assert "没有每日信号次数上限" in message
+ assert "观察窗口" in message
diff --git a/web/routes.py b/web/routes.py
index 99798202..5ee19d06 100644
--- a/web/routes.py
+++ b/web/routes.py
@@ -203,6 +203,52 @@ def _refresh_city_market_cache(city: str, force_refresh: bool = False) -> dict:
return payload
+def _build_history_model_reference(
+ *,
+ forecasts: dict,
+ actual: object,
+ deb: object,
+) -> dict:
+ """Expose the archived model snapshot as reference evidence, not truth."""
+ actual_value = _sf(actual)
+ deb_value = _sf(deb)
+ entries = []
+ for model_name, model_value in (forecasts or {}).items():
+ if _is_excluded_model_name(str(model_name)):
+ continue
+ value = _sf(model_value)
+ if value is None:
+ continue
+ error = abs(value - actual_value) if actual_value is not None else None
+ entries.append(
+ {
+ "model": str(model_name),
+ "value": round(value, 1),
+ "error": round(error, 1) if error is not None else None,
+ "participates_in_deb": True,
+ }
+ )
+ entries.sort(
+ key=lambda row: (
+ row["error"] is None,
+ row["error"] if row["error"] is not None else 999,
+ row["model"],
+ )
+ )
+ deb_error = abs(deb_value - actual_value) if deb_value is not None and actual_value is not None else None
+ return {
+ "available": bool(entries),
+ "truth_layer": "settlement_actual",
+ "reference_layer": "archived_model_snapshot",
+ "deb": {
+ "value": round(deb_value, 1) if deb_value is not None else None,
+ "error": round(deb_error, 1) if deb_error is not None else None,
+ },
+ "models": entries,
+ "model_count": len(entries),
+ }
+
+
def _build_city_history_payload(city: str, include_records: bool = False) -> dict:
source = str(CITIES.get(city, {}).get("settlement_source") or "metar").strip().lower()
truth_rows = _truth_record_repo.load_city(city)
@@ -276,6 +322,11 @@ def _build_city_history_payload(city: str, include_records: bool = False) -> dic
forecasts,
snapshots,
)
+ model_reference = _build_history_model_reference(
+ forecasts=forecasts,
+ actual=act,
+ deb=deb,
+ )
mgm = forecasts.get("MGM")
out.append(
{
@@ -285,6 +336,7 @@ def _build_city_history_payload(city: str, include_records: bool = False) -> dic
"mu": float(mu) if mu is not None else None,
"mgm": float(mgm) if mgm is not None else None,
"forecasts": forecasts,
+ "model_reference": model_reference,
"settlement_source": rec.get("settlement_source"),
"settlement_station_code": rec.get("settlement_station_code"),
"settlement_station_label": rec.get("settlement_station_label"),