Skip to content

Commit 77a1a2a

Browse files
authored
Add standard signal snapshots (#99)
1 parent 6d6a785 commit 77a1a2a

6 files changed

Lines changed: 237 additions & 2 deletions

File tree

application/rebalance_service.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from application.execution_service import execute_rebalance_cycle
99
from application.runtime_dependencies import LongBridgeRebalanceConfig, LongBridgeRebalanceRuntime
10+
from application.signal_snapshot import build_signal_snapshot
1011
from notifications.events import NotificationPublisher
1112
from notifications import renderers as notification_renderers
1213
from quant_platform_kit.common.notification_localization import (
@@ -217,6 +218,15 @@ def fetch_replanned_state():
217218
safe_haven_cash_substitute_threshold_usd=config.safe_haven_cash_substitute_threshold_usd,
218219
)
219220
execution = execution_result.execution
221+
execution["signal_snapshot"] = build_signal_snapshot(
222+
platform="longbridge",
223+
strategy_profile=config.strategy_profile,
224+
execution={
225+
**execution,
226+
"latest_price_source": "longbridge_candlesticks",
227+
},
228+
allocation=execution_result.allocation,
229+
)
220230
logs = list(execution_result.logs)
221231
skip_logs = list(execution_result.skip_logs)
222232
note_logs = list(execution_result.note_logs)
@@ -249,3 +259,4 @@ def fetch_replanned_state():
249259
extra_notification_lines=config.extra_notification_lines,
250260
)
251261
)
262+
return execution_result

application/runtime_composer.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,7 @@ def build_rebalance_config(self, *, strategy_plugin_signals=()) -> LongBridgeReb
182182
separator=self.separator,
183183
translator=self.translator,
184184
with_prefix=self.with_prefix,
185+
strategy_profile=self.strategy_profile,
185186
strategy_display_name=self.strategy_display_name_localized,
186187
dry_run_only=self.dry_run_only,
187188
post_sell_refresh_attempts=self.order_poll_max_attempts,

