Add history model context and widen signal push window
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user