Skip to content

Commit 697c5d9

Browse files
committed
Add standard signal snapshots
1 parent e913e04 commit 697c5d9

4 files changed

Lines changed: 261 additions & 3 deletions

File tree

application/rebalance_service.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
build_reconciliation_record,
1414
write_reconciliation_record,
1515
)
16+
from application.signal_snapshot import build_signal_snapshot
1617
from notifications.events import NotificationPublisher
1718
from notifications import renderers as notification_renderers
1819
from quant_platform_kit.common.models import PortfolioSnapshot, Position
@@ -581,6 +582,18 @@ def run_strategy_core(
581582
signal_metadata = {}
582583
allocation = _resolve_weight_allocation(signal_metadata, required=target_weights is not None)
583584
resolved_target_weights = dict(allocation.get("targets") or {}) if target_weights is not None else None
585+
signal_metadata = dict(signal_metadata or {})
586+
signal_metadata["signal_snapshot"] = build_signal_snapshot(
587+
platform="ibkr",
588+
strategy_profile=signal_metadata.get("strategy_profile"),
589+
metadata={
590+
**signal_metadata,
591+
"latest_price_source": signal_metadata.get("price_source_mode")
592+
or "ibkr_strategy_market_data",
593+
},
594+
allocation={**allocation, "target_mode": "weight"},
595+
target_weights=resolved_target_weights,
596+
)
584597

585598
dashboard = notification_renderers.build_dashboard(
586599
positions,

application/signal_snapshot.py

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
"""Shared signal snapshot payload helpers for platform reports."""
2+
3+
from __future__ import annotations
4+
5+
from collections.abc import Mapping
6+
from datetime import date, datetime, timezone
7+
from typing import Any
8+
9+
SIGNAL_SNAPSHOT_SCHEMA_VERSION = "signal_snapshot.v1"
10+
11+
_INDICATOR_FIELDS = (
12+
"benchmark_symbol",
13+
"benchmark_price",
14+
"long_trend_value",
15+
"exit_line",
16+
"active_risk_asset",
17+
"allocation_mode",
18+
"trend_symbol",
19+
"trend_price",
20+
"trend_ma",
21+
"trend_ma20",
22+
"trend_ma20_slope",
23+
"trend_rsi14",
24+
"trend_rsi14_dynamic_threshold",
25+
"trend_rsi14_effective_threshold",
26+
"trend_bb_upper",
27+
"blend_gate_volatility_delever_metric",
28+
"blend_gate_volatility_delever_triggered",
29+
)
30+
31+
32+
def _utcnow() -> datetime:
33+
return datetime.now(timezone.utc)
34+
35+
36+
def _json_safe(value: Any) -> Any:
37+
if isinstance(value, datetime):
38+
return value.isoformat()
39+
if isinstance(value, date):
40+
return value.isoformat()
41+
if isinstance(value, Mapping):
42+
return {str(key): _json_safe(item) for key, item in value.items()}
43+
if isinstance(value, (list, tuple, set)):
44+
return [_json_safe(item) for item in value]
45+
return value
46+
47+
48+
def _first_value(*values: Any) -> Any:
49+
for value in values:
50+
if value is not None and value != "":
51+
return value
52+
return None
53+
54+
55+
def _merge_signal_sources(*sources: Mapping[str, Any] | None) -> dict[str, Any]:
56+
merged: dict[str, Any] = {}
57+
for source in sources:
58+
if not isinstance(source, Mapping):
59+
continue
60+
annotations = source.get("execution_annotations")
61+
if isinstance(annotations, Mapping):
62+
merged.update(annotations)
63+
merged.update(source)
64+
return merged
65+
66+
67+
def _normalized_numeric_mapping(value: Any) -> dict[str, float]:
68+
if not isinstance(value, Mapping):
69+
return {}
70+
normalized: dict[str, float] = {}
71+
for key, raw_value in value.items():
72+
symbol = str(key or "").strip().upper()
73+
if not symbol:
74+
continue
75+
try:
76+
normalized[symbol] = float(raw_value)
77+
except (TypeError, ValueError):
78+
continue
79+
return normalized
80+
81+
82+
def _target_payload(
83+
*,
84+
allocation: Mapping[str, Any] | None,
85+
explicit_target_weights: Mapping[str, Any] | None,
86+
) -> tuple[str | None, dict[str, float], dict[str, float]]:
87+
allocation = allocation if isinstance(allocation, Mapping) else {}
88+
target_mode = str(allocation.get("target_mode") or "").strip() or None
89+
targets = _normalized_numeric_mapping(explicit_target_weights or allocation.get("targets"))
90+
if target_mode == "value":
91+
return target_mode, {}, targets
92+
return target_mode, targets, {}
93+
94+
95+
def build_signal_snapshot(
96+
*,
97+
platform: str,
98+
strategy_profile: str | None = None,
99+
generated_at: datetime | None = None,
100+
diagnostics: Mapping[str, Any] | None = None,
101+
execution: Mapping[str, Any] | None = None,
102+
allocation: Mapping[str, Any] | None = None,
103+
metadata: Mapping[str, Any] | None = None,
104+
target_weights: Mapping[str, Any] | None = None,
105+
) -> dict[str, Any]:
106+
source = _merge_signal_sources(metadata, diagnostics, execution)
107+
target_mode, normalized_weights, normalized_values = _target_payload(
108+
allocation=allocation,
109+
explicit_target_weights=target_weights,
110+
)
111+
indicators = {
112+
field: _json_safe(source[field])
113+
for field in _INDICATOR_FIELDS
114+
if source.get(field) not in (None, "")
115+
}
116+
snapshot = {
117+
"schema_version": SIGNAL_SNAPSHOT_SCHEMA_VERSION,
118+
"platform": str(platform or "").strip(),
119+
"strategy_profile": _first_value(strategy_profile, source.get("strategy_profile")),
120+
"strategy_version": source.get("strategy_version"),
121+
"generated_at": _json_safe(generated_at or _utcnow()),
122+
"signal_as_of": _json_safe(
123+
_first_value(
124+
source.get("signal_as_of"),
125+
source.get("signal_date"),
126+
source.get("snapshot_as_of"),
127+
source.get("trade_date"),
128+
)
129+
),
130+
"market_date": _json_safe(
131+
_first_value(
132+
source.get("market_date"),
133+
source.get("signal_date"),
134+
source.get("snapshot_as_of"),
135+
source.get("trade_date"),
136+
)
137+
),
138+
"effective_date": _json_safe(source.get("effective_date")),
139+
"latest_price_source": _first_value(
140+
source.get("latest_price_source"),
141+
source.get("price_source_mode"),
142+
source.get("market_data_source"),
143+
source.get("signal_source"),
144+
),
145+
"quote_overlay_used": source.get("quote_overlay_used"),
146+
"data_freshness_warning": _first_value(
147+
source.get("data_freshness_warning"),
148+
source.get("snapshot_price_fallback_used"),
149+
),
150+
"signal": _first_value(
151+
source.get("signal_display"),
152+
source.get("signal_description"),
153+
source.get("signal_message"),
154+
),
155+
"status": _first_value(
156+
source.get("status_display"),
157+
source.get("status_description"),
158+
source.get("market_status"),
159+
source.get("canary_status"),
160+
),
161+
"target_mode": target_mode,
162+
"target_weights": normalized_weights,
163+
"target_values": normalized_values,
164+
"indicators": indicators,
165+
}
166+
return {key: _json_safe(value) for key, value in snapshot.items()}

main.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from application.runtime_composer import build_runtime_composer
2323
from application.runtime_strategy_adapters import build_runtime_strategy_adapters
2424
from application.rebalance_service import run_strategy_core as run_rebalance_cycle
25+
from application.signal_snapshot import build_signal_snapshot
2526
from decision_mapper import map_strategy_decision
2627
from entrypoints.cloud_run import is_market_open_today
2728
from notifications.telegram import build_strategy_display_name, build_translator, send_telegram_message
@@ -530,6 +531,22 @@ def build_strategy_plugin_alert_context_label() -> str:
530531
)
531532

532533

534+
def _has_signal_snapshot_details(snapshot: dict[str, object]) -> bool:
535+
return any(
536+
snapshot.get(field_name)
537+
for field_name in (
538+
"signal_as_of",
539+
"market_date",
540+
"latest_price_source",
541+
"target_weights",
542+
"target_values",
543+
"indicators",
544+
"signal",
545+
"status",
546+
)
547+
)
548+
549+
533550
def publish_strategy_plugin_alerts(signals, *, report=None):
534551
result = dispatch_strategy_plugin_alerts(
535552
signals,
@@ -688,6 +705,35 @@ def _handle_request(*, dry_run_only_override: bool | None = None, response_body:
688705
)
689706
execution_summary = dict(cycle_result.execution_summary or {})
690707
reconciliation_record = dict(cycle_result.reconciliation_record or {})
708+
signal_metadata = dict(cycle_result.signal_metadata or {})
709+
signal_snapshot = dict(signal_metadata.get("signal_snapshot") or {})
710+
if not signal_snapshot:
711+
signal_snapshot = build_signal_snapshot(
712+
platform="ibkr",
713+
strategy_profile=signal_metadata.get("strategy_profile") or STRATEGY_PROFILE,
714+
metadata=signal_metadata,
715+
target_weights=cycle_result.target_weights,
716+
)
717+
if execution_summary.get("price_source_mode") or reconciliation_record.get("price_source_mode"):
718+
signal_snapshot["latest_price_source"] = (
719+
execution_summary.get("price_source_mode")
720+
or reconciliation_record.get("price_source_mode")
721+
)
722+
fallback_used = bool(
723+
execution_summary.get("snapshot_price_fallback_used")
724+
or reconciliation_record.get("snapshot_price_fallback_used")
725+
)
726+
if fallback_used:
727+
signal_snapshot["data_freshness_warning"] = "snapshot_price_fallback_used"
728+
has_signal_snapshot = _has_signal_snapshot_details(signal_snapshot)
729+
if has_signal_snapshot:
730+
log_runtime_event(
731+
log_context,
732+
"strategy_signal_snapshot",
733+
message="Strategy signal snapshot",
734+
execution_window="precheck" if dry_run_only_override else "execution",
735+
**signal_snapshot,
736+
)
691737
finalize_runtime_report(
692738
report,
693739
status="ok",
@@ -721,6 +767,7 @@ def _handle_request(*, dry_run_only_override: bool | None = None, response_body:
721767
"snapshot_price_fallback_symbols": execution_summary.get("snapshot_price_fallback_symbols")
722768
or reconciliation_record.get("snapshot_price_fallback_symbols")
723769
or [],
770+
**({"signal_snapshot": signal_snapshot} if has_signal_snapshot else {}),
724771
},
725772
artifacts={
726773
"reconciliation_record_path": cycle_result.reconciliation_record_path,

notifications/renderers.py

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,36 @@ def _build_timing_audit_lines(signal_metadata, *, translator) -> list[str]:
300300
return [f"{label}: {value}"]
301301

302302

303+
def _format_signal_snapshot_line(snapshot, *, translator) -> str:
304+
if not isinstance(snapshot, Mapping):
305+
return ""
306+
market_date = str(snapshot.get("market_date") or snapshot.get("signal_as_of") or "").strip()
307+
source = str(snapshot.get("latest_price_source") or "").strip()
308+
overlay = snapshot.get("quote_overlay_used")
309+
warning = snapshot.get("data_freshness_warning")
310+
if not market_date and not source and overlay is None and warning in (None, "", False):
311+
return ""
312+
if _translator_uses_zh(translator):
313+
overlay_text = "是" if overlay is True else "否" if overlay is False else "未知"
314+
parts = [
315+
f"日期 {market_date or '未知'}",
316+
f"数据源 {source or '未知'}",
317+
f"报价覆盖 {overlay_text}",
318+
]
319+
if warning not in (None, "", False):
320+
parts.append(f"提示 {warning}")
321+
return "🧾 信号快照: " + " | ".join(parts)
322+
overlay_text = "yes" if overlay is True else "no" if overlay is False else "unknown"
323+
parts = [
324+
f"date {market_date or 'unknown'}",
325+
f"source {source or 'unknown'}",
326+
f"quote overlay {overlay_text}",
327+
]
328+
if warning not in (None, "", False):
329+
parts.append(f"warning {warning}")
330+
return "🧾 Signal snapshot: " + " | ".join(parts)
331+
332+
303333
def _strategy_dashboard_text(signal_metadata, *, translator) -> str:
304334
metadata = signal_metadata if isinstance(signal_metadata, Mapping) else {}
305335
raw_annotations = metadata.get("execution_annotations")
@@ -311,11 +341,13 @@ def _strategy_dashboard_text(signal_metadata, *, translator) -> str:
311341
or ""
312342
)
313343
timing_lines = _build_timing_audit_lines(metadata, translator=translator)
314-
if not timing_lines:
344+
snapshot_line = _format_signal_snapshot_line(metadata.get("signal_snapshot"), translator=translator)
345+
audit_lines = [*timing_lines, *([snapshot_line] if snapshot_line else [])]
346+
if not audit_lines:
315347
return dashboard_text
316348
if not dashboard_text:
317-
return "\n".join(timing_lines)
318-
return f"{dashboard_text}\n" + "\n".join(timing_lines)
349+
return "\n".join(audit_lines)
350+
return f"{dashboard_text}\n" + "\n".join(audit_lines)
319351

320352

321353
def build_dashboard(

0 commit comments

Comments
 (0)