Add history model context and widen signal push window

This commit is contained in:
2569718930@qq.com
2026-04-17 19:49:01 +08:00
parent ce7a037b6d
commit 449eb48d8b
8 changed files with 380 additions and 11 deletions
+3
View File
@@ -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=
@@ -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 ── */
@@ -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() {
)}
</div>
{!error && <HistoryChart />}
{!error && modelReferenceRows.length > 0 && (
<div className="history-model-reference">
<div className="modal-section-heading">
<div className="modal-section-kicker">
{locale === "en-US" ? "Reference layer" : "参考层"}
</div>
<div className="history-peak-reference-title">
{locale === "en-US"
? "Model Reference at Cutoff"
: "当时模型参考"}
</div>
<div className="modal-section-note">
{locale === "en-US"
? "These are archived model snapshots used for audit context. Settlement truth still comes only from the finalized observation source."
: "这里展示当时归档的模型快照,仅用于解释判断背景;结算真值仍只来自最终实况来源。"}
</div>
</div>
<div className="history-model-reference-scroll">
{modelReferenceRows.map((row) => {
const models = (row.model_reference?.models || []).slice(0, 6);
return (
<div key={row.date} className="history-model-reference-row">
<div className="history-peak-reference-date">
{row.date}
</div>
<div className="history-model-reference-body">
<div className="history-model-reference-summary">
<span>
{locale === "en-US" ? "Actual" : "最终实测"}{" "}
<strong>
{row.actual}
{store.selectedDetail?.temp_symbol || "°C"}
</strong>
</span>
<span>
DEB{" "}
<strong>
{row.model_reference?.deb?.value ?? row.deb ?? "--"}
{store.selectedDetail?.temp_symbol || "°C"}
</strong>
{row.model_reference?.deb?.error != null
? ` / ${locale === "en-US" ? "err" : "误差"} ${row.model_reference.deb.error}${store.selectedDetail?.temp_symbol || "°C"}`
: ""}
</span>
</div>
<div className="history-model-reference-models">
{models.map((model) => (
<div
key={`${row.date}-${model.model}`}
className="history-model-reference-model"
>
<span className="history-model-name">
{model.model}
</span>
<span>
{model.value}
{store.selectedDetail?.temp_symbol || "°C"}
</span>
<span className="history-model-error">
{model.error != null
? `${locale === "en-US" ? "err" : "误差"} ${model.error}${store.selectedDetail?.temp_symbol || "°C"}`
: locale === "en-US"
? "pending"
: "待结算"}
</span>
</div>
))}
</div>
</div>
</div>
);
})}
</div>
</div>
)}
{!error && settledPeakRows.length > 0 && (
<div className="history-peak-reference">
<div className="modal-section-heading">
+16
View File
@@ -527,6 +527,22 @@ export interface HistoryPoint {
mu?: number | null;
mgm?: number | null;
forecasts?: Record<string, number | null>;
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;
+18 -4
View File
@@ -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(
+38 -6
View File
@@ -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(
+64 -1
View File
@@ -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
+52
View File
@@ -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"),