application/runtime_dependencies.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ class LongBridgeRebalanceConfig:
1616
separator: str
1717
translator: Callable[..., str]
1818
with_prefix: Callable[[str], str]
19+
strategy_profile: str = ""
1920
strategy_display_name: str = ""
2021
dry_run_only: bool = False
2122
post_sell_refresh_attempts: int = 1

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: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -309,13 +309,28 @@ def run_strategy(*, force_run: bool = False, validation_only: bool = False, vali
309309
)
310310
if not validation_only:
311311
publish_strategy_plugin_alerts(strategy_plugin_signals, report=report)
312-
run_rebalance_cycle(
312+
cycle_result = run_rebalance_cycle(
313313
runtime=composer.build_rebalance_runtime(
314314
silent_cycle_notifications=validation_only,
315315
),
316316
config=composer.build_rebalance_config(),
317317
)
318-
finalize_runtime_report(report, status="ok")
318+
signal_snapshot = {}
319+
if cycle_result is not None:
320+
execution = dict(getattr(cycle_result, "execution", {}) or {})
321+
signal_snapshot = dict(execution.get("signal_snapshot") or {})
322+
if signal_snapshot:
323+
reporting_adapters.log_event(
324+
log_context,
325+
"strategy_signal_snapshot",
326+
message="Strategy signal snapshot",
327+
**signal_snapshot,
328+
)
329+
finalize_runtime_report(
330+
report,
331+
status="ok",
332+
diagnostics={"signal_snapshot": signal_snapshot} if signal_snapshot else None,
333+
)
319334
reporting_adapters.log_event(
320335
log_context,
321336
"strategy_cycle_completed",

notifications/renderers.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
from collections.abc import Mapping
56
import re
67

78
from notifications.events import RenderedNotification
@@ -123,6 +124,42 @@ def _append_timing_lines(lines, *, execution, translator) -> None:
123124
lines.extend(_build_timing_audit_lines(execution, translator=translator))
124125

125126

127+
def _format_signal_snapshot_line(snapshot, *, translator) -> str:
128+
if not isinstance(snapshot, Mapping):
129+
return ""
130+
market_date = str(snapshot.get("market_date") or snapshot.get("signal_as_of") or "").strip()
131+
source = str(snapshot.get("latest_price_source") or "").strip()
132+
overlay = snapshot.get("quote_overlay_used")
133+
warning = snapshot.get("data_freshness_warning")
134+
if not market_date and not source and overlay is None and warning in (None, "", False):
135+
return ""
136+
if _translator_uses_zh(translator):
137+
overlay_text = "是" if overlay is True else "否" if overlay is False else "未知"
138+
parts = [
139+
f"日期 {market_date or '未知'}",
140+
f"数据源 {source or '未知'}",
141+
f"报价覆盖 {overlay_text}",
142+
]
143+
if warning not in (None, "", False):
144+
parts.append(f"提示 {warning}")
145+
return "🧾 信号快照: " + " | ".join(parts)
146+
overlay_text = "yes" if overlay is True else "no" if overlay is False else "unknown"
147+
parts = [
148+
f"date {market_date or 'unknown'}",
149+
f"source {source or 'unknown'}",
150+
f"quote overlay {overlay_text}",
151+
]
152+
if warning not in (None, "", False):
153+
parts.append(f"warning {warning}")
154+
return "🧾 Signal snapshot: " + " | ".join(parts)
155+
156+
157+
def _append_signal_snapshot_line(lines, *, execution, translator) -> None:
158+
line = _format_signal_snapshot_line(execution.get("signal_snapshot"), translator=translator)
159+
if line:
160+
lines.append(line)
161+
162+
126163
def _append_status_lines(lines, *, execution, translator, signal_key):
127164
status_display = _localize_notification_text(execution.get("status_display"), translator=translator)
128165
if status_display:
@@ -199,6 +236,7 @@ def render_rebalance_notification(
199236
_append_extra_notification_lines(detailed_lines, extra_notification_lines)
200237
_append_dashboard_block(detailed_lines, execution=execution, separator=separator)
201238
_append_timing_lines(detailed_lines, execution=execution, translator=translator)
239+
_append_signal_snapshot_line(detailed_lines, execution=execution, translator=translator)
202240
_append_status_lines(
203241
detailed_lines,
204242
execution=execution,
@@ -214,6 +252,7 @@ def render_rebalance_notification(
214252
_append_extra_notification_lines(compact_lines, extra_notification_lines)
215253
_append_dashboard_block(compact_lines, execution=execution, separator=separator)
216254
_append_timing_lines(compact_lines, execution=execution, translator=translator)
255+
_append_signal_snapshot_line(compact_lines, execution=execution, translator=translator)
217256
_append_compact_status_lines(
218257
compact_lines,
219258
execution=execution,
@@ -245,6 +284,7 @@ def render_heartbeat_notification(
245284
_append_extra_notification_lines(detailed_lines, extra_notification_lines)
246285
_append_dashboard_block(detailed_lines, execution=execution, separator=separator)
247286
_append_timing_lines(detailed_lines, execution=execution, translator=translator)
287+
_append_signal_snapshot_line(detailed_lines, execution=execution, translator=translator)
248288
detailed_lines.append(separator)
249289
_append_status_lines(
250290
detailed_lines,
@@ -279,6 +319,7 @@ def render_heartbeat_notification(
279319
_append_extra_notification_lines(compact_lines, extra_notification_lines)
280320
_append_dashboard_block(compact_lines, execution=execution, separator=separator)
281321
_append_timing_lines(compact_lines, execution=execution, translator=translator)
322+
_append_signal_snapshot_line(compact_lines, execution=execution, translator=translator)
282323
_append_compact_status_lines(
283324
compact_lines,
284325
execution=execution,

0 commit comments

Comments
 (0)