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_ENABLED=true
|
||||||
TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC=1800
|
TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC=1800
|
||||||
TELEGRAM_MARKET_FOCUS_DIGEST_TOP_N=5
|
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
|
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=
|
POLYWEATHER_MONITORING_ALERT_CHAT_IDS=
|
||||||
|
|
||||||
|
|||||||
@@ -2769,6 +2769,14 @@
|
|||||||
background: rgba(15, 23, 42, 0.42);
|
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) {
|
.root :global(.history-modal .history-peak-reference-title) {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
@@ -2776,6 +2784,16 @@
|
|||||||
margin-bottom: 10px;
|
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) {
|
.root :global(.history-modal .history-peak-reference-scroll) {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -2820,12 +2838,69 @@
|
|||||||
background: rgba(255, 255, 255, 0.03);
|
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) {
|
.root :global(.history-modal .history-peak-reference-date) {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 700;
|
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) {
|
.root :global(.history-modal .history-peak-reference-meta) {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -2857,6 +2932,14 @@
|
|||||||
.root :global(.history-modal .history-peak-reference-scroll) {
|
.root :global(.history-modal .history-peak-reference-scroll) {
|
||||||
max-height: 320px;
|
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) {
|
@media (max-width: 640px) {
|
||||||
@@ -2921,15 +3004,33 @@
|
|||||||
padding: 12px;
|
padding: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.root :global(.history-modal .history-model-reference) {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.root :global(.history-modal .history-peak-reference-scroll) {
|
.root :global(.history-modal .history-peak-reference-scroll) {
|
||||||
max-height: 260px;
|
max-height: 260px;
|
||||||
padding-right: 2px;
|
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) {
|
.root :global(.history-modal .history-peak-reference-row) {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: 6px;
|
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 ── */
|
/* ── Info Button ── */
|
||||||
|
|||||||
@@ -199,6 +199,19 @@ export function HistoryModal() {
|
|||||||
.reverse(),
|
.reverse(),
|
||||||
[summary.recentData],
|
[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;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
@@ -383,6 +396,81 @@ export function HistoryModal() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!error && <HistoryChart />}
|
{!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 && (
|
{!error && settledPeakRows.length > 0 && (
|
||||||
<div className="history-peak-reference">
|
<div className="history-peak-reference">
|
||||||
<div className="modal-section-heading">
|
<div className="modal-section-heading">
|
||||||
|
|||||||
@@ -527,6 +527,22 @@ export interface HistoryPoint {
|
|||||||
mu?: number | null;
|
mu?: number | null;
|
||||||
mgm?: number | null;
|
mgm?: number | null;
|
||||||
forecasts?: Record<string, 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_source?: string | null;
|
||||||
settlement_station_code?: string | null;
|
settlement_station_code?: string | null;
|
||||||
settlement_station_label?: string | null;
|
settlement_station_label?: string | null;
|
||||||
|
|||||||
@@ -162,10 +162,24 @@ class StartupCoordinator:
|
|||||||
"cities_count": cities_count,
|
"cities_count": cities_count,
|
||||||
"chat_targets": len(chat_ids),
|
"chat_targets": len(chat_ids),
|
||||||
"focus_digest_enabled": _env_bool("TELEGRAM_MARKET_FOCUS_DIGEST_ENABLED", True),
|
"focus_digest_enabled": _env_bool("TELEGRAM_MARKET_FOCUS_DIGEST_ENABLED", True),
|
||||||
"focus_digest_hours": str(
|
"focus_digest_interval_sec": max(
|
||||||
os.getenv("TELEGRAM_MARKET_FOCUS_DIGEST_HOURS") or "11,18"
|
300,
|
||||||
).strip(),
|
_env_int("TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC", 1800),
|
||||||
"schedule_basis": "server_local_time",
|
),
|
||||||
|
"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"
|
validation_error = None if chat_ids else "missing_TELEGRAM_CHAT_IDS"
|
||||||
return self._start_with_validation(
|
return self._start_with_validation(
|
||||||
|
|||||||
@@ -317,6 +317,32 @@ def _format_interval_brief(seconds: int) -> str:
|
|||||||
return f"{total}秒"
|
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]:
|
def _local_peak_context(alert_payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
evidence = alert_payload.get("evidence") or {}
|
evidence = alert_payload.get("evidence") or {}
|
||||||
generated_local_time = str(evidence.get("generated_local_time") or "").strip()
|
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):
|
if not _market_price_cap_ok(item, require_actionable_quote=True):
|
||||||
continue
|
continue
|
||||||
if for_push:
|
if for_push:
|
||||||
|
window_config = _focus_push_window_config()
|
||||||
peak_context = _local_peak_context(item)
|
peak_context = _local_peak_context(item)
|
||||||
local_min = _minute_of_day(peak_context.get("local_time"))
|
local_min = _minute_of_day(peak_context.get("local_time"))
|
||||||
minutes_to_peak = peak_context.get("minutes_to_peak")
|
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
|
continue
|
||||||
if isinstance(minutes_to_peak, int | float):
|
if isinstance(minutes_to_peak, (int, float)):
|
||||||
if minutes_to_peak > 360:
|
if minutes_to_peak > window_config["before_peak_min"]:
|
||||||
continue
|
continue
|
||||||
if minutes_to_peak < -90:
|
if minutes_to_peak < -window_config["after_peak_min"]:
|
||||||
continue
|
continue
|
||||||
shortlisted.append(item)
|
shortlisted.append(item)
|
||||||
if len(shortlisted) >= top_n:
|
if len(shortlisted) >= top_n:
|
||||||
@@ -542,6 +569,8 @@ def _build_focus_digest_message(
|
|||||||
frequency_parts = [
|
frequency_parts = [
|
||||||
f"后台扫描:约每{scan_interval}一次",
|
f"后台扫描:约每{scan_interval}一次",
|
||||||
f"主动推送:约每{digest_interval}一次",
|
f"主动推送:约每{digest_interval}一次",
|
||||||
|
f"观察窗口:{_focus_push_window_label()}",
|
||||||
|
"没有每日信号次数上限",
|
||||||
]
|
]
|
||||||
lines.append("更新频率:" + ";".join(frequency_parts))
|
lines.append("更新频率:" + ";".join(frequency_parts))
|
||||||
return "\n".join(lines).strip()
|
return "\n".join(lines).strip()
|
||||||
@@ -576,17 +605,20 @@ def _maybe_send_focus_digest(
|
|||||||
if bool((item.get("market_snapshot") or {}).get("available"))
|
if bool((item.get("market_snapshot") or {}).get("available"))
|
||||||
and _market_price_cap_ok(item, require_actionable_quote=True)
|
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)
|
high_priority_count = sum(1 for item in shortlisted if _market_monitor_score(item) >= 72)
|
||||||
logger.info(
|
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),
|
len(payloads),
|
||||||
available_count,
|
available_count,
|
||||||
actionable_count,
|
actionable_count,
|
||||||
len(shortlisted),
|
len(shortlisted),
|
||||||
|
len(reference_shortlisted),
|
||||||
high_priority_count,
|
high_priority_count,
|
||||||
digest_interval_sec,
|
digest_interval_sec,
|
||||||
top_n,
|
top_n,
|
||||||
|
_focus_push_window_label(),
|
||||||
)
|
)
|
||||||
if last_digest_ts and now_ts - last_digest_ts < digest_interval_sec:
|
if last_digest_ts and now_ts - last_digest_ts < digest_interval_sec:
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
from src.analysis.market_alert_engine import build_trading_alerts
|
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():
|
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="当前市场概览")
|
digest = build_market_monitor_digest({}, slot_label="当前市场概览")
|
||||||
assert digest == "ℹ️ 当前没有可用的市场监控摘要。"
|
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
|
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:
|
def _build_city_history_payload(city: str, include_records: bool = False) -> dict:
|
||||||
source = str(CITIES.get(city, {}).get("settlement_source") or "metar").strip().lower()
|
source = str(CITIES.get(city, {}).get("settlement_source") or "metar").strip().lower()
|
||||||
truth_rows = _truth_record_repo.load_city(city)
|
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,
|
forecasts,
|
||||||
snapshots,
|
snapshots,
|
||||||
)
|
)
|
||||||
|
model_reference = _build_history_model_reference(
|
||||||
|
forecasts=forecasts,
|
||||||
|
actual=act,
|
||||||
|
deb=deb,
|
||||||
|
)
|
||||||
mgm = forecasts.get("MGM")
|
mgm = forecasts.get("MGM")
|
||||||
out.append(
|
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,
|
"mu": float(mu) if mu is not None else None,
|
||||||
"mgm": float(mgm) if mgm is not None else None,
|
"mgm": float(mgm) if mgm is not None else None,
|
||||||
"forecasts": forecasts,
|
"forecasts": forecasts,
|
||||||
|
"model_reference": model_reference,
|
||||||
"settlement_source": rec.get("settlement_source"),
|
"settlement_source": rec.get("settlement_source"),
|
||||||
"settlement_station_code": rec.get("settlement_station_code"),
|
"settlement_station_code": rec.get("settlement_station_code"),
|
||||||
"settlement_station_label": rec.get("settlement_station_label"),
|
"settlement_station_label": rec.get("settlement_station_label"),
|
||||||
|
|||||||
Reference in New Issue
Block a user