Skip to content

Commit bdb850a

Browse files
committed
Record dry-run notification delivery evidence
1 parent 984ec3d commit bdb850a

4 files changed

Lines changed: 128 additions & 10 deletions

File tree

application/runtime_composer.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ def send_tg_message(self, message: str) -> None:
9999
)
100100
sender(message)
101101

102-
def build_notification_adapters(self):
102+
def build_notification_adapters(self, *, delivery_events: list[dict[str, Any]] | None = None):
103103
return self.notification_adapter_builder(
104104
with_prefix=self.with_prefix,
105105
send_message=self.send_tg_message,
@@ -109,6 +109,7 @@ def build_notification_adapters(self):
109109
order_poll_max_attempts=self.order_poll_max_attempts,
110110
sleeper=self.sleeper,
111111
log_message=lambda message: self.printer(self.with_prefix(message), flush=True),
112+
delivery_events=delivery_events,
112113
)
113114

114115
def build_reporting_adapters(self):
@@ -152,8 +153,13 @@ def build_reporting_adapters(self):
152153
printer=lambda line: self.printer(line, flush=True),
153154
)
154155

155-
def build_rebalance_runtime(self, *, silent_cycle_notifications: bool = False) -> LongBridgeRebalanceRuntime:
156-
notification_adapters = self.build_notification_adapters()
156+
def build_rebalance_runtime(
157+
self,
158+
*,
159+
silent_cycle_notifications: bool = False,
160+
notification_delivery_events: list[dict[str, Any]] | None = None,
161+
) -> LongBridgeRebalanceRuntime:
162+
notification_adapters = self.build_notification_adapters(delivery_events=notification_delivery_events)
157163
notifications = (
158164
CallableNotificationPort(lambda _message: None)
159165
if silent_cycle_notifications

application/runtime_notification_adapters.py

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

33
from __future__ import annotations
44

5+
import hashlib
56
from collections.abc import Callable
67
from dataclasses import dataclass
78
from typing import Any
@@ -24,6 +25,7 @@ class LongBridgeNotificationAdapters:
2425
notify_issue: Callable[[str, str], None]
2526
post_submit_order: Callable[[Any, Any, Any], None]
2627
cycle_publisher: NotificationPublisher
28+
delivery_events: list[dict[str, Any]]
2729

2830
def publish_cycle_notification(self, *, detailed_text: str, compact_text: str) -> None:
2931
self.cycle_publisher.publish(
@@ -44,18 +46,33 @@ def build_runtime_notification_adapters(
4446
order_poll_max_attempts: int,
4547
sleeper: Callable[[float], None],
4648
log_message: Callable[[str], None] | None = None,
49+
delivery_events: list[dict[str, Any]] | None = None,
4750
) -> LongBridgeNotificationAdapters:
51+
recorded_delivery_events = delivery_events if delivery_events is not None else []
52+
53+
def send_recorded_message(message: str) -> None:
54+
send_message(message)
55+
compact = str(message or "")
56+
recorded_delivery_events.append(
57+
{
58+
"sink": "telegram",
59+
"delivery_status": "sent",
60+
"compact_text_sha256": hashlib.sha256(compact.encode("utf-8")).hexdigest(),
61+
"compact_text_length": len(compact),
62+
}
63+
)
64+
4865
cycle_publisher = NotificationPublisher(
4966
log_message=log_message or (lambda message: print(with_prefix(message), flush=True)),
50-
send_message=send_message,
67+
send_message=send_recorded_message,
5168
)
5269
notify_issue = build_issue_notifier(
5370
with_prefix_fn=with_prefix,
54-
send_tg_message_fn=send_message,
71+
send_tg_message_fn=send_recorded_message,
5572
)
5673
order_event_publisher = NotificationPublisher(
5774
log_message=lambda _message: None,
58-
send_message=send_message,
75+
send_message=send_recorded_message,
5976
)
6077

6178
def publish_order_event(event: OrderLifecycleEvent) -> None:
@@ -81,8 +98,9 @@ def post_submit_order(trade_context, order_intent, report) -> None:
8198
)
8299

83100
return LongBridgeNotificationAdapters(
84-
notification_port=CallableNotificationPort(send_message),
101+
notification_port=CallableNotificationPort(send_recorded_message),
85102
notify_issue=notify_issue,
86103
post_submit_order=post_submit_order,
87104
cycle_publisher=cycle_publisher,
105+
delivery_events=recorded_delivery_events,
88106
)

main.py

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,37 @@ def _summarize_cycle_result_for_report(cycle_result, *, dry_run: bool) -> dict:
153153
return summary
154154

155155

156+
def _build_notification_delivery_log_for_report(
157+
*,
158+
platform: str,
159+
strategy_profile: str,
160+
run_id: str,
161+
dry_run: bool,
162+
orders_previewed_count: int,
163+
delivery_events: list[dict],
164+
) -> dict:
165+
events = [dict(event) for event in delivery_events if dict(event).get("delivery_status") == "sent"]
166+
if not dry_run or orders_previewed_count <= 0 or not events:
167+
return {}
168+
return {
169+
"notification_schema_version": "hk_live_enablement_notification.v1",
170+
"notification_event_type": "hk_snapshot_live_enablement_dry_run",
171+
"notification_correlation_id": str(run_id or ""),
172+
"locales": ["en", "zh-Hans"],
173+
"profile": str(strategy_profile or ""),
174+
"platform": str(platform or ""),
175+
"validation_status": "passed",
176+
"orders_previewed": int(orders_previewed_count),
177+
"delivery_events": events,
178+
"notification_contains_profile": True,
179+
"notification_contains_platform": True,
180+
"notification_contains_validation_status": True,
181+
"notification_contains_order_preview_summary": True,
182+
"notification_redacts_sensitive_fields": True,
183+
"redaction_policy": "raw notification text is not persisted; only sha256 and length are recorded",
184+
}
185+
186+
156187
signal_text = build_signal_text(t)
157188
strategy_display_name = build_strategy_display_name(t)(
158189
STRATEGY_PROFILE,
@@ -455,10 +486,20 @@ def run_strategy(*, force_run: bool = False, validation_only: bool = False, vali
455486
)
456487
if not validation_only:
457488
publish_strategy_plugin_alerts(strategy_plugin_signals, report=report)
458-
cycle_result = run_rebalance_cycle(
459-
runtime=composer.build_rebalance_runtime(
489+
notification_delivery_events: list[dict] = []
490+
try:
491+
rebalance_runtime = composer.build_rebalance_runtime(
460492
silent_cycle_notifications=validation_only,
461-
),
493+
notification_delivery_events=notification_delivery_events,
494+
)
495+
except TypeError as exc:
496+
if "notification_delivery_events" not in str(exc):
497+
raise
498+
rebalance_runtime = composer.build_rebalance_runtime(
499+
silent_cycle_notifications=validation_only,
500+
)
501+
cycle_result = run_rebalance_cycle(
502+
runtime=rebalance_runtime,
462503
config=composer.build_rebalance_config(strategy_plugin_signals=strategy_plugin_signals),
463504
)
464505
signal_snapshot = {}
@@ -469,6 +510,16 @@ def run_strategy(*, force_run: bool = False, validation_only: bool = False, vali
469510
cycle_result,
470511
dry_run=bool(report.get("dry_run")),
471512
)
513+
notification_delivery_log = _build_notification_delivery_log_for_report(
514+
platform="longbridge",
515+
strategy_profile=STRATEGY_PROFILE,
516+
run_id=str(report.get("run_id") or ""),
517+
dry_run=bool(report.get("dry_run")),
518+
orders_previewed_count=int(execution_summary.get("orders_previewed_count") or 0),
519+
delivery_events=notification_delivery_events,
520+
)
521+
if notification_delivery_log:
522+
execution_summary["notification_delivery_log"] = notification_delivery_log
472523
if signal_snapshot:
473524
reporting_adapters.log_event(
474525
log_context,

tests/test_request_handling.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -603,6 +603,49 @@ def test_cycle_result_summary_counts_dry_run_order_previews(self):
603603
self.assertEqual(summary["orders_previewed"][0]["symbol"], "02800.HK")
604604
self.assertEqual(summary["quote_snapshot"]["quotes"][0]["symbol"], "02800.HK")
605605

606+
def test_notification_delivery_log_summary_records_sent_dry_run_without_raw_text(self):
607+
module = load_module()
608+
609+
payload = module._build_notification_delivery_log_for_report(
610+
platform="longbridge",
611+
strategy_profile="hk_low_vol_dividend_quality",
612+
run_id="run-001",
613+
dry_run=True,
614+
orders_previewed_count=2,
615+
delivery_events=[
616+
{
617+
"sink": "telegram",
618+
"delivery_status": "sent",
619+
"compact_text_sha256": "a" * 64,
620+
"compact_text_length": 42,
621+
}
622+
],
623+
)
624+
625+
self.assertEqual(payload["notification_schema_version"], "hk_live_enablement_notification.v1")
626+
self.assertEqual(payload["notification_event_type"], "hk_snapshot_live_enablement_dry_run")
627+
self.assertEqual(payload["notification_correlation_id"], "run-001")
628+
self.assertEqual(payload["locales"], ["en", "zh-Hans"])
629+
self.assertEqual(payload["profile"], "hk_low_vol_dividend_quality")
630+
self.assertEqual(payload["platform"], "longbridge")
631+
self.assertEqual(payload["orders_previewed"], 2)
632+
self.assertTrue(payload["notification_redacts_sensitive_fields"])
633+
self.assertNotIn("compact_text", payload["delivery_events"][0])
634+
635+
def test_notification_delivery_log_summary_stays_empty_without_sent_event(self):
636+
module = load_module()
637+
638+
payload = module._build_notification_delivery_log_for_report(
639+
platform="longbridge",
640+
strategy_profile="hk_low_vol_dividend_quality",
641+
run_id="run-001",
642+
dry_run=True,
643+
orders_previewed_count=2,
644+
delivery_events=[],
645+
)
646+
647+
self.assertEqual(payload, {})
648+
606649

607650
if __name__ == "__main__":
608651
unittest.main()

0 commit comments

Comments
 (0)