diff --git a/.github/workflows/execution-report-heartbeat.yml b/.github/workflows/execution-report-heartbeat.yml index c130199..a991999 100644 --- a/.github/workflows/execution-report-heartbeat.yml +++ b/.github/workflows/execution-report-heartbeat.yml @@ -126,6 +126,14 @@ jobs: RUNTIME_HEARTBEAT_LOOKBACK_HOURS: ${{ inputs.lookback_hours || vars.RUNTIME_HEARTBEAT_LOOKBACK_HOURS || '36' }} RUNTIME_HEARTBEAT_FAIL_WORKFLOW_ON_ALERT: ${{ inputs.fail_workflow_on_alert || vars.RUNTIME_HEARTBEAT_FAIL_WORKFLOW_ON_ALERT || 'true' }} RUNTIME_HEARTBEAT_NOTIFY_ON_SUCCESS: ${{ vars.RUNTIME_HEARTBEAT_NOTIFY_ON_SUCCESS || 'false' }} + LONGBRIDGE_UNIFIED_NOTIFICATION_ENABLED: ${{ vars.LONGBRIDGE_UNIFIED_NOTIFICATION_ENABLED || 'false' }} + DIGEST_CANDIDATES_GCS_URI: ${{ vars.DIGEST_CANDIDATES_GCS_URI || secrets.DIGEST_CANDIDATES_GCS_URI }} + QSL_NOTIFICATION_MARKER_GCS_URI: ${{ vars.QSL_NOTIFICATION_MARKER_GCS_URI || secrets.QSL_NOTIFICATION_MARKER_GCS_URI }} + DIGEST_STORE_TARGET: ${{ vars.DIGEST_STORE_TARGET }} + DIGEST_OPAQUE_ACCOUNT_UID: ${{ secrets.DIGEST_OPAQUE_ACCOUNT_UID }} + DIGEST_TARGET_ID: ${{ secrets.DIGEST_TARGET_ID }} + DIGEST_MATRIX_TARGET_ID: ${{ matrix.target.id }} + DIGEST_MATRIX_ACCOUNT_SCOPE: ${{ matrix.target.label }} RUNTIME_HEARTBEAT_ACCEPT_STATUSES: ${{ vars.RUNTIME_HEARTBEAT_ACCEPT_STATUSES }} RUNTIME_HEARTBEAT_REJECT_STATUSES: ${{ vars.RUNTIME_HEARTBEAT_REJECT_STATUSES }} RUNTIME_HEARTBEAT_MARKET_AWARE: ${{ vars.RUNTIME_HEARTBEAT_MARKET_AWARE || 'true' }} @@ -288,7 +296,7 @@ jobs: - name: Publish existing reports as unified digest candidates - if: ${{ success() && vars.QSL_ROUTINE_NOTIFICATION_MODE == 'daily_digest' }} + if: ${{ (success() && vars.QSL_ROUTINE_NOTIFICATION_MODE == 'daily_digest' && vars.LONGBRIDGE_UNIFIED_NOTIFICATION_ENABLED != 'true') || (!cancelled() && env.INSPECT_SG_PROBE_PERMISSIONS != 'true' && vars.LONGBRIDGE_UNIFIED_NOTIFICATION_ENABLED == 'true') }} env: QSL_ROUTINE_NOTIFICATION_MODE: ${{ vars.QSL_ROUTINE_NOTIFICATION_MODE || 'immediate' }} RUNTIME_TARGET_ENABLED: ${{ vars.RUNTIME_TARGET_ENABLED }} diff --git a/application/account_new_risk_gate_support.py b/application/account_new_risk_gate_support.py index 702a264..a96e175 100644 --- a/application/account_new_risk_gate_support.py +++ b/application/account_new_risk_gate_support.py @@ -386,8 +386,8 @@ def maybe_publish_attention_for_admission( ) -> Mapping[str, int]: """Publish ACTION/HALT attention when NEW_RISK / ops axes require a page. - Dedupes on transition keys within the process. Never grants live, raises RRL, - or invents daily-loss facts. + Legacy uses process-local transition keys; the opted-in notification adapter + uses durable claims. Never grants live, raises RRL, or invents daily-loss facts. """ try: @@ -417,6 +417,12 @@ def maybe_publish_attention_for_admission( mandate_dd_budget=resolve_mandate_dd_budget(profile), ) ) + from notifications.unified import enabled as unified_enabled, publish_attention + if unified_enabled(): + return publish_attention(decision, telegram_sender=telegram_sender, + recover=(not prohibited and not operational_uncertain and snapshot is not None + and str(snapshot.observation_status) == "COMPLETE" + and str(snapshot.reconciliation_status) == "VERIFIED")) return publish_attention_telegram_transition( decision=decision, platform=_ATTENTION_PLATFORM, diff --git a/application/rebalance_service.py b/application/rebalance_service.py index b9f45ab..6a7e424 100644 --- a/application/rebalance_service.py +++ b/application/rebalance_service.py @@ -1237,7 +1237,13 @@ def submit_claimed_order(order_intent): if isinstance(position_currency_by_symbol, dict): execution["position_currency_by_symbol"] = dict(position_currency_by_symbol) - routine_to_digest = _defer_cycle_notification(execution_result, config=config) + from notifications.unified import enabled as unified_enabled, prepare_cycle + if unified_enabled(): + routine_to_digest, notification_publisher = prepare_cycle( + execution_result, config=config, publisher=notification_publisher, notify_issue=runtime.notify_issue, + ) + else: + routine_to_digest = _defer_cycle_notification(execution_result, config=config) if routine_to_digest: print(config.with_prefix("notification_suppressed reason=daily_digest"), flush=True) elif pending_orders: diff --git a/application/runtime_notification_adapters.py b/application/runtime_notification_adapters.py index fbce4f7..e150227 100644 --- a/application/runtime_notification_adapters.py +++ b/application/runtime_notification_adapters.py @@ -12,6 +12,7 @@ OrderLifecycleEvent, monitor_submitted_order_status, publish_order_lifecycle_event, + is_filled_status, is_partial_filled_status, is_terminal_error_status, ) from notifications.telegram import build_issue_notifier from quant_platform_kit.common.quantity import format_quantity @@ -89,23 +90,59 @@ def send_recorded_message(message: str) -> bool: log_message=log_message or (lambda message: print(with_prefix(message), flush=True)), send_message=send_recorded_message, ) - notify_issue = build_issue_notifier( + legacy_notify_issue = build_issue_notifier( with_prefix_fn=with_prefix, send_tg_message_fn=send_recorded_message, ) + + def issue_with_ack(title, detail): + before = len(recorded_delivery_events) + legacy_notify_issue(title, detail) + events = recorded_delivery_events[before:] + return bool(events) and all(event.get("transport_acknowledged") is True for event in events) + + def notify_issue(title, detail): + from notifications import unified + if not unified.enabled(): + return legacy_notify_issue(title, detail) + # Existing callers provide fixed issue titles. Hash the category, never + # provider detail text, run ids or credentials into a marker identity. + reason = hashlib.sha256(str(title).encode()).hexdigest() + return unified.dispatch_immediate(lambda: issue_with_ack(title, detail), + kind="real_error", incident=f"runtime_issue:{reason}") order_event_publisher = NotificationPublisher( log_message=lambda _message: None, send_message=send_recorded_message, ) def publish_order_event(event: OrderLifecycleEvent) -> None: - publish_order_lifecycle_event( - event, - translator=translator, - publisher=order_event_publisher, - ) + from notifications import unified + def publish(): + before = len(recorded_delivery_events) + outcome = publish_order_lifecycle_event(event, translator=translator, publisher=order_event_publisher) + if not unified.enabled(): + return outcome + events = recorded_delivery_events[before:] + return bool(events) and all(item.get("transport_acknowledged") is True for item in events) + if unified.enabled() and not (is_filled_status(event.status) or is_partial_filled_status(event.status)): + from notifications.digest_snapshot.unified_contract import order_identity_digest + unified.dispatch_immediate(publish, kind="order_status_unknown", + incident=f"order_status:{order_identity_digest(event.order_id)}", state=event.status, + severity=2 if is_terminal_error_status(event.status) else 1) + else: + if unified.enabled() and is_filled_status(event.status): + unified.record_order_resolution(event) + publish() def post_submit_order(trade_context, order_intent, report) -> None: + from notifications import unified + issue_notifier = legacy_notify_issue + if unified.enabled(): + from notifications.digest_snapshot.unified_contract import order_identity_digest + if report.broker_order_id: + incident = f"order_status_poll:{order_identity_digest(report.broker_order_id)}" + def issue_notifier(title, detail): + return unified.dispatch_immediate(lambda: issue_with_ack(title, detail), kind="real_error", incident=incident) monitor_submitted_order_status( trade_context, str(order_intent.symbol), @@ -116,7 +153,7 @@ def post_submit_order(trade_context, order_intent, report) -> None: order_poll_interval_sec=order_poll_interval_sec, order_poll_max_attempts=order_poll_max_attempts, publish_order_event=publish_order_event, - notify_issue=notify_issue, + notify_issue=issue_notifier, sleeper=sleeper, ) diff --git a/main.py b/main.py index bbc5d08..2d8bb5a 100644 --- a/main.py +++ b/main.py @@ -8,7 +8,7 @@ import importlib import os import time -from datetime import datetime +from datetime import datetime, timezone from flask import Flask, request @@ -54,6 +54,7 @@ from runtime_execution_policy import dca_execution_unsupported_reason from runtime_config_support import load_platform_runtime_settings from notifications.telegram import build_signal_text, build_strategy_display_name, build_translator +from notifications import unified as unified_notifications from quant_platform_kit.common.runtime_reports import ( append_runtime_report_error, build_runtime_report_base, @@ -427,7 +428,9 @@ def _summarize_cycle_result_for_report(cycle_result, *, dry_run: bool) -> dict: if isinstance(account_identity, dict): summary["account_identity"] = dict(account_identity) from notifications.digest_snapshot.digest_routing import daily_digest_enabled - if daily_digest_enabled(): + if unified_notifications.enabled(): + _, summary["notification_digest"] = unified_notifications.cycle_facts(cycle_result, dry_run=dry_run) + elif daily_digest_enabled(): from notifications.digest_snapshot.digest_candidates import cycle_evidence evidence = {**dict(getattr(cycle_result, "allocation", {}) or {}), **execution} evidence.setdefault("execution_status", "completed" if broker_submission_done else "no_action") @@ -659,7 +662,7 @@ def _runtime_error_notification_message(exc: Exception, *, route_label: str) -> )) -def _notify_runtime_error(exc: Exception, *, route_label: str) -> bool: +def _send_runtime_error(exc: Exception, *, route_label: str) -> bool: targets = _runtime_error_notification_targets() if not targets: print(t("runtime_notification_missing_target"), flush=True) @@ -689,6 +692,14 @@ def _notify_runtime_error(exc: Exception, *, route_label: str) -> bool: return bool(outcomes) and all(outcomes) +def _notify_runtime_error(exc: Exception, *, route_label: str) -> bool: + return unified_notifications.dispatch_immediate( + lambda: _send_runtime_error(exc, route_label=route_label), kind="real_error", + incident=f"runtime_route:{route_label}:{_runtime_failure_reason(exc)}", + occurred_at=datetime.now(timezone.utc), run_id=build_run_id(), + ) if unified_notifications.enabled() else _send_runtime_error(exc, route_label=route_label) + + def _handle_route_runtime_error(exc: Exception, *, route_label: str): print(t("runtime_failure_log"), flush=True) _notify_runtime_error(exc, route_label=route_label) @@ -697,7 +708,11 @@ def _handle_route_runtime_error(exc: Exception, *, route_label: str): def _route_with_runtime_error_fallback(handler, *, success_body: str, route_label: str, **kwargs): try: - result = handler(**kwargs) + if unified_notifications.enabled(): + with unified_notifications.bind_route(route_label): + result = handler(**kwargs) + else: + result = handler(**kwargs) except Exception as exc: return _handle_route_runtime_error(exc, route_label=route_label) if result is False: @@ -876,7 +891,11 @@ def run_strategy(*, force_run: bool = False, validation_only: bool = False, vali live_execution_session_authorized=bool(market_open and not validation_only), ) failure_phase = "strategy_cycle" - cycle_result = run_rebalance_cycle(runtime=rebalance_runtime, config=rebalance_config) + if unified_notifications.enabled(): + with unified_notifications.bind_report(report): + cycle_result = run_rebalance_cycle(runtime=rebalance_runtime, config=rebalance_config) + else: + cycle_result = run_rebalance_cycle(runtime=rebalance_runtime, config=rebalance_config) failure_phase = "reporting" signal_snapshot = {} if cycle_result is not None: @@ -886,6 +905,12 @@ def run_strategy(*, force_run: bool = False, validation_only: bool = False, vali cycle_result, dry_run=bool(report.get("dry_run")), ) + if unified_notifications.enabled(): + try: + unified_notifications.attach_cycle_event(execution_summary, cycle_result, report=report, config=rebalance_config) + except Exception: + print("notification_event_attachment_unconfirmed", flush=True) + unified_notifications.record_failure("notification_event_attachment_unconfirmed", report=report) notification_delivery_log = _build_notification_delivery_log_for_report( platform="longbridge", strategy_profile=STRATEGY_PROFILE, @@ -896,6 +921,11 @@ def run_strategy(*, force_run: bool = False, validation_only: bool = False, vali ) if notification_delivery_log: execution_summary["notification_delivery_log"] = notification_delivery_log + if unified_notifications.enabled(): + notification_delivery_events.extend( + {"sink": "notification_store", "delivery_status": "failed", "transport_acknowledged": False, + "error_type": code} for code in report.get("notification_unified_errors", ()) + ) notification_delivery_summary = _build_notification_delivery_summary( notification_delivery_events ) @@ -948,10 +978,13 @@ def run_strategy(*, force_run: bool = False, validation_only: bool = False, vali ) if not (validation_only and _v7_application_candidate_bound()): try: - notification_adapters.publish_cycle_notification( - detailed_text=_compact_error_notification(exc, phase=failure_phase), - compact_text=_compact_error_notification(exc, phase=failure_phase), - ) + def publish_failure(): + return notification_adapters.publish_cycle_notification( + detailed_text=_compact_error_notification(exc, phase=failure_phase), + compact_text=_compact_error_notification(exc, phase=failure_phase), + ) + unified_notifications.dispatch_immediate(publish_failure, kind="real_error", + incident=f"runtime_failure:{failure_phase}:{failure_reason}", report=report, actually_ran=True) except Exception as notification_exc: notification_delivery_events.append( { @@ -995,6 +1028,8 @@ def run_strategy(*, force_run: bool = False, validation_only: bool = False, vali try: report_path = reporting_adapters.persist_execution_report(report) print(f"execution_report {report_path}", flush=True) + if unified_notifications.enabled(): + unified_notifications.recover_runtime(report=report) except Exception as persist_exc: print(f"failed to persist execution report: {type(persist_exc).__name__}", flush=True) @@ -1122,15 +1157,15 @@ def run_probe(*, response_body: str = "Probe OK"): exc, title=t("health_probe_title"), prefix=t("health_probe_error_prefix"), phase="health_probe", ) if composer is not None: - composer.build_notification_adapters().publish_cycle_notification( - detailed_text=err, - compact_text=_compact_error_notification( - exc, - title=t("health_probe_title"), - prefix=t("health_probe_error_prefix"), - phase="health_probe", - ), - ) + def publish_probe_failure(): + return composer.build_notification_adapters().publish_cycle_notification( + detailed_text=err, + compact_text=_compact_error_notification( + exc, title=t("health_probe_title"), prefix=t("health_probe_error_prefix"), phase="health_probe", + ), + ) + unified_notifications.dispatch_immediate(publish_probe_failure, kind="real_error", + incident="runtime_failure:health_probe", report=report) else: print(err, flush=True) return "Error", 500 @@ -1141,6 +1176,8 @@ def run_probe(*, response_body: str = "Probe OK"): if reporting_adapters is not None and report is not None: report_path = reporting_adapters.persist_execution_report(report) print(f"execution_report {report_path}", flush=True) + if unified_notifications.enabled(): + unified_notifications.recover_runtime(report=report, probe=True) except Exception as persist_exc: print(f"failed to persist execution report: {type(persist_exc).__name__}", flush=True) diff --git a/notifications/digest_snapshot/__init__.py b/notifications/digest_snapshot/__init__.py index 63deddf..02b01a3 100644 --- a/notifications/digest_snapshot/__init__.py +++ b/notifications/digest_snapshot/__init__.py @@ -1 +1,4 @@ -"""Notification-only QPK source snapshot; trading dependency pins stay unchanged.""" +"""Notification-only snapshot from merged QPK; trading pins stay unchanged.""" + +UNIFIED_CONTRACT_QPK_SHA = "07e24c0c804a9b47316b6ec3c75bb63820d8f421" +UNIFIED_CONTRACT_SHA256 = "7ba48d1d7c74e66be8cd3ff983c2117002776e82cda6675a084cfe0dc51aecc0" diff --git a/notifications/digest_snapshot/unified_contract.py b/notifications/digest_snapshot/unified_contract.py new file mode 100644 index 0000000..94ec7ed --- /dev/null +++ b/notifications/digest_snapshot/unified_contract.py @@ -0,0 +1,284 @@ +"""Opt-in notification facts and routing; no broker, transport, or storage IO. + +Consumers inject their existing candidate producer and durable marker storage. +This module neither changes receipt acceptance nor replaces the central daily +recipient/report-date guard. Unknown evidence is never converted to zero. +""" +from __future__ import annotations + +import hashlib +import json +from collections.abc import Callable, Mapping +from dataclasses import asdict, dataclass +from datetime import date, datetime, time, timedelta +from typing import Literal, Protocol +from zoneinfo import ZoneInfo + +from .digest_routing import daily_digest_enabled + +Route = Literal["immediate", "digest_candidate", "legacy"] +EVENT_KINDS = frozenset({ + "routine_cycle", "healthy_heartbeat", "order_submitted", "fill", + "receipt_missing", "receipt_stale", "receipt_unacceptable", "auth_failure", + "order_status_unknown", "risk_escalation", "real_error", "blocked_run", + "expected_block_observed", +}) +ROUTINE_EVENT_KINDS = frozenset({"routine_cycle", "healthy_heartbeat"}) +ORDER_EVENT_KINDS = frozenset({"order_submitted", "fill"}) +SGT = ZoneInfo("Asia/Singapore") + + +def unified_notification_enabled(env: Mapping[str, object], key: str) -> bool: + """Read a release flag: only boolean True or the string 'true' enables it.""" + value = env.get(key) + return value is True or isinstance(value, str) and value.strip().lower() == "true" + + +def effective_notification_mode(env: Mapping[str, object], flag_key: str) -> str: + """Map the release boolean onto the existing single routing mode.""" + if unified_notification_enabled(env, flag_key): + return "daily_digest" + return str(env.get("QSL_ROUTINE_NOTIFICATION_MODE") or "immediate") + + +def select_notification_route(*, event_kind: str, env: Mapping[str, object], flag_key: str) -> Route: + """OFF delegates untouched to legacy. Unknown/blocked/urgent stays immediate. + + The new contract defaults routine orders to immediate. Legacy routing's + historical order default is deliberately not changed by this API. + """ + if not unified_notification_enabled(env, flag_key): + return "legacy" + if daily_digest_enabled(effective_notification_mode(env, flag_key)): + if event_kind in ROUTINE_EVENT_KINDS: + return "digest_candidate" + if event_kind in ORDER_EVENT_KINDS and unified_notification_enabled(env, "QSL_DIGEST_ROUTINE_ORDERS"): + return "digest_candidate" + return "immediate" + + +@dataclass(frozen=True) +class BusinessWindow: + start: datetime + end: datetime + business_day: date + report_date: date + window_id: str + + +def sgt_business_window(occurred_at: datetime) -> BusinessWindow: + """Containing half-open [D 06:00, D+1 06:00) window; not a send guard.""" + if occurred_at.tzinfo is None or occurred_at.utcoffset() is None: + raise ValueError("notification_time_timezone_missing") + day = (occurred_at.astimezone(SGT) - timedelta(hours=6)).date() + start = datetime.combine(day, time(6), SGT) + end = start + timedelta(days=1) + return BusinessWindow(start, end, day, end.date(), f"sgt-0600:{day.isoformat()}") + + +def _digest(parts: list[object]) -> str: + return hashlib.sha256(json.dumps(parts, ensure_ascii=False, separators=(",", ":")).encode()).hexdigest() + + +def order_identity_digest(order_or_execution_id: str) -> str: + """Irreversible, domain-separated identity; never put raw broker IDs in keys.""" + if not isinstance(order_or_execution_id, str) or not order_or_execution_id.strip(): + raise ValueError("notification_order_identity_missing") + return _digest(["qsl.notification_order.v1", order_or_execution_id]) + + +@dataclass(frozen=True) +class NotificationEvent: + event_kind: str + occurred_at: datetime + platform_id: str | None = None + opaque_account_uid: str | None = None + target_id: str | None = None + strategy_profile: str | None = None + source_run_id: str | None = None + order_execution_digest: str | None = None + incident_reason: str | None = None + order_count: int | None = None + fill_count: int | None = None + expected_block_evidence: str | None = None + + def __post_init__(self) -> None: + sgt_business_window(self.occurred_at) + for count in (self.order_count, self.fill_count): + if count is not None and (type(count) is not int or count < 0): + raise ValueError("notification_count_invalid") + digest = self.order_execution_digest + if digest is not None and (len(digest) != 64 or any(c not in "0123456789abcdef" for c in digest)): + raise ValueError("notification_order_digest_invalid") + if self.event_kind == "expected_block_observed" and not _identity_present(self.expected_block_evidence): + raise ValueError("notification_expected_block_evidence_missing") + + @property + def coverage_status(self) -> str: + return "known" if routine_dedupe_key(self) is not None else "unknown" + + def to_candidate(self) -> dict[str, object]: + """Private producer fact, to be projected with existing digest_candidates. + + This event envelope is not a replacement for qsl.digest_candidates.v1; + the existing archive projector remains responsible for window counts. + """ + result = asdict(self) + window = sgt_business_window(self.occurred_at) + result.update( + schema_version="qsl.notification_event.v1", occurred_at=self.occurred_at.isoformat(), + business_day=window.business_day.isoformat(), report_date=window.report_date.isoformat(), + window_id=window.window_id, dedupe_key=routine_dedupe_key(self), + coverage_status=self.coverage_status, + ) + if self.coverage_status == "unknown": + result.update(order_count=None, fill_count=None) + return result + + +def _identity_present(value: object) -> bool: + return isinstance(value, str) and bool(value.strip()) + + +def routine_dedupe_key(event: NotificationEvent) -> str | None: + parts = [event.platform_id, event.opaque_account_uid, event.target_id, + event.strategy_profile, event.source_run_id, event.event_kind] + if event.event_kind in ORDER_EVENT_KINDS: + parts.append(event.order_execution_digest) + return _digest(["qsl.notification_routine.v1", *parts]) if all(map(_identity_present, parts)) else None + + +class CandidatePersistenceError(RuntimeError): + pass + + +@dataclass(frozen=True) +class RoutingResult: + route: Route + candidate_persisted: bool + result: object = None + + +def route_notification_event( + event: NotificationEvent, *, env: Mapping[str, object], flag_key: str, + persist_candidate: Callable[[dict[str, object]], bool], + legacy: Callable[[], object], immediate: Callable[[], object], +) -> RoutingResult: + """Persist before deferral. Alert delivery survives candidate-write failure. + + The immediate callback owns durable incident reservation and receipt ACK. + Callers must inspect candidate_persisted; deferral is never delivery. + """ + route = select_notification_route(event_kind=event.event_kind, env=env, flag_key=flag_key) + if route == "legacy": + return RoutingResult(route, False, legacy()) + if route == "digest_candidate": + if event.coverage_status != "known": + raise CandidatePersistenceError("notification_candidate_identity_unknown") + if persist_candidate(event.to_candidate()) is not True: + raise CandidatePersistenceError("notification_candidate_write_unconfirmed") + return RoutingResult(route, True) + try: + persisted = persist_candidate(event.to_candidate()) is True + except Exception: + # A routine evidence-store failure cannot suppress a real alert. + persisted = False + return RoutingResult(route, persisted, immediate()) + + +class IncidentMarkerStore(Protocol): + """Durable JSON state, with atomic compare-and-set across workers. + + Missing key -> (None, None); read failures MUST raise, not act like missing. + A version is an opaque storage generation. No non-atomic has/write adapter + is provided: existing alert_marker has no atomic claim or readable receipt. + """ + def read(self, key: str) -> tuple[object, Mapping[str, object] | None]: ... + + def compare_and_set(self, key: str, version: object, value: Mapping[str, object]) -> bool: ... + + +@dataclass(frozen=True) +class IncidentDecision: + action: Literal["send", "duplicate", "recovered", "coverage_unknown"] + incident_key: str | None = None + transition_key: str | None = None + + +class IncidentTracker: + """Reserve before sending; pending/uncertain is never blindly resent. + + The caller supplies ordered observations and explicit recovery evidence. + Merely seeing a healthy label elsewhere is not proof of recovery. Severity + is a caller-owned nonnegative rank; a rise is a new escalation. Storage + errors propagate; a process-local set is never a production fallback. + """ + def __init__(self, store: IncidentMarkerStore): + self.store = store + + def _read_marker(self, key: str) -> tuple[object, dict[str, object] | None]: + version, value = self.store.read(key) + if version is None and value is None: + return version, None + required = {"schema_version", "active", "state", "severity", "generation", + "transition_key", "delivery_status", "receipt_id"} + if (version is None or not isinstance(value, Mapping) or not required.issubset(value) + or value["schema_version"] != "qsl.notification_incident.v1" + or type(value["active"]) is not bool + or not _identity_present(value["state"]) + or any(type(value[k]) is not int or value[k] < 0 for k in ("severity", "generation")) + or value["delivery_status"] not in {"not_attempted", "pending", "uncertain", "delivered"} + or (value["generation"] > 0 and not _identity_present(value["transition_key"]))): + raise RuntimeError("notification_marker_history_incomplete") + return version, dict(value) + + def observe(self, event: NotificationEvent, *, state: str, severity: int = 1, + recovered: bool = False) -> IncidentDecision: + parts = [event.platform_id, event.target_id, event.opaque_account_uid, + event.strategy_profile, event.incident_reason] + if not all(map(_identity_present, parts)): + return IncidentDecision("coverage_unknown") + if not _identity_present(state) or type(severity) is not int or severity < 0: + raise ValueError("notification_incident_state_invalid") + key = _digest(["qsl.notification_incident.v1", *parts]) + for _ in range(8): + version, previous = self._read_marker(key) + previous = previous or { + "schema_version": "qsl.notification_incident.v1", "active": False, + "state": None, "severity": 0, "generation": 0, + "transition_key": None, "delivery_status": "not_attempted", "receipt_id": None, + } + active = previous.get("active") is True + generation = int(previous.get("generation", 0)) + transition = previous.get("transition_key") + if recovered: + value = {**previous, "active": False, "state": state, "severity": severity} + action = "recovered" + elif active and severity <= int(previous["severity"]): + # A downgrade updates observation state without sending. Later + # severity-up remains visible, even before full recovery. + value = {**previous, "state": state, "severity": severity} + action = "duplicate" + else: + generation += 1 + transition = _digest([key, generation, previous.get("state"), state, severity]) + value = {"schema_version": "qsl.notification_incident.v1", + "active": True, "state": state, "severity": severity, + "generation": generation, "transition_key": transition, + "delivery_status": "pending", "receipt_id": None} + action = "send" + if value == previous or self.store.compare_and_set(key, version, value): + return IncidentDecision(action, key, transition) + raise RuntimeError("notification_marker_concurrent_update") + + def record_delivery(self, decision: IncidentDecision, *, receipt_id: str | None = None) -> None: + """Only an explicit receipt is delivered; absent receipt stays uncertain.""" + if decision.action != "send" or not decision.incident_key: + raise ValueError("notification_delivery_without_reservation") + version, previous = self._read_marker(decision.incident_key) + if not previous or previous.get("transition_key") != decision.transition_key: + raise RuntimeError("notification_marker_transition_changed") + value = {**previous, "delivery_status": "delivered" if _identity_present(receipt_id) else "uncertain", + "receipt_id": receipt_id if _identity_present(receipt_id) else None} + if not self.store.compare_and_set(decision.incident_key, version, value): + raise RuntimeError("notification_marker_concurrent_update") diff --git a/notifications/unified.py b/notifications/unified.py new file mode 100644 index 0000000..3cd8515 --- /dev/null +++ b/notifications/unified.py @@ -0,0 +1,682 @@ +"""LongBridge adapter for opt-in, target-bound notification candidates. + +Only notification facts and markers belong here. No broker, execution-state, +schedule, risk-admission or runtime enablement mutations are permitted. +""" +from __future__ import annotations + +import json +import os +import hashlib +import re +from dataclasses import replace +from datetime import datetime, timezone +from collections.abc import Mapping +from contextlib import contextmanager +from contextvars import ContextVar + +from .digest_snapshot.unified_contract import ( + NotificationEvent, IncidentTracker, IncidentDecision, CandidatePersistenceError, + effective_notification_mode, unified_notification_enabled, route_notification_event, +) +from .unified_store import GCSNotificationStore + + +FLAG = "LONGBRIDGE_UNIFIED_NOTIFICATION_ENABLED" +_TARGET_KEYS = ("platform_id", "strategy_profile", "service_name", "account_scope", + "account_selector", "deployment_selector", "dry_run_only") +_REPORT = ContextVar("longbridge_notification_report", default=None) +_ROUTE = ContextVar("longbridge_notification_route", default=None) + + +@contextmanager +def bind_report(report): + token = _REPORT.set(report) + try: + yield + finally: + _REPORT.reset(token) + + +@contextmanager +def bind_route(route): + token = _ROUTE.set(route) + try: + yield + finally: + _ROUTE.reset(token) + + +def enabled(env=None): + env = os.environ if env is None else env + return unified_notification_enabled(env, FLAG) + + +def routing_env(env=None): + env = os.environ if env is None else env + if not enabled(env): + return env + # The release flag selects the existing single routing mode. It does not + # mutate the process environment or authorize any runtime target. + return {**env, "QSL_ROUTINE_NOTIFICATION_MODE": effective_notification_mode(env, FLAG), + "QSL_DIGEST_ROUTINE_ORDERS": "false"} + + +def target_binding(env=None, *, report=None): + env = os.environ if env is None else env + if env.get("RUNTIME_TARGET_ENABLED") != "true": + raise ValueError("notification_target_not_enabled") + target = json.loads(env.get("RUNTIME_TARGET_JSON") or "{}") + if not isinstance(target, dict) or any(key not in target for key in _TARGET_KEYS): + raise ValueError("notification_target_binding_missing") + for key in _TARGET_KEYS[:-1]: + if not target[key]: + raise ValueError("notification_target_binding_missing") + if (type(target["dry_run_only"]) is not bool + or not isinstance(target["account_selector"], list) + or any(not isinstance(value, str) or not value.strip() for value in target["account_selector"]) + or any(not isinstance(target[key], str) or not target[key].strip() for key in _TARGET_KEYS[:-1] if key != "account_selector")): + raise ValueError("notification_target_binding_missing") + if target.get("runtime_target_enabled") is False: + raise ValueError("notification_target_not_enabled") + if report is not None: + source = report.get("runtime_target") + if not isinstance(source, Mapping) or any(source.get(key) != target[key] for key in _TARGET_KEYS): + raise ValueError("notification_source_binding_mismatch") + if (report.get("platform") != target["platform_id"] + or report.get("strategy_profile") != target["strategy_profile"] + or report.get("service_name") != target["service_name"] + or report.get("dry_run") is not target["dry_run_only"]): + raise ValueError("notification_source_binding_mismatch") + target_id = str(env.get("DIGEST_TARGET_ID") or "").strip() + account_uid = str(env.get("DIGEST_OPAQUE_ACCOUNT_UID") or "").strip() + matrix_id = str(env.get("DIGEST_MATRIX_TARGET_ID") or "").strip() + matrix_scope = str(env.get("DIGEST_MATRIX_ACCOUNT_SCOPE") or "").strip() + if not target_id or not account_uid: + raise ValueError("notification_identity_unknown") + if (matrix_id and target_id != matrix_id) or (matrix_scope and target["account_scope"] != matrix_scope): + raise ValueError("notification_matrix_binding_mismatch") + return {"platform_id": target["platform_id"], "strategy_profile": target["strategy_profile"], + "target_id": target_id, "opaque_account_uid": account_uid}, target + + +def _stamp(value): + stamp = value if isinstance(value, datetime) else datetime.fromisoformat(str(value).replace("Z", "+00:00")) + if stamp.tzinfo is None: + raise ValueError("notification_time_timezone_missing") + return stamp.astimezone(timezone.utc) + + +def make_event(kind, *, env=None, report=None, incident=None, occurred_at=None, run_id=None, **counts): + env = os.environ if env is None else env + report = report if report is not None else _REPORT.get() + identity, _ = target_binding(env, report=report) + source_run = run_id or (report or {}).get("run_id") + stamp = occurred_at or (report or {}).get("started_at") + if not source_run or not stamp: + raise ValueError("notification_source_run_unknown") + return NotificationEvent(kind, _stamp(stamp), **identity, source_run_id=str(source_run), + incident_reason=incident, **counts) + + +def _hash(value): + return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(",", ":")).encode()).hexdigest() + + +def _export_projection(store, state_store, key, state): + version, previous = store.read(key) + _, latest = state_store.read(f"candidate_state/{key}") + if latest != state: + raise CandidatePersistenceError("notification_candidate_concurrent_update") + allowed = {"platform_id", "strategy_profile", "opaque_account_uid", "target_id", "business_day", "actually_ran", + "as_of", "status", "execution_status", "order_count", "fill_count", "cycle_count", "field_status", + "evidence_provenance", "reason_code", "execution_mode", "source_run_id", "notification_events", + "scheduled_idle"} + rows = [{key: value for key, value in row.items() if key in allowed} for row in state["runs"]] + for row in rows: + row.update(evidence_persistence_status="persisted", evidence_delivery_status="pending_central") + payload = {"schema_version": "qsl.digest_candidates.v1", "producer_status": "projected", "runs": rows} + if previous == payload: + return True + if not store.compare_and_set(key, version, payload): + raise CandidatePersistenceError("notification_candidate_concurrent_update") + return True + + +def persist_candidate(event, *, env=None, actually_ran=True, dry_run=False, preview_count=None, store=None, state_store=None): + """Bridge private event facts into the existing per-target candidate object. + + Merge by source run, never by evidence-source count. Runtime and archive + producers share the same run identity and must agree on its facts. + """ + env = os.environ if env is None else env + identity, target = target_binding(env) + dry_run = target["dry_run_only"] + if any(event.get(key) != value for key, value in identity.items()) or event.get("coverage_status") != "known": + raise CandidatePersistenceError("notification_candidate_identity_unknown") + slot = str(env.get("DIGEST_STORE_TARGET") or "") + if not re.fullmatch(r"[a-z0-9][a-z0-9_-]{0,40}", slot): + raise ValueError("candidate_store_identity_missing") + injected_store = store + store = store or GCSNotificationStore(env.get("DIGEST_CANDIDATES_GCS_URI", "")) + state_store = state_store or injected_store or GCSNotificationStore(env.get("QSL_NOTIFICATION_MARKER_GCS_URI", "")) + key = f"{identity['platform_id']}/{event['business_day']}/{slot}" + version, previous = state_store.read(f"candidate_state/{key}") + previous = previous or {} + if not previous and store.read(key)[1] is not None: + raise ValueError("notification_candidate_history_unknown") + scope_key = _hash(identity) + if previous and previous.get("notification_scope_key") != scope_key: + # An old-format or differently bound object cannot be silently replaced. + raise ValueError("notification_candidate_scope_conflict") + events = dict(previous.get("notification_events") or {}) + fact_key = event["dedupe_key"] + conflicts = list(previous.get("notification_conflicts") or []) + if fact_key in events and events[fact_key] != event: + # Preserve different observations without interpreting event counters + # as whole-run counters. Archive run facts below own those counters. + fact_key = f"{fact_key}-{_hash(event)}" + events[fact_key] = event + sources = dict(previous.get("notification_source_runs") or {}) + if actually_ran: + source = {"order_count": event["order_count"], "fill_count": event["fill_count"], + "dry_run": dry_run, "preview_count": preview_count, + "as_of": event["occurred_at"], "event_kind": event["event_kind"]} + run_id = event["source_run_id"] + old_source = sources.get(run_id, {}) + for name in ("order_count", "fill_count", "preview_count"): + old, observed = old_source.get(name), source[name] + if old is not None and observed is not None and old != observed: + conflict = {"source_run_id": run_id, "field": name, "previous": old, "observed": observed} + if conflict not in conflicts: + conflicts.append(conflict) + if old is not None: + source[name] = old + sources[run_id] = source + rows = [] + for run_id, source in sorted(sources.items()): + run_events = [item for item in events.values() if item["source_run_id"] == run_id] + run_conflicts = [item for item in conflicts if item["source_run_id"] == run_id] + counts = {name: source[name] if not any(item.get("field", name) == name for item in run_conflicts) else None + for name in ("order_count", "fill_count", "preview_count")} + urgent = bool(run_conflicts) or any(item["event_kind"] not in { + "routine_cycle", "healthy_heartbeat", "order_submitted", "fill"} for item in run_events) + row = {**identity, "source_run_id": run_id, "business_day": event["business_day"], + "actually_ran": True, "as_of": source["as_of"], + "status": "alert" if urgent else "ok", "execution_status": "execution_alert" if urgent else + "executed" if counts["order_count"] else "ok_no_order", + **counts, "cycle_count": 1, "dry_run": dry_run, + "execution_mode": "dry_run" if dry_run else "live", + "field_status": {name: "known" if counts[name] is not None else "unknown" + for name in ("order_count", "fill_count")}, + "evidence_provenance": "candidates", "persistence_status": "persisted", "delivery_status": "pending_central", + "notification_events": run_events} + row["field_status"]["cycle_count"] = "known" + if not urgent and row["order_count"] is None and not dry_run: + row["execution_status"] = "execution_unknown" + if urgent: + row["reason_code"] = "source_cycle_alert" + rows.append(row) + payload = {"schema_version": "qsl.digest_candidates.v1", "producer_status": "projected", "runs": rows, + "notification_scope_key": scope_key, "notification_events": events, "notification_source_runs": sources, + "notification_conflicts": conflicts} + if payload != previous and not state_store.compare_and_set(f"candidate_state/{key}", version, payload): + # No retries after an uncertain write; an explicit CAS conflict is visible. + raise CandidatePersistenceError("notification_candidate_concurrent_update") + return _export_projection(store, state_store, key, payload) + + +class _StaleObservation(RuntimeError): + pass + + +class _OrderedStore: + def __init__(self, store, stamp): + self.store, self.stamp = store, stamp + + def read(self, key): + version, value = self.store.read(key) + if value and value.get("observed_at") and _stamp(value["observed_at"]) > self.stamp: + raise _StaleObservation("notification_observation_out_of_order") + return version, value + + def compare_and_set(self, key, version, value): + return self.store.compare_and_set(key, version, {**value, "observed_at": self.stamp.isoformat()}) + + def advance(self, key): + version, value = self.read(key) + if value and value.get("observed_at") != self.stamp.isoformat(): + if not self.compare_and_set(key, version, value): + raise RuntimeError("notification_marker_concurrent_update") + + +def observe_incident(event, *, state, severity=1, recovered=False, env=None, store=None): + env = os.environ if env is None else env + store = store or GCSNotificationStore(env.get("QSL_NOTIFICATION_MARKER_GCS_URI", "")) + ordered = _OrderedStore(store, event.occurred_at) + try: + decision = IncidentTracker(ordered).observe(event, state=state, severity=severity, recovered=recovered) + if decision.incident_key: + ordered.advance(decision.incident_key) + group = next((prefix for prefix in ("cycle_attention:", "new_risk_attention:", "runtime_issue:") + if str(event.incident_reason or "").startswith(prefix)), None) + if not recovered and group: + identity = [event.platform_id, event.target_id, event.opaque_account_uid, event.strategy_profile] + index_key = f"incident_groups/{_hash([*identity, group])}" + version, index = store.read(index_key) + reasons = dict((index or {}).get("reasons") or {}) + if reasons.get(event.incident_reason) != event.source_run_id: + reasons[event.incident_reason] = event.source_run_id + if not store.compare_and_set(index_key, version, {"reasons": reasons}): + raise RuntimeError("notification_marker_concurrent_update") + return decision, IncidentTracker(store) + except _StaleObservation: + return IncidentDecision("duplicate"), IncidentTracker(store) + + +def recover_incident_group(event, group, *, env=None, store=None, skip_current_run=False): + env = os.environ if env is None else env + store = store or GCSNotificationStore(env.get("QSL_NOTIFICATION_MARKER_GCS_URI", "")) + identity = [event.platform_id, event.target_id, event.opaque_account_uid, event.strategy_profile] + _, index = store.read(f"incident_groups/{_hash([*identity, group])}") + for reason, source_run in (index or {}).get("reasons", {}).items(): + if skip_current_run and source_run == event.source_run_id: + continue + observe_incident(replace(event, incident_reason=reason), state="healthy", recovered=True, severity=0, env=env, store=store) + + +def recover_cycle_incidents(event, *, env=None, store=None): + return recover_incident_group(event, "cycle_attention:", env=env, store=store) + + +def send_incident(event, send, *, state=None, severity=1, env=None, store=None): + decision, tracker = observe_incident(event, state=state or event.event_kind, severity=severity, env=env, store=store) + if decision.action != "send": + return False + try: + result = send() + except Exception: + tracker.record_delivery(decision) + raise + # Existing boolean senders acknowledge transport only. A sender may supply + # its actual unique send-history receipt, but we never manufacture one. + receipt = result.get("receipt_id") if isinstance(result, Mapping) else None + if (not isinstance(receipt, str) or not receipt.strip() or receipt.startswith("transport-ack:") + or receipt in {decision.transition_key, event.to_candidate()["dedupe_key"]}): + receipt = None + tracker.record_delivery(decision, receipt_id=receipt) + return result is True or receipt is not None + + +def _storage_failure(notify_issue): + if notify_issue is None: + return + if str(os.environ.get("NOTIFY_LANG") or "zh").lower().startswith("en"): + return notify_issue("Notification evidence unavailable", "Candidate storage or incident history could not be confirmed.") + else: + return notify_issue("通知证据不可用", "无法确认候选记录或告警历史,请检查通知存储。") + + +def _safe_send_incident(event, send, **kwargs): + try: + return send_incident(event, send, **kwargs) + except Exception as exc: + # A missing/unreadable reservation cannot authorize an untracked send. + print(f"notification_incident_unconfirmed error_type={type(exc).__name__}", flush=True) + record_failure("incident_history_unconfirmed") + return False + + +def record_failure(code, *, report=None): + report = report if report is not None else _REPORT.get() + if report is not None: + failures = report.setdefault("notification_unified_errors", []) + if code not in failures: + failures.append(code) + + +def cycle_facts(result, *, dry_run=False, attention_reasons=()): + from .digest_snapshot.digest_routing import defer_routine_cycle_to_digest + evidence = {**dict(getattr(result, "allocation", {}) or {}), **dict(getattr(result, "execution", {}) or {})} + action = bool(getattr(result, "action_done", False)) + pending = tuple(getattr(result, "pending_orders", ()) or ()) + evidence.setdefault("execution_status", "completed" if action else "no_action") + evidence["broker_submission_done"] = action and not dry_run + evidence["orders_pending_count"] = len(pending) + evidence["notification_attention_reason_codes"] = tuple(getattr(result, "notification_attention_reason_codes", ()) or ()) + tuple(attention_reasons) + # Passed diagnostics do not constitute a risk escalation. Keep all others. + evidence["risk_flags"] = tuple(flag for flag in (evidence.get("risk_flags") or ()) if flag != "risk_gate:passed") + healthy = defer_routine_cycle_to_digest(evidence, submitted_order_count=0, mode="daily_digest", include_orders=False) + kind = "routine_cycle" if healthy else "real_error" + status = str(evidence.get("execution_status") or "").lower() + if pending or "unknown" in status or "pending" in status: + kind = "order_status_unknown" + elif evidence.get("auth_expired"): + kind = "auth_failure" + elif status in {"blocked", "rejected"} or evidence.get("execution_blocked_reason") or evidence.get("blocked_reason"): + kind = "blocked_run" + elif action and not dry_run and not any(evidence.get(key) for key in ("error", "errors", "persistence_error")): + kind = "order_submitted" + known_zero = healthy and not dry_run and not action and not pending + return kind, {"schema_version": "qsl.notification_cycle.v1", "order_count": 0 if known_zero else None, + "fill_count": 0 if known_zero else None, "routine": healthy and not dry_run, "dry_run": dry_run} + + +def cycle_incident(result, kind): + if kind in {"routine_cycle", "order_submitted"}: + return None + evidence = dict(getattr(result, "execution", {}) or {}) + reason = next((str(evidence[key]) for key in ("blocked_reason", "execution_blocked_reason", "no_op_reason") + if isinstance(evidence.get(key), str) and re.fullmatch(r"[a-zA-Z0-9_.:-]{1,128}", evidence[key])), "unknown") + identities = [] + from .digest_snapshot.unified_contract import order_identity_digest + for order in getattr(result, "pending_orders", ()) or (): + if isinstance(order, Mapping): + identity = order.get("order_id") or order.get("broker_order_id") + if identity: + identities.append(order_identity_digest(str(identity))) + return f"cycle_attention:{kind}:{reason}" + (f":{_hash(sorted(identities))}" if identities else "") + + +def prepare_cycle(result, *, config, publisher, notify_issue): + """Called only on the opted-in path after unchanged execution persistence.""" + from .events import NotificationPublisher + try: + kind, facts = cycle_facts(result, dry_run=config.dry_run_only, + attention_reasons=getattr(config, "notification_attention_reason_codes", ())) + event = make_event(kind, incident=cycle_incident(result, kind), + order_count=facts["order_count"], fill_count=facts["fill_count"]) + if kind == "order_submitted": + # There is no complete order collection on ExecutionCycleResult. + # This is a run fact, not an invented individual order event. + fact = replace(event, event_kind="routine_cycle", incident_reason=None).to_candidate() + persist_candidate(fact, dry_run=False) + return False, publisher + result_route = route_notification_event(event, env=routing_env(), flag_key=FLAG, + persist_candidate=lambda fact: persist_candidate(fact, dry_run=config.dry_run_only, + preview_count=len(getattr(result, "dry_run_orders", ()) or ()) if config.dry_run_only else None), + legacy=lambda: None, immediate=lambda: None) + if not result_route.candidate_persisted: + record_failure("candidate_persistence_unconfirmed") + failure = replace(event, event_kind="real_error", incident_reason="candidate_persistence_failed") + _safe_send_incident(failure, lambda: _storage_failure(notify_issue)) + if kind == "routine_cycle": + # Only this same source's clean terminal result proves recovery. + try: + recover_cycle_incidents(event) + observe_incident(replace(event, incident_reason="candidate_persistence_failed"), state="persisted", recovered=True, severity=0) + except Exception: + print("notification_recovery_unconfirmed", flush=True) + record_failure("incident_recovery_unconfirmed") + return True, publisher + wrapped = NotificationPublisher(log_message=publisher.log_message, + send_message=lambda text: _safe_send_incident(event, lambda: publisher.send_message(text))) + return False, wrapped + except Exception as exc: + print(f"notification_candidate_unconfirmed error_type={type(exc).__name__}", flush=True) + record_failure("candidate_persistence_unconfirmed") + try: + failure = make_event("real_error", incident="candidate_persistence_failed") + _safe_send_incident(failure, lambda: _storage_failure(notify_issue)) + except Exception: + print("notification_identity_or_history_unknown", flush=True) + if bool(getattr(result, "action_done", False)) and not config.dry_run_only and not getattr(result, "pending_orders", ()): + # Real order visibility never depends on digest storage/configuration. + return False, publisher + # Candidate failure is an explicit operational failure, never a healthy + # zero-trade confirmation. Broker/result/persistence paths stay intact. + return False, NotificationPublisher(log_message=publisher.log_message, send_message=lambda text: False) + + +def dispatch_immediate(send, *, kind, incident, report=None, occurred_at=None, run_id=None, + actually_ran=False, state=None, severity=1): + if not enabled(): + return send() + try: + event = make_event(kind, report=report, incident=incident, occurred_at=occurred_at, run_id=run_id) + def immediate(): + with bind_report(report if report is not None else _REPORT.get()): + return _safe_send_incident(event, send, state=state, severity=severity) + if not actually_ran and report is None and _REPORT.get() is None: + # A missing receipt has no business run to export. The QRS + # contract has no independent non-counting monitor channel. + return immediate() + route = route_notification_event(event, env=routing_env(), flag_key=FLAG, + persist_candidate=lambda fact: persist_candidate(fact, actually_ran=actually_ran, + dry_run=bool((report or {}).get("dry_run"))), legacy=send, + immediate=immediate) + if not route.candidate_persisted: + print("notification_candidate_unconfirmed coverage_status=unknown", flush=True) + record_failure("candidate_persistence_unconfirmed", report=report) + return route.result + except Exception as exc: + print(f"notification_incident_unconfirmed error_type={type(exc).__name__}", flush=True) + record_failure("incident_history_unconfirmed", report=report) + return False + + +def record_order_resolution(order_event): + from .digest_snapshot.unified_contract import order_identity_digest + try: + digest = order_identity_digest(order_event.order_id) + fact = make_event("fill", order_execution_digest=digest) + persist_candidate(fact.to_candidate(), actually_ran=False) + for stream in ("order_status", "order_status_poll"): + observe_incident(replace(fact, incident_reason=f"{stream}:{digest}"), state="filled", recovered=True, severity=0) + except Exception: + record_failure("order_notification_evidence_unconfirmed") + + +def recover_runtime(*, report=None, route_label=None, occurred_at=None, run_id=None, probe=False): + if not enabled(): + return + if report is not None: + if report.get("status") != "ok" or report.get("notification_unified_errors"): + return + summary = report.get("summary") or {} + if summary.get("orders_pending_count") or summary.get("execution_status") in {"blocked", "pending_reconciliation"}: + return + try: + reasons = ("unknown", "paper_live_conflict") + incidents = ([f"runtime_route:{route_label}:{reason}" for reason in reasons] if route_label else + ["runtime_failure:health_probe"] if probe else + [f"runtime_failure:{phase}:{reason}" for phase in ( + "market_hours", "runtime_configuration", "strategy_cycle", "reporting") for reason in reasons]) + # Only a persisted successful run/probe can recover its own route. + # A plain HTTP success (for example a skipped market window) is not proof. + if report is not None and _ROUTE.get(): + incidents.extend(f"runtime_route:{_ROUTE.get()}:{reason}" for reason in reasons) + for incident in incidents: + event = make_event("real_error", report=report, incident=incident, occurred_at=occurred_at, run_id=run_id) + observe_incident(event, state="healthy", recovered=True, severity=0) + if report is not None and not probe: + recover_incident_group(event, "runtime_issue:", skip_current_run=True) + except Exception: + record_failure("runtime_recovery_unconfirmed", report=report) + + +def attach_cycle_event(summary, result, *, report, config): + kind, facts = cycle_facts(result, dry_run=config.dry_run_only, + attention_reasons=getattr(config, "notification_attention_reason_codes", ())) + if kind == "order_submitted": + kind = "routine_cycle" + event = make_event(kind, report=report, incident=cycle_incident(result, kind), + order_count=facts["order_count"], fill_count=facts["fill_count"]) + summary["notification_digest"] = {**facts, "event": event.to_candidate(), + "preview_count": len(getattr(result, "dry_run_orders", ()) or ()) if config.dry_run_only else None} + + +def _archived_event(report, env): + identity, target = target_binding(env, report=report) + if report.get("schema_version") != "runtime_report.v1" or report.get("status") not in { + "ok", "completed", "success", "error", "aborted", "failed", "skipped"}: + raise ValueError("candidate_source_incomplete") + facts = (report.get("summary") or {}).get("notification_digest") or {} + known = facts.get("schema_version") == "qsl.notification_cycle.v1" and facts.get("dry_run") is target["dry_run_only"] + failed = report.get("status") in {"error", "aborted", "failed"} + event = make_event("real_error" if failed else "routine_cycle", env=env, report=report, + incident="runtime_failure" if failed else None, + order_count=facts.get("order_count") if known else None, + fill_count=facts.get("fill_count") if known else None) + saved = facts.get("event") + if saved is not None: + if (not isinstance(saved, dict) or any(saved.get(key) != value for key, value in identity.items()) + or saved.get("source_run_id") != report.get("run_id") + or _stamp(saved.get("occurred_at")) != event.occurred_at): + raise ValueError("notification_archived_event_mismatch") + event = replace(event, event_kind=saved["event_kind"], incident_reason=saved.get("incident_reason"), + order_count=saved.get("order_count"), fill_count=saved.get("fill_count")) + if event.to_candidate() != saved: + raise ValueError("notification_archived_event_mismatch") + return event, facts + + +def heartbeat_observation(*, now, report=None, recovery_at=None, env=None): + env = os.environ if env is None else env + if not enabled(env): + return + try: + if not isinstance(report, Mapping): + raise ValueError("notification_receipt_business_run_unknown") + archived, facts = _archived_event(report, env) + if archived.occurred_at > _stamp(now): + raise ValueError("notification_receipt_time_invalid") + persist_candidate(archived.to_candidate(), env=env, preview_count=facts.get("preview_count")) + # The observed receipt owns both the run identity and its business + # window. A next-window monitor job is not another business run. + event = replace(archived, event_kind="healthy_heartbeat", incident_reason="execution_receipt", + order_count=None, fill_count=None) + persist_candidate(event.to_candidate(), env=env, actually_ran=False) + if recovery_at is not None: + observe_incident(replace(event, occurred_at=_stamp(recovery_at)), state="accepted", + recovered=True, severity=0, env=env) + except Exception as exc: + print(f"notification_heartbeat_candidate_unconfirmed error_type={type(exc).__name__}", flush=True) + raise + + +def publish_attention(decision, *, recover, telegram_sender=None): + from quant_platform_kit.risk.attention import render_attention_compact + from quant_platform_kit.risk.attention_notify import _default_telegram_sender + from .display_names import display_name + try: + event = make_event("risk_escalation", incident=f"new_risk_attention:{_hash(sorted(decision.reason_codes))}") + if decision.level.value not in {"action", "halt"}: + if recover: + recover_incident_group(event, "new_risk_attention:") + return {"sent": 0, "skipped": 1, "failed": 0} + identity, _ = target_binding() + locale = os.environ.get("QSL_NOTIFY_LANG") or os.environ.get("NOTIFY_LANG") or "zh" + english = str(locale).lower().startswith("en") + text = render_attention_compact(locale=locale, + platform=display_name("platforms", identity["platform_id"], locale, fallback="Platform" if english else "平台"), + account_alias=display_name("accounts", identity["opaque_account_uid"], locale, platform=identity["platform_id"], + fallback="Account" if english else "账户"), + strategy_profile=display_name("strategies", identity["strategy_profile"], locale, + fallback="Unnamed strategy" if english else "未命名策略"), decision=decision) + sender = telegram_sender or _default_telegram_sender + outcome = dispatch_immediate(lambda: bool(sender(text=text, alert_key=event.incident_reason)), + kind="risk_escalation", incident=event.incident_reason, state=decision.level.value, + severity=decision.level.severity_order) + return {"sent": int(outcome is True), "skipped": int(outcome is not True), "failed": 0} + except Exception as exc: + print(f"notification_risk_history_unconfirmed error_type={type(exc).__name__}", flush=True) + record_failure("risk_notification_history_unconfirmed") + return {"sent": 0, "skipped": 0, "failed": 1} + + +def emit_archived(env, *, now=None, read=None, store=None): + """Catch up the completed window from existing reports, including PAPER. + + No runtime invocation, target enablement or scheduler changes. The same + source run and routine event kind are used by the synchronous producer. + """ + from .digest_snapshot.digest_archive import _read + from .digest_snapshot.unified_contract import sgt_business_window + from datetime import timedelta + if not enabled(env): + raise ValueError("notification_adapter_not_enabled") + if env.get("RUNTIME_TARGET_ENABLED") == "false": + return {"status": "target_disabled", "uploaded": False} + values = env.get("DIGEST_TARGET_BINDINGS_JSON", "").strip() + if values: + bindings = json.loads(values) + if not isinstance(bindings, list) or not all(isinstance(value, dict) for value in bindings): + raise ValueError("candidate_bindings_invalid") + selected = [value for value in bindings if value.get("DIGEST_TARGET_ID") == env.get("DIGEST_MATRIX_TARGET_ID")] + if len(selected) != 1: + raise ValueError("notification_matrix_binding_unknown") + env = {**env, **selected[0], "DIGEST_TARGET_BINDINGS_JSON": ""} + identity, target = target_binding(env) + current = _stamp(now or datetime.now(timezone.utc)) + window = sgt_business_window(current) + start, end = window.start - timedelta(days=1), window.start + prefix = str(env.get("EXECUTION_REPORT_GCS_URI") or "").rstrip("/") + from .unified_store import validate_prefix + validate_prefix(prefix) + read = read or _read + months = {start.astimezone(timezone.utc).strftime("%Y-%m"), end.astimezone(timezone.utc).strftime("%Y-%m")} + entries = [] + for month in sorted(months): + entries.extend(read(["ls", "--json", f"{prefix}/{identity['platform_id']}/**/{month}/*.json"])) + if len(entries) > 10000: + raise ValueError("candidate_archive_budget_exceeded") + seen, count = set(), 0 + for entry in entries: + uri = str(entry.get("url") or "").split("#", 1)[0] + if uri in seen: + continue + seen.add(uri) + if not uri.startswith(f"{prefix}/{identity['platform_id']}/"): + raise ValueError("candidate_source_locator_mismatch") + updated = _stamp((entry.get("metadata") or {}).get("updated") or (entry.get("metadata") or {}).get("timeCreated")) + if not start <= updated <= current: + continue + report = read(["cat", uri]) + source = report.get("runtime_target") or {} + if any(source.get(key) != target[key] for key in _TARGET_KEYS): + continue + stamp = _stamp(report.get("started_at")) + if not start <= stamp < end: + continue + # Run facts keep the cycle kind even when real-order notifications were + # immediate. Absence of per-order evidence remains null, never zero. + event, facts = _archived_event(report, env) + persist_candidate(event.to_candidate(), env=env, dry_run=target["dry_run_only"], store=store, + preview_count=facts.get("preview_count") if target["dry_run_only"] else None) + count += 1 + if not count: + # Absence is not proof of a healthy no-run window. Preserve the existing + # archive producer's fail-closed schedule check for expected live runs. + schedule = env.get("DIGEST_NOTIFICATION_SCHEDULE_JSON") + if not schedule: + raise ValueError("candidate_source_absent") + from quant_platform_kit.common.runtime_heartbeat_policy import filter_due_targets + due, evaluated = filter_due_targets([json.loads(schedule)], since=start, now=end - timedelta(microseconds=1), + publication_grace=timedelta(0), warning_logger=lambda message: None) + if not evaluated or due: + raise ValueError("candidate_due_source_absent") + # This is a proved idle window, with no invented run or event time. + injected_store = store + store = store or GCSNotificationStore(env.get("DIGEST_CANDIDATES_GCS_URI", "")) + state_store = injected_store or GCSNotificationStore(env.get("QSL_NOTIFICATION_MARKER_GCS_URI", "")) + slot = str(env.get("DIGEST_STORE_TARGET") or "") + if not re.fullmatch(r"[a-z0-9][a-z0-9_-]{0,40}", slot): + raise ValueError("candidate_store_identity_missing") + key = f"{identity['platform_id']}/{start.date().isoformat()}/{slot}" + version, previous = state_store.read(f"candidate_state/{key}") + if not previous and store.read(key)[1] is not None: + raise ValueError("notification_candidate_history_unknown") + if previous: + if previous.get("notification_scope_key") != _hash(identity): + raise ValueError("notification_candidate_scope_conflict") + if previous.get("notification_source_runs"): + raise ValueError("notification_idle_contradicts_runs") + payload = {"schema_version": "qsl.digest_candidates.v1", "notification_scope_key": _hash(identity), + "notification_events": {}, "notification_source_runs": {}, "notification_conflicts": [], + "runs": [{**identity, "business_day": start.date().isoformat(), "actually_ran": False, + "scheduled_idle": True, "status": "ok", "evidence_provenance": "candidates"}]} + if payload != previous and not state_store.compare_and_set(f"candidate_state/{key}", version, payload): + raise CandidatePersistenceError("notification_candidate_concurrent_update") + _export_projection(store, state_store, key, payload) + return {"status": "projected", "uploaded": True, "runs": count} diff --git a/notifications/unified_store.py b/notifications/unified_store.py new file mode 100644 index 0000000..9715e7c --- /dev/null +++ b/notifications/unified_store.py @@ -0,0 +1,58 @@ +"""Private notification storage, separate from all execution state. + +Conditional writes are single attempts. An uncertain transport/storage result +must be reconciled from its persisted marker, never retried as a fresh send. +""" +from __future__ import annotations + +import json +import re + + +def validate_prefix(value: str) -> str: + value = str(value or "").rstrip("/") + if not re.fullmatch(r"gs://[a-z0-9][a-z0-9._-]+/[^\s?#*]+", value): + raise ValueError("notification_store_configuration_missing") + return value + + +class GCSNotificationStore: + def __init__(self, prefix: str, *, client=None): + self.prefix = validate_prefix(prefix) + if client is None: + from google.cloud import storage + client = storage.Client() + self.client = client + + def _blob(self, key): + from google.cloud import storage + if not re.fullmatch(r"[a-zA-Z0-9_./-]+", key) or ".." in key: + raise ValueError("notification_store_key_invalid") + return storage.Blob.from_string(f"{self.prefix}/{key}.json", client=self.client) + + def read(self, key): + from google.api_core.exceptions import NotFound + blob = self._blob(key) + try: + blob.reload(retry=None, timeout=30) + except NotFound: + return None, None + generation = int(blob.generation) + # Disappearance/change after metadata was observed is incomplete + # history, not a new absent marker authorizing a fresh send. + raw = blob.download_as_bytes(if_generation_match=generation, retry=None, timeout=30) + value = json.loads(raw) + if not isinstance(value, dict): + raise ValueError("notification_marker_invalid") + return generation, value + + def compare_and_set(self, key, expected_generation, value): + from google.api_core.exceptions import PreconditionFailed + blob = self._blob(key) + try: + blob.upload_from_string(json.dumps(value, allow_nan=False, sort_keys=True), + content_type="application/json", if_generation_match=expected_generation or 0, + retry=None, timeout=30) + except PreconditionFailed: + return False + return True diff --git a/scripts/emit_unified_digest_candidates.py b/scripts/emit_unified_digest_candidates.py index ba1716c..e2db1ea 100644 --- a/scripts/emit_unified_digest_candidates.py +++ b/scripts/emit_unified_digest_candidates.py @@ -5,4 +5,14 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from notifications.digest_snapshot.digest_archive import main # noqa: E402 if __name__ == "__main__": - raise SystemExit(main()) + from notifications.unified import enabled, emit_archived + import os + import json + if enabled(): + try: + print(json.dumps(emit_archived(os.environ), sort_keys=True)) + except Exception: + print(json.dumps({"status": "blocked", "uploaded": False, "reason": "candidate_projection_failed"})) + raise SystemExit(1) + else: + raise SystemExit(main()) diff --git a/scripts/execution_report_heartbeat.py b/scripts/execution_report_heartbeat.py index 079076c..fb066f5 100644 --- a/scripts/execution_report_heartbeat.py +++ b/scripts/execution_report_heartbeat.py @@ -14,6 +14,8 @@ from typing import Any from zoneinfo import ZoneInfo, ZoneInfoNotFoundError +from notifications import unified as unified_notifications + from quant_platform_kit.common.operational_notification_localization import ( format_operational_alert, localize_operational_activity, @@ -1166,13 +1168,30 @@ def _localized_heartbeat_detail(detail: str) -> str: return f"{localized_activity}{separator}{suffix}" if separator else localized_activity -def _notify_normal_heartbeat(name: str, detail: str) -> None: +def _notify_normal_heartbeat(name: str, detail: str, *, observed_at=None, checked_at=None, observed_report=None) -> None: # Successful no-trade runs notify in run_strategy, using that cycle's broker # snapshot. Scheduled scans retain alerts but never duplicate cycle success. + if unified_notifications.enabled() and observed_at is not None: + unified_notifications.heartbeat_observation(now=checked_at, recovery_at=observed_at, report=observed_report) if _env_bool("RUNTIME_HEARTBEAT_NOTIFY_ON_SUCCESS", False): print(f"Execution report success notification skipped for {name}: cycle notifications belong to the runtime; scanner is alerts-only") +def _notify_heartbeat_alert(message, *, now, kind, inspected_count=0): + if not unified_notifications.enabled(): + return _send_telegram(message) + locale = _notification_locale() + issue = (_notice("heartbeat_no_recent_report", lookback_hours=os.environ.get("RUNTIME_HEARTBEAT_LOOKBACK_HOURS") or "36") + if kind == "receipt_missing" else _notice("heartbeat_no_acceptable_report", count=inspected_count)) + # Keep raw diagnostics in the existing logs; use the shared localized + # template without private target names, archive URIs or mixed raw reasons. + compact = format_operational_alert(locale=locale, alert_type="execution_report_heartbeat", + name="Execution receipts" if locale == "en" else "执行回执", context={}, issues=[issue]) + return unified_notifications.dispatch_immediate(lambda: _send_telegram(compact[:3900]), + kind=kind, incident="execution_receipt", occurred_at=now, + run_id=os.environ.get("GITHUB_RUN_ID"), actually_ran=False) + + def main(now: dt.datetime | None = None) -> int: project = ( os.environ.get("RUNTIME_HEARTBEAT_GCP_PROJECT_ID") @@ -1224,7 +1243,7 @@ def main(now: dt.datetime | None = None) -> int: technical_details=[str(exc)], ) print(message) - _send_telegram(message) + _notify_heartbeat_alert(message, now=now, kind="receipt_unacceptable") return 1 if fail_workflow else 0 publication_grace_minutes = float( os.environ.get("RUNTIME_HEARTBEAT_PUBLICATION_GRACE_MINUTES") or "30" @@ -1326,6 +1345,8 @@ def main(now: dt.datetime | None = None) -> int: sorted_objects = sorted(objects.values(), key=lambda item: item[1], reverse=True) accepted = [] accepted_by_service: dict[str, tuple[str, dt.datetime, str, str]] = {} + notification_recovery_proofs = {} + notification_recovery_reports = {} inspected = [] for uri, updated in sorted_objects[:max_reports]: payload = _cat_gcs_json(uri, project=project) @@ -1353,6 +1374,16 @@ def main(now: dt.datetime | None = None) -> int: ok, reason = _is_accepted_report(payload) inspected.append(f"- {updated.isoformat()} {uri} {reason}") if ok: + if unified_notifications.enabled(): + try: + unified_notifications.target_binding(report=payload) + # Object upload time alone cannot prove a new execution. + proof = unified_notifications._stamp(payload.get("finished_at") or payload.get("started_at")) + if proof <= now: + notification_recovery_proofs[uri] = proof + notification_recovery_reports[uri] = payload + except (ValueError, TypeError): + print("notification_receipt_recovery_proof_unknown", flush=True) activity = _report_activity_label(payload) if required_keys: accepted_by_service[service_name] = (uri, updated, reason, activity) @@ -1372,14 +1403,30 @@ def main(now: dt.datetime | None = None) -> int: for key in required_keys ) print(f"Execution report heartbeat OK for {name}: {details}") - _notify_normal_heartbeat(name, summary_details) + if unified_notifications.enabled(): + uris = [accepted_by_service[key][0] for key in required_keys] + if any(uri not in notification_recovery_proofs for uri in uris): + raise ValueError("notification_receipt_binding_or_time_unknown") + proof = min(notification_recovery_proofs[uri] for uri in uris) + for uri in uris: + _notify_normal_heartbeat(name, summary_details, observed_at=proof, checked_at=now, + observed_report=notification_recovery_reports[uri]) + else: + _notify_normal_heartbeat(name, summary_details) return 0 if accepted: uri, updated, reason, activity = accepted[0] print( f"Execution report heartbeat OK for {name}: {reason}, updated={updated.isoformat()}, uri={uri}" ) - _notify_normal_heartbeat(name, f"{activity}@{updated.isoformat()}") + if unified_notifications.enabled(): + if uri not in notification_recovery_proofs: + raise ValueError("notification_receipt_binding_or_time_unknown") + _notify_normal_heartbeat(name, f"{activity}@{updated.isoformat()}", + observed_at=notification_recovery_proofs[uri], checked_at=now, + observed_report=notification_recovery_reports[uri]) + else: + _notify_normal_heartbeat(name, f"{activity}@{updated.isoformat()}") return 0 issues = [] @@ -1422,7 +1469,9 @@ def main(now: dt.datetime | None = None) -> int: workflow_url=run_url, ) print(message) - _send_telegram(message[:3900]) + _notify_heartbeat_alert(message[:3900], now=now, + kind="receipt_missing" if not sorted_objects else "receipt_unacceptable", + inspected_count=min(len(sorted_objects), max_reports)) return 1 if fail_workflow else 0 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..5c570f5 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,23 @@ +"""All unit tests use injected transports, including during collection.""" +import socket + +import pytest + + +_network = pytest.MonkeyPatch() + + +def _offline(*args, **kwargs): + raise OSError("Network disabled in unit tests; inject a fake transport") + + +def pytest_configure(config): + _network.setattr(socket.socket, "connect", _offline) + _network.setattr(socket.socket, "connect_ex", _offline) + _network.setattr(socket.socket, "sendto", _offline) + _network.setattr(socket, "create_connection", _offline) + _network.setattr(socket, "getaddrinfo", _offline) + + +def pytest_unconfigure(config): + _network.undo() diff --git a/tests/fixtures/notification_main_20e1f526.json b/tests/fixtures/notification_main_20e1f526.json new file mode 100644 index 0000000..e7b5a85 --- /dev/null +++ b/tests/fixtures/notification_main_20e1f526.json @@ -0,0 +1,990 @@ +{ + "zh": { + "cycle": { + "healthy": { + "calls": [ + [ + "bootstrap", + null + ], + [ + "snapshot", + null + ], + [ + "plan", + [ + "indicators", + "snapshot" + ] + ], + [ + "execute", + { + "dry_run_only": false, + "symbol_suffix": ".US", + "limit_sell_discount": 1.0, + "limit_buy_premium": 1.0, + "cash_only_execution": true, + "fractional_buy_execution": false, + "buy_quantity_step": 1.0 + } + ], + [ + "send", + "💓 【策略运行心跳】\n✅ 无交易,无需调仓" + ] + ], + "execution": { + "execution_status": "no_action", + "cash_only_execution": true, + "signal_snapshot": {} + }, + "action_done": false, + "pending": [] + }, + "order": { + "calls": [ + [ + "bootstrap", + null + ], + [ + "snapshot", + null + ], + [ + "plan", + [ + "indicators", + "snapshot" + ] + ], + [ + "execute", + { + "dry_run_only": false, + "symbol_suffix": ".US", + "limit_sell_discount": 1.0, + "limit_buy_premium": 1.0, + "cash_only_execution": true, + "fractional_buy_execution": false, + "buy_quantity_step": 1.0 + } + ], + [ + "send", + "🔔 【调仓指令】" + ] + ], + "execution": { + "execution_status": "completed", + "cash_only_execution": true, + "signal_snapshot": {} + }, + "action_done": true, + "pending": [] + }, + "pending": { + "calls": [ + [ + "bootstrap", + null + ], + [ + "snapshot", + null + ], + [ + "plan", + [ + "indicators", + "snapshot" + ] + ], + [ + "execute", + { + "dry_run_only": false, + "symbol_suffix": ".US", + "limit_sell_discount": 1.0, + "limit_buy_premium": 1.0, + "cash_only_execution": true, + "fractional_buy_execution": false, + "buy_quantity_step": 1.0 + } + ], + [ + "send", + "⏳ 【订单待券商最终确认】" + ] + ], + "execution": { + "execution_status": "completed", + "cash_only_execution": true, + "signal_snapshot": {} + }, + "action_done": true, + "pending": [ + { + "status": "pending_reconciliation" + } + ] + }, + "blocked": { + "calls": [ + [ + "bootstrap", + null + ], + [ + "snapshot", + null + ], + [ + "plan", + [ + "indicators", + "snapshot" + ] + ], + [ + "execute", + { + "dry_run_only": false, + "symbol_suffix": ".US", + "limit_sell_discount": 1.0, + "limit_buy_premium": 1.0, + "cash_only_execution": true, + "fractional_buy_execution": false, + "buy_quantity_step": 1.0 + } + ], + [ + "send", + "💓 【策略运行心跳】\n⚠️ 执行受阻,本轮未提交新订单" + ] + ], + "execution": { + "execution_status": "blocked", + "blocked_reason": "synthetic_rejection", + "cash_only_execution": true, + "signal_snapshot": {} + }, + "action_done": false, + "pending": [] + }, + "preview": { + "calls": [ + [ + "bootstrap", + null + ], + [ + "snapshot", + null + ], + [ + "plan", + [ + "indicators", + "snapshot" + ] + ], + [ + "execute", + { + "dry_run_only": true, + "symbol_suffix": ".US", + "limit_sell_discount": 1.0, + "limit_buy_premium": 1.0, + "cash_only_execution": true, + "fractional_buy_execution": false, + "buy_quantity_step": 1.0 + } + ] + ], + "execution": { + "execution_status": "no_action", + "cash_only_execution": true, + "signal_snapshot": {} + }, + "action_done": false, + "pending": [] + } + }, + "heartbeat": { + "healthy": { + "calls": [ + [ + "list", + [ + "gs://synthetic-reports/archive/longbridge/**/2026-10/*.json" + ], + { + "project": "synthetic" + } + ], + [ + "cat", + [ + "gs://synthetic-reports/archive/longbridge/core/2026-10/test.json" + ], + { + "project": "synthetic" + } + ] + ], + "exit_code": 0 + }, + "missing": { + "calls": [ + [ + "list", + [ + "gs://synthetic-reports/archive/longbridge/**/2026-10/*.json" + ], + { + "project": "synthetic" + } + ], + [ + "send", + "[执行回执心跳] synthetic\n检查范围:过去 36 小时\n问题:\n- 过去 36 小时内没有新的执行回执" + ] + ], + "exit_code": 1 + }, + "rejected": { + "calls": [ + [ + "list", + [ + "gs://synthetic-reports/archive/longbridge/**/2026-10/*.json" + ], + { + "project": "synthetic" + } + ], + [ + "cat", + [ + "gs://synthetic-reports/archive/longbridge/core/2026-10/test.json" + ], + { + "project": "synthetic" + } + ], + [ + "send", + "[执行回执心跳] synthetic\n检查范围:过去 36 小时\n问题:\n- 缺少可接受的执行回执:synthetic-service\n最近检查的回执(原文):\n- 2026-10-16T22:40:00+00:00 gs://synthetic-reports/archive/longbridge/core/2026-10/test.json rejected execution_status=blocked" + ] + ], + "exit_code": 1 + }, + "disabled": { + "calls": [], + "exit_code": 0 + } + }, + "cycle_legacy_digest": { + "healthy": { + "calls": [ + [ + "bootstrap", + null + ], + [ + "snapshot", + null + ], + [ + "plan", + [ + "indicators", + "snapshot" + ] + ], + [ + "execute", + { + "dry_run_only": false, + "symbol_suffix": ".US", + "limit_sell_discount": 1.0, + "limit_buy_premium": 1.0, + "cash_only_execution": true, + "fractional_buy_execution": false, + "buy_quantity_step": 1.0 + } + ] + ], + "execution": { + "execution_status": "no_action", + "cash_only_execution": true, + "signal_snapshot": {} + }, + "action_done": false, + "pending": [] + }, + "order": { + "calls": [ + [ + "bootstrap", + null + ], + [ + "snapshot", + null + ], + [ + "plan", + [ + "indicators", + "snapshot" + ] + ], + [ + "execute", + { + "dry_run_only": false, + "symbol_suffix": ".US", + "limit_sell_discount": 1.0, + "limit_buy_premium": 1.0, + "cash_only_execution": true, + "fractional_buy_execution": false, + "buy_quantity_step": 1.0 + } + ] + ], + "execution": { + "execution_status": "completed", + "cash_only_execution": true, + "signal_snapshot": {} + }, + "action_done": true, + "pending": [] + }, + "pending": { + "calls": [ + [ + "bootstrap", + null + ], + [ + "snapshot", + null + ], + [ + "plan", + [ + "indicators", + "snapshot" + ] + ], + [ + "execute", + { + "dry_run_only": false, + "symbol_suffix": ".US", + "limit_sell_discount": 1.0, + "limit_buy_premium": 1.0, + "cash_only_execution": true, + "fractional_buy_execution": false, + "buy_quantity_step": 1.0 + } + ], + [ + "send", + "⏳ 【订单待券商最终确认】" + ] + ], + "execution": { + "execution_status": "completed", + "cash_only_execution": true, + "signal_snapshot": {} + }, + "action_done": true, + "pending": [ + { + "status": "pending_reconciliation" + } + ] + }, + "blocked": { + "calls": [ + [ + "bootstrap", + null + ], + [ + "snapshot", + null + ], + [ + "plan", + [ + "indicators", + "snapshot" + ] + ], + [ + "execute", + { + "dry_run_only": false, + "symbol_suffix": ".US", + "limit_sell_discount": 1.0, + "limit_buy_premium": 1.0, + "cash_only_execution": true, + "fractional_buy_execution": false, + "buy_quantity_step": 1.0 + } + ], + [ + "send", + "💓 【策略运行心跳】\n⚠️ 执行受阻,本轮未提交新订单" + ] + ], + "execution": { + "execution_status": "blocked", + "blocked_reason": "synthetic_rejection", + "cash_only_execution": true, + "signal_snapshot": {} + }, + "action_done": false, + "pending": [] + }, + "preview": { + "calls": [ + [ + "bootstrap", + null + ], + [ + "snapshot", + null + ], + [ + "plan", + [ + "indicators", + "snapshot" + ] + ], + [ + "execute", + { + "dry_run_only": true, + "symbol_suffix": ".US", + "limit_sell_discount": 1.0, + "limit_buy_premium": 1.0, + "cash_only_execution": true, + "fractional_buy_execution": false, + "buy_quantity_step": 1.0 + } + ] + ], + "execution": { + "execution_status": "no_action", + "cash_only_execution": true, + "signal_snapshot": {} + }, + "action_done": false, + "pending": [] + } + } + }, + "en": { + "cycle": { + "healthy": { + "calls": [ + [ + "bootstrap", + null + ], + [ + "snapshot", + null + ], + [ + "plan", + [ + "indicators", + "snapshot" + ] + ], + [ + "execute", + { + "dry_run_only": false, + "symbol_suffix": ".US", + "limit_sell_discount": 1.0, + "limit_buy_premium": 1.0, + "cash_only_execution": true, + "fractional_buy_execution": false, + "buy_quantity_step": 1.0 + } + ], + [ + "send", + "💓 【Strategy cycle heartbeat】\n✅ No trade; no rebalance needed" + ] + ], + "execution": { + "execution_status": "no_action", + "cash_only_execution": true, + "signal_snapshot": {} + }, + "action_done": false, + "pending": [] + }, + "order": { + "calls": [ + [ + "bootstrap", + null + ], + [ + "snapshot", + null + ], + [ + "plan", + [ + "indicators", + "snapshot" + ] + ], + [ + "execute", + { + "dry_run_only": false, + "symbol_suffix": ".US", + "limit_sell_discount": 1.0, + "limit_buy_premium": 1.0, + "cash_only_execution": true, + "fractional_buy_execution": false, + "buy_quantity_step": 1.0 + } + ], + [ + "send", + "🔔 【Trade Execution Report】" + ] + ], + "execution": { + "execution_status": "completed", + "cash_only_execution": true, + "signal_snapshot": {} + }, + "action_done": true, + "pending": [] + }, + "pending": { + "calls": [ + [ + "bootstrap", + null + ], + [ + "snapshot", + null + ], + [ + "plan", + [ + "indicators", + "snapshot" + ] + ], + [ + "execute", + { + "dry_run_only": false, + "symbol_suffix": ".US", + "limit_sell_discount": 1.0, + "limit_buy_premium": 1.0, + "cash_only_execution": true, + "fractional_buy_execution": false, + "buy_quantity_step": 1.0 + } + ], + [ + "send", + "⏳ 【Broker Order Pending Confirmation】" + ] + ], + "execution": { + "execution_status": "completed", + "cash_only_execution": true, + "signal_snapshot": {} + }, + "action_done": true, + "pending": [ + { + "status": "pending_reconciliation" + } + ] + }, + "blocked": { + "calls": [ + [ + "bootstrap", + null + ], + [ + "snapshot", + null + ], + [ + "plan", + [ + "indicators", + "snapshot" + ] + ], + [ + "execute", + { + "dry_run_only": false, + "symbol_suffix": ".US", + "limit_sell_discount": 1.0, + "limit_buy_premium": 1.0, + "cash_only_execution": true, + "fractional_buy_execution": false, + "buy_quantity_step": 1.0 + } + ], + [ + "send", + "💓 【Strategy cycle heartbeat】\n⚠️ Execution blocked; no new orders submitted this cycle" + ] + ], + "execution": { + "execution_status": "blocked", + "blocked_reason": "synthetic_rejection", + "cash_only_execution": true, + "signal_snapshot": {} + }, + "action_done": false, + "pending": [] + }, + "preview": { + "calls": [ + [ + "bootstrap", + null + ], + [ + "snapshot", + null + ], + [ + "plan", + [ + "indicators", + "snapshot" + ] + ], + [ + "execute", + { + "dry_run_only": true, + "symbol_suffix": ".US", + "limit_sell_discount": 1.0, + "limit_buy_premium": 1.0, + "cash_only_execution": true, + "fractional_buy_execution": false, + "buy_quantity_step": 1.0 + } + ] + ], + "execution": { + "execution_status": "no_action", + "cash_only_execution": true, + "signal_snapshot": {} + }, + "action_done": false, + "pending": [] + } + }, + "heartbeat": { + "healthy": { + "calls": [ + [ + "list", + [ + "gs://synthetic-reports/archive/longbridge/**/2026-10/*.json" + ], + { + "project": "synthetic" + } + ], + [ + "cat", + [ + "gs://synthetic-reports/archive/longbridge/core/2026-10/test.json" + ], + { + "project": "synthetic" + } + ] + ], + "exit_code": 0 + }, + "missing": { + "calls": [ + [ + "list", + [ + "gs://synthetic-reports/archive/longbridge/**/2026-10/*.json" + ], + { + "project": "synthetic" + } + ], + [ + "send", + "[Execution Report Heartbeat] synthetic\nLookback: 36 hours\nIssues:\n- No new execution report in the last 36 hours" + ] + ], + "exit_code": 1 + }, + "rejected": { + "calls": [ + [ + "list", + [ + "gs://synthetic-reports/archive/longbridge/**/2026-10/*.json" + ], + { + "project": "synthetic" + } + ], + [ + "cat", + [ + "gs://synthetic-reports/archive/longbridge/core/2026-10/test.json" + ], + { + "project": "synthetic" + } + ], + [ + "send", + "[Execution Report Heartbeat] synthetic\nLookback: 36 hours\nIssues:\n- Missing acceptable execution report: synthetic-service\nRecent reports (original):\n- 2026-10-16T22:40:00+00:00 gs://synthetic-reports/archive/longbridge/core/2026-10/test.json rejected execution_status=blocked" + ] + ], + "exit_code": 1 + }, + "disabled": { + "calls": [], + "exit_code": 0 + } + }, + "cycle_legacy_digest": { + "healthy": { + "calls": [ + [ + "bootstrap", + null + ], + [ + "snapshot", + null + ], + [ + "plan", + [ + "indicators", + "snapshot" + ] + ], + [ + "execute", + { + "dry_run_only": false, + "symbol_suffix": ".US", + "limit_sell_discount": 1.0, + "limit_buy_premium": 1.0, + "cash_only_execution": true, + "fractional_buy_execution": false, + "buy_quantity_step": 1.0 + } + ] + ], + "execution": { + "execution_status": "no_action", + "cash_only_execution": true, + "signal_snapshot": {} + }, + "action_done": false, + "pending": [] + }, + "order": { + "calls": [ + [ + "bootstrap", + null + ], + [ + "snapshot", + null + ], + [ + "plan", + [ + "indicators", + "snapshot" + ] + ], + [ + "execute", + { + "dry_run_only": false, + "symbol_suffix": ".US", + "limit_sell_discount": 1.0, + "limit_buy_premium": 1.0, + "cash_only_execution": true, + "fractional_buy_execution": false, + "buy_quantity_step": 1.0 + } + ] + ], + "execution": { + "execution_status": "completed", + "cash_only_execution": true, + "signal_snapshot": {} + }, + "action_done": true, + "pending": [] + }, + "pending": { + "calls": [ + [ + "bootstrap", + null + ], + [ + "snapshot", + null + ], + [ + "plan", + [ + "indicators", + "snapshot" + ] + ], + [ + "execute", + { + "dry_run_only": false, + "symbol_suffix": ".US", + "limit_sell_discount": 1.0, + "limit_buy_premium": 1.0, + "cash_only_execution": true, + "fractional_buy_execution": false, + "buy_quantity_step": 1.0 + } + ], + [ + "send", + "⏳ 【Broker Order Pending Confirmation】" + ] + ], + "execution": { + "execution_status": "completed", + "cash_only_execution": true, + "signal_snapshot": {} + }, + "action_done": true, + "pending": [ + { + "status": "pending_reconciliation" + } + ] + }, + "blocked": { + "calls": [ + [ + "bootstrap", + null + ], + [ + "snapshot", + null + ], + [ + "plan", + [ + "indicators", + "snapshot" + ] + ], + [ + "execute", + { + "dry_run_only": false, + "symbol_suffix": ".US", + "limit_sell_discount": 1.0, + "limit_buy_premium": 1.0, + "cash_only_execution": true, + "fractional_buy_execution": false, + "buy_quantity_step": 1.0 + } + ], + [ + "send", + "💓 【Strategy cycle heartbeat】\n⚠️ Execution blocked; no new orders submitted this cycle" + ] + ], + "execution": { + "execution_status": "blocked", + "blocked_reason": "synthetic_rejection", + "cash_only_execution": true, + "signal_snapshot": {} + }, + "action_done": false, + "pending": [] + }, + "preview": { + "calls": [ + [ + "bootstrap", + null + ], + [ + "snapshot", + null + ], + [ + "plan", + [ + "indicators", + "snapshot" + ] + ], + [ + "execute", + { + "dry_run_only": true, + "symbol_suffix": ".US", + "limit_sell_discount": 1.0, + "limit_buy_premium": 1.0, + "cash_only_execution": true, + "fractional_buy_execution": false, + "buy_quantity_step": 1.0 + } + ] + ], + "execution": { + "execution_status": "no_action", + "cash_only_execution": true, + "signal_snapshot": {} + }, + "action_done": false, + "pending": [] + } + } + } +} diff --git a/tests/test_notification_consolidation.py b/tests/test_notification_consolidation.py new file mode 100644 index 0000000..2136882 --- /dev/null +++ b/tests/test_notification_consolidation.py @@ -0,0 +1,730 @@ +"""Offline entrypoint comparisons captured from main at 20e1f52657a6.""" +from __future__ import annotations + +import copy +import datetime as dt +import json +import os +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + +from application import rebalance_service as service +from application.runtime_dependencies import LongBridgeRebalanceConfig, LongBridgeRebalanceRuntime +from notifications.telegram import build_translator +from scripts import execution_report_heartbeat as heartbeat +from notifications import unified + + +def cycle_case(case, language, *, notification_report=None): + calls = [] + execution = {"execution_status": "no_action"} + orders, pending = (), () + action = case in {"order", "pending"} + if action: + execution["execution_status"] = "completed" + orders = ({"status": "filled", "fills": [{"execution_id": "synthetic-fill"}]},) + if case == "pending": + pending = ({"status": "pending_reconciliation"},) + if case == "blocked": + execution.update(execution_status="blocked", blocked_reason="synthetic_rejection") + plan = {"execution": {}, "portfolio": {}, "allocation": {"target_mode": "value"}} + result = service.ExecutionCycleResult( + plan=plan, portfolio={}, execution=execution, allocation=plan["allocation"], + logs=(), skip_logs=(), note_logs=(), action_done=action, + pending_orders=pending, + ) + + def record(name, value=None): + calls.append([name, value]) + + def bootstrap(): + record("bootstrap") + return "synthetic-quote", "synthetic-trade", {} + + def resolve(**kwargs): + record("plan", sorted(kwargs)) + return plan + + def snapshot(): + record("snapshot") + return SimpleNamespace(as_of="2026-10-06", metadata={}) + + def forbidden(*args, **kwargs): + raise AssertionError("broker call in notification test") + + def execute(**kwargs): + record("execute", {key: kwargs[key] for key in ( + "dry_run_only", "symbol_suffix", "limit_sell_discount", "limit_buy_premium", + "cash_only_execution", "fractional_buy_execution", "buy_quantity_step", + )}) + return result + + runtime = LongBridgeRebalanceRuntime( + bootstrap=bootstrap, resolve_rebalance_plan=resolve, + market_data_port_factory=lambda *_: SimpleNamespace(get_quote=forbidden), + estimate_max_purchase_quantity=forbidden, + notifications=SimpleNamespace(send_text=lambda text: record("send", text) or True), + notify_issue=lambda *args: record("issue", list(args)), + portfolio_port_factory=lambda *_: SimpleNamespace(get_portfolio_snapshot=snapshot), + execution_port_factory=lambda *_: SimpleNamespace(submit_order=forbidden), + ) + config = LongBridgeRebalanceConfig( + limit_sell_discount=1.0, limit_buy_premium=1.0, separator="-", + translator=build_translator(language), with_prefix=lambda text: text, + dry_run_only=case == "preview", notify_no_trade_cycles=True, + ) + with patch.object(service, "execute_rebalance_cycle", execute), patch.object( + service, "build_signal_snapshot", return_value={} + ), unified.bind_report(notification_report): + returned = service.run_strategy(runtime=runtime, config=config) + assert returned is result + return {"calls": calls, "execution": copy.deepcopy(result.execution), + "action_done": result.action_done, "pending": list(result.pending_orders)} + + +def heartbeat_case(case, language): + calls = [] + now = dt.datetime(2026, 10, 16, 23, tzinfo=dt.timezone.utc) + payload = {"platform": "longbridge", "service_name": "synthetic-service", "status": "ok"} + if case == "rejected": + payload.update(summary={"execution_status": "blocked"}) + + def listed(*args, **kwargs): + calls.append(["list", list(args), kwargs]) + return [] if case == "missing" else [{ + "url": "gs://synthetic-reports/archive/longbridge/core/2026-10/test.json", + "metadata": {"updated": "2026-10-16T22:40:00Z"}, + }] + + def cat(*args, **kwargs): + calls.append(["cat", list(args), kwargs]) + return payload + + env = {"RUNTIME_HEARTBEAT_NAME": "synthetic", "RUNTIME_HEARTBEAT_REPORT_PLATFORM": "longbridge", + "RUNTIME_HEARTBEAT_REQUIRED_SERVICES": "synthetic-service", + "RUNTIME_HEARTBEAT_GCS_URIS": "gs://synthetic-reports/archive", + "RUNTIME_HEARTBEAT_SCHEDULER_AWARE": "false", "GCP_PROJECT_ID": "synthetic", + "NOTIFY_LANG": language, "RUNTIME_TARGET_ENABLED": "false" if case == "disabled" else "true"} + env.update({key: value for key, value in os.environ.items() if key.startswith(("LONGBRIDGE_UNIFIED_", "QSL_"))}) + with patch.dict(os.environ, env, clear=True), patch.object(heartbeat, "_list_gcs_objects", listed), patch.object( + heartbeat, "_cat_gcs_json", cat + ), patch.object(heartbeat, "_send_telegram", lambda text: calls.append(["send", text]) or True): + code = heartbeat.main(now=now) + return {"calls": calls, "exit_code": code} + + +@pytest.mark.parametrize("flag", [None, "", "false", "invalid"]) +@pytest.mark.parametrize("language", ["zh", "en"]) +@pytest.mark.parametrize("mode", [None, "daily_digest"]) +def test_flags_off_entrypoints_match_main_golden(monkeypatch, flag, language, mode): + if mode is None: + monkeypatch.delenv("QSL_ROUTINE_NOTIFICATION_MODE", raising=False) + else: + monkeypatch.setenv("QSL_ROUTINE_NOTIFICATION_MODE", mode) + monkeypatch.delenv("QSL_DIGEST_ROUTINE_ORDERS", raising=False) + if flag is None: + monkeypatch.delenv("LONGBRIDGE_UNIFIED_NOTIFICATION_ENABLED", raising=False) + else: + monkeypatch.setenv("LONGBRIDGE_UNIFIED_NOTIFICATION_ENABLED", flag) + golden = json.loads((Path(__file__).parent / "fixtures" / "notification_main_20e1f526.json").read_text()) + for case in ("healthy", "order", "pending", "blocked", "preview"): + assert cycle_case(case, language) == golden[language]["cycle_legacy_digest" if mode else "cycle"][case] + for case in ("healthy", "missing", "rejected", "disabled"): + assert heartbeat_case(case, language) == golden[language]["heartbeat"][case] + + +def binding_env(target_id="paper", scope="PAPER", dry_run=True): + target = {"platform_id": "longbridge", "strategy_profile": "synthetic-core", + "service_name": "synthetic-service", "account_scope": scope, + "account_selector": ["synthetic-selector"], "deployment_selector": "synthetic-slot", + "dry_run_only": dry_run} + return {unified.FLAG: "true", "RUNTIME_TARGET_ENABLED": "true", + "RUNTIME_TARGET_JSON": json.dumps(target), "DIGEST_TARGET_ID": target_id, + "DIGEST_OPAQUE_ACCOUNT_UID": "synthetic-uid", "DIGEST_MATRIX_TARGET_ID": target_id, + "DIGEST_MATRIX_ACCOUNT_SCOPE": scope, + "DIGEST_CANDIDATES_GCS_URI": "gs://synthetic-reports/candidates", "DIGEST_STORE_TARGET": target_id, + "QSL_NOTIFICATION_MARKER_GCS_URI": "gs://synthetic-reports/markers"} + + +@pytest.mark.parametrize("value", [None, "", "invalid", "false", "0", "off", "1", "yes"]) +def test_selector_off_preserves_existing_single_mode(value): + env = {"QSL_ROUTINE_NOTIFICATION_MODE": "daily_digest"} + if value is not None: + env[unified.FLAG] = value + assert not unified.enabled(env) + assert unified.routing_env(env) is env + + +def test_selector_on_maps_to_existing_mode_and_keeps_orders_immediate(): + env = {unified.FLAG: "true", "QSL_ROUTINE_NOTIFICATION_MODE": "immediate"} + mapped = unified.routing_env(env) + assert mapped["QSL_ROUTINE_NOTIFICATION_MODE"] == "daily_digest" + assert mapped["QSL_DIGEST_ROUTINE_ORDERS"] == "false" + assert env["QSL_ROUTINE_NOTIFICATION_MODE"] == "immediate" + + +@pytest.mark.parametrize("target_id,scope,dry_run", [("paper", "PAPER", True), ("hk", "HK", False), ("sg", "SG", False)]) +def test_environment_binding_never_mixes_targets(target_id, scope, dry_run): + env = binding_env(target_id, scope, dry_run) + identity, target = unified.target_binding(env) + assert identity["target_id"] == target_id + report = {"runtime_target": target, "platform": target["platform_id"], + "strategy_profile": target["strategy_profile"], "service_name": target["service_name"], + "dry_run": dry_run} + assert unified.target_binding(env, report=report)[0] == identity + report["runtime_target"] = {**target, "account_scope": "OTHER"} + with pytest.raises(ValueError, match="source_binding_mismatch"): + unified.target_binding(env, report=report) + with pytest.raises(ValueError, match="not_enabled"): + unified.target_binding({**env, "RUNTIME_TARGET_ENABLED": "false"}) + with pytest.raises(ValueError, match="matrix_binding_mismatch"): + unified.target_binding({**env, "DIGEST_MATRIX_TARGET_ID": "other"}) + with pytest.raises(ValueError, match="identity_unknown"): + unified.target_binding({**env, "DIGEST_OPAQUE_ACCOUNT_UID": ""}) + + +class MemoryStore: + """Test-only atomic storage; reconstructing it shares persisted backing.""" + def __init__(self, backing=None): + self.backing = backing if backing is not None else {} + self.calls = [] + + def read(self, key): + self.calls.append(["read", key]) + return copy.deepcopy(self.backing.get(key, (None, None))) + + def compare_and_set(self, key, version, value): + self.calls.append(["cas", key]) + current, _ = self.backing.get(key, (None, None)) + if current != version: + return False + self.backing[key] = ((current or 0) + 1, copy.deepcopy(value)) + return True + + +def source_report(env, run_id="synthetic-run", stamp="2026-10-16T20:00:00Z"): + target = json.loads(env["RUNTIME_TARGET_JSON"]) + return {"schema_version": "runtime_report.v1", "run_id": run_id, "started_at": stamp, + "runtime_target": target, "platform": target["platform_id"], "strategy_profile": target["strategy_profile"], + "service_name": target["service_name"], "dry_run": target["dry_run_only"], "status": "ok"} + + +def fake_stores(monkeypatch): + stores = {} + def factory(prefix): + return stores.setdefault(prefix, MemoryStore()) + monkeypatch.setattr(unified, "GCSNotificationStore", factory) + return stores + + +def test_on_entrypoint_writes_candidate_before_deferral_and_keeps_broker_calls(monkeypatch): + env = binding_env("sg", "SG", False) + stores = fake_stores(monkeypatch) + with patch.dict(os.environ, env, clear=True): + report = source_report(env) + observed = cycle_case("healthy", "en", notification_report=report) + assert [call[0] for call in observed["calls"]] == ["bootstrap", "snapshot", "plan", "execute"] + candidate = next(iter(stores[env["DIGEST_CANDIDATES_GCS_URI"]].backing.values()))[1] + row = candidate["runs"][0] + assert (row["order_count"], row["fill_count"], row["cycle_count"], row["actually_ran"]) == (0, 0, 1, True) + assert row["target_id"] == "sg" and row["business_day"] == "2026-10-16" + # Another worker observing the same run writes no second run. + cycle_case("healthy", "en", notification_report=report) + assert next(iter(stores[env["DIGEST_CANDIDATES_GCS_URI"]].backing.values()))[1] == candidate + + +@pytest.mark.parametrize("target_id,scope,dry_run", [("paper", "PAPER", True), ("hk", "HK", False), ("sg", "SG", False)]) +def test_candidate_rows_are_target_bound_and_never_invent_order_counts(monkeypatch, target_id, scope, dry_run): + env = binding_env(target_id, scope, dry_run) + store = MemoryStore() + report = source_report(env) + fact = unified.make_event("routine_cycle", env=env, report=report, order_count=None, fill_count=None).to_candidate() + assert unified.persist_candidate(fact, env=env, store=store, dry_run=dry_run, preview_count=2 if dry_run else None) + first = next(iter(store.backing.values()))[1] + assert first["runs"][0]["order_count"] is None and first["runs"][0]["fill_count"] is None + second_report = source_report(env, "synthetic-run-2") + second = unified.make_event("routine_cycle", env=env, report=second_report, order_count=0, fill_count=0).to_candidate() + unified.persist_candidate(second, env=env, store=store, dry_run=dry_run) + rows = next(iter(store.backing.values()))[1]["runs"] + assert [row["source_run_id"] for row in rows] == ["synthetic-run", "synthetic-run-2"] + assert all(row["cycle_count"] == 1 and row["dry_run"] is dry_run for row in rows) + assert rows[0]["order_count"] is None and rows[0]["fill_count"] is None + assert rows[1]["order_count"] == 0 and rows[1]["fill_count"] == 0 + foreign = {**fact, "target_id": "different-target"} + with pytest.raises(Exception, match="identity_unknown"): + unified.persist_candidate(foreign, env=env, store=store) + + +def test_real_orders_stay_immediate_when_on_and_counts_stay_unknown(monkeypatch): + env = binding_env("sg", "SG", False) + env["QSL_DIGEST_ROUTINE_ORDERS"] = "true" + stores = fake_stores(monkeypatch) + with patch.dict(os.environ, env, clear=True): + observed = cycle_case("order", "en", notification_report=source_report(env)) + assert [call[0] for call in observed["calls"]] == ["bootstrap", "snapshot", "plan", "execute", "send"] + row = next(iter(stores[env["DIGEST_CANDIDATES_GCS_URI"]].backing.values()))[1]["runs"][0] + assert row["order_count"] is None and row["fill_count"] is None + + +def test_candidate_failure_alerts_and_is_not_a_healthy_confirmation(monkeypatch): + env = binding_env("sg", "SG", False) + stores = fake_stores(monkeypatch) + def failed(*args, **kwargs): + raise RuntimeError("synthetic write outcome unknown") + monkeypatch.setattr(unified, "persist_candidate", failed) + with patch.dict(os.environ, env, clear=True): + report = source_report(env) + observed = cycle_case("healthy", "en", notification_report=report) + assert "candidate_persistence_unconfirmed" in report["notification_unified_errors"] + assert len([call for call in observed["calls"] if call[0] == "issue"]) == 1 + assert not [call for call in observed["calls"] if call[0] == "send"] + marker = next(iter(stores[env["QSL_NOTIFICATION_MARKER_GCS_URI"]].backing.values()))[1] + assert marker["delivery_status"] == "uncertain" # notify_issue has no positive transport ACK + + +def test_real_order_notification_survives_candidate_storage_failure(monkeypatch): + env = binding_env("sg", "SG", False) + fake_stores(monkeypatch) + monkeypatch.setattr(unified, "persist_candidate", lambda *a, **kw: (_ for _ in ()).throw(TimeoutError("synthetic write uncertainty"))) + with patch.dict(os.environ, env, clear=True): + report = source_report(env) + observed = cycle_case("order", "en", notification_report=report) + assert len([call for call in observed["calls"] if call[0] == "send"]) == 1 + assert "candidate_persistence_unconfirmed" in report["notification_unified_errors"] + + +def test_immediate_markers_survive_workers_recovery_and_severity_up(monkeypatch): + env = binding_env("sg", "SG", False) + backing = {} + sent = [] + event = unified.make_event("receipt_missing", env=env, report=source_report(env), incident="execution_receipt") + def send(): + sent.append("alert") + return True + assert unified.send_incident(event, send, env=env, store=MemoryStore(backing)) + repeated = unified.replace(event, source_run_id="another-worker") + assert not unified.send_incident(repeated, send, env=env, store=MemoryStore(backing)) + assert unified.send_incident(repeated, send, env=env, store=MemoryStore(backing), severity=2) + recovered = unified.replace(event, occurred_at=event.occurred_at + dt.timedelta(minutes=2)) + assert unified.observe_incident(recovered, env=env, store=MemoryStore(backing), state="accepted", recovered=True)[0].action == "recovered" + worse = unified.replace(recovered, occurred_at=recovered.occurred_at + dt.timedelta(minutes=1)) + assert unified.send_incident(worse, send, env=env, store=MemoryStore(backing)) + assert len(sent) == 3 + # A late report cannot establish recovery or create another event. + assert unified.observe_incident(event, env=env, store=MemoryStore(backing), state="accepted", recovered=True)[0].action == "duplicate" + assert not unified.send_incident(worse, send, env=env, store=MemoryStore(backing)) + + +@pytest.mark.parametrize("outcome", [False, None, "raise"]) +def test_uncertain_or_failed_send_is_never_retried(monkeypatch, outcome): + env = binding_env("sg", "SG", False) + backing = {} + event = unified.make_event("auth_failure", env=env, report=source_report(env), incident="auth_failure") + calls = [] + def send(): + calls.append(1) + if outcome == "raise": + raise TimeoutError("synthetic uncertain ACK") + return outcome + if outcome == "raise": + with pytest.raises(TimeoutError): + unified.send_incident(event, send, env=env, store=MemoryStore(backing)) + else: + assert not unified.send_incident(event, send, env=env, store=MemoryStore(backing)) + assert not unified.send_incident(event, send, env=env, store=MemoryStore(backing)) + assert calls == [1] + assert next(iter(backing.values()))[1]["delivery_status"] == "uncertain" + + +def test_marker_storage_failure_never_falls_back_to_untracked_send(): + env = binding_env("sg", "SG", False) + class Unreadable(MemoryStore): + def read(self, key): + raise OSError("synthetic incomplete history") + event = unified.make_event("real_error", env=env, report=source_report(env), incident="failure") + with pytest.raises(OSError): + unified.send_incident(event, lambda: pytest.fail("untracked send"), env=env, store=Unreadable()) + + +def test_incident_scope_separates_account_target_strategy_and_reason(): + env = binding_env("sg", "SG", False) + event = unified.make_event("real_error", env=env, report=source_report(env), incident="failure") + store = MemoryStore() + variants = [event, unified.replace(event, target_id="other-target"), + unified.replace(event, opaque_account_uid="other-account"), unified.replace(event, strategy_profile="other-strategy"), + unified.replace(event, incident_reason="other-reason")] + for item in variants: + assert unified.send_incident(item, lambda: True, env=env, store=store) + assert len(store.backing) == 5 + + +def test_partial_candidate_conflict_keeps_earlier_alert_and_unknown_counts(): + env = binding_env("sg", "SG", False) + event = unified.make_event("blocked_run", env=env, report=source_report(env), incident="cycle_attention", order_count=1, fill_count=0) + store = MemoryStore() + unified.persist_candidate(event.to_candidate(), env=env, store=store) + changed = unified.replace(event, order_count=0, fill_count=0) + unified.persist_candidate(changed.to_candidate(), env=env, store=store) + payload = next(iter(store.backing.values()))[1] + assert payload["notification_conflicts"] and len(payload["notification_events"]) == 2 + assert payload["runs"][0]["status"] == "alert" and payload["runs"][0]["order_count"] is None + + +def test_snapshot_matches_the_pinned_qpk_contract(): + import hashlib + from notifications.digest_snapshot import UNIFIED_CONTRACT_SHA256, UNIFIED_CONTRACT_QPK_SHA + path = Path(__file__).parents[1] / "notifications" / "digest_snapshot" / "unified_contract.py" + assert hashlib.sha256(path.read_bytes()).hexdigest() == UNIFIED_CONTRACT_SHA256 + assert UNIFIED_CONTRACT_QPK_SHA == "07e24c0c804a9b47316b6ec3c75bb63820d8f421" + + +@pytest.mark.parametrize("language", ["zh", "en"]) +def test_heartbeat_entrypoint_dedupes_across_runs_and_recovers_only_from_new_receipt(monkeypatch, language): + env = binding_env("sg", "SG", False) + env.update(RUNTIME_HEARTBEAT_REPORT_PLATFORM="longbridge", RUNTIME_HEARTBEAT_SCHEDULER_AWARE="false", + RUNTIME_HEARTBEAT_REQUIRED_SERVICES="synthetic-service", NOTIFY_LANG=language, GITHUB_RUN_ID="monitor-1") + fake_stores(monkeypatch) + sent, source = [], {"payload": None} + now = dt.datetime(2026, 10, 17, 0, tzinfo=dt.timezone.utc) + monkeypatch.setattr(heartbeat, "load_runtime_targets", lambda env: []) + monkeypatch.setattr(heartbeat, "_resolve_required_services", lambda **kw: (["synthetic-service"], None, False)) + monkeypatch.setattr(heartbeat, "_report_globs", lambda *args: ["gs://synthetic-reports/archive/*"]) + monkeypatch.setattr(heartbeat, "_list_gcs_objects", lambda *args, **kw: [] if source["payload"] is None else [{ + "url": "gs://synthetic-reports/archive/test.json", "metadata": {"updated": source["updated"]}}]) + monkeypatch.setattr(heartbeat, "_cat_gcs_json", lambda *args, **kw: source["payload"]) + monkeypatch.setattr(heartbeat, "_send_telegram", lambda text: sent.append(text) or True) + with patch.dict(os.environ, env, clear=True): + assert heartbeat.main(now=now) == 1 + os.environ["GITHUB_RUN_ID"] = "monitor-2" + assert heartbeat.main(now=now + dt.timedelta(minutes=5)) == 1 + assert len(sent) == 1 + # A rewritten old object may pass the unchanged heartbeat lookback; + # its upload timestamp cannot establish incident recovery. + source.update(payload=source_report(env, "old-runtime", "2026-10-16T20:00:00Z"), updated="2026-10-17T00:06:00Z") + os.environ["GITHUB_RUN_ID"] = "monitor-old-object" + assert heartbeat.main(now=now + dt.timedelta(minutes=7)) == 0 + source["payload"] = None + os.environ["GITHUB_RUN_ID"] = "monitor-still-unrecovered" + assert heartbeat.main(now=now + dt.timedelta(minutes=8)) == 1 + assert len(sent) == 1 + source.update(payload=source_report(env, "new-runtime", "2026-10-17T00:09:00Z"), updated="2026-10-17T00:09:00Z") + os.environ["GITHUB_RUN_ID"] = "monitor-3" + assert heartbeat.main(now=now + dt.timedelta(minutes=10)) == 0 + source["payload"] = None + os.environ["GITHUB_RUN_ID"] = "monitor-4" + assert heartbeat.main(now=now + dt.timedelta(minutes=15)) == 1 + assert len(sent) == 2 + assert all("gs://" not in text and "synthetic-service" not in text for text in sent) + if language == "en": + assert not any("执行" in text for text in sent) + else: + assert all("Execution" not in text and "receipt_missing" not in text for text in sent) + + +def test_heartbeat_disabled_on_still_skips_all_io(monkeypatch): + env = binding_env() + env["RUNTIME_TARGET_ENABLED"] = "false" + monkeypatch.setattr(heartbeat, "_list_gcs_objects", lambda *a, **kw: pytest.fail("disabled GCS read")) + monkeypatch.setattr(unified, "GCSNotificationStore", lambda *a: pytest.fail("disabled marker/candidate access")) + with patch.dict(os.environ, env, clear=True): + assert heartbeat.main() == 0 + + +@pytest.mark.parametrize("paper", [False, True]) +def test_archive_and_runtime_share_run_identity_without_double_counting(paper): + env = binding_env("paper" if paper else "sg", "PAPER" if paper else "SG", paper) + env["EXECUTION_REPORT_GCS_URI"] = "gs://synthetic-reports/archive" + store = MemoryStore() + report = source_report(env) + event = unified.make_event("routine_cycle", env=env, report=report, + order_count=None if paper else 0, fill_count=None if paper else 0) + facts = {"schema_version": "qsl.notification_cycle.v1", "dry_run": paper, + "order_count": event.order_count, "fill_count": event.fill_count, + "event": event.to_candidate(), "preview_count": 2 if paper else None} + report["summary"] = {"notification_digest": facts} + unified.persist_candidate(event.to_candidate(), env=env, store=store, dry_run=paper, preview_count=facts["preview_count"]) + before = copy.deepcopy(store.backing) + def read(args): + if args[0] == "ls": + return [{"url": "gs://synthetic-reports/archive/longbridge/core/2026-10/test.json", + "metadata": {"updated": "2026-10-16T20:01:00Z"}}] + return report + result = unified.emit_archived(env, now=dt.datetime(2026, 10, 17, 0, tzinfo=dt.timezone.utc), read=read, store=store) + assert result["runs"] == 1 and store.backing == before + row = next(iter(store.backing.values()))[1]["runs"][0] + assert row["cycle_count"] == 1 and row["preview_count"] == (2 if paper else None) + + +def test_no_run_window_is_not_fabricated_and_proved_idle_has_no_message_intent(monkeypatch): + env = binding_env("sg", "SG", False) + env["EXECUTION_REPORT_GCS_URI"] = "gs://synthetic-reports/archive" + store = MemoryStore() + now = dt.datetime(2026, 10, 17, 0, tzinfo=dt.timezone.utc) + with pytest.raises(ValueError, match="candidate_source_absent"): + unified.emit_archived(env, now=now, read=lambda args: [], store=store) + assert not store.backing + env["DIGEST_NOTIFICATION_SCHEDULE_JSON"] = json.dumps({"scheduler": {"main_time": "0 20 * * 1-5", "timezone": "UTC"}}) + import quant_platform_kit.common.runtime_heartbeat_policy as policy + monkeypatch.setattr(policy, "filter_due_targets", lambda *args, **kw: ([], True)) + unified.emit_archived(env, now=now, read=lambda args: [], store=store) + payload = next(iter(store.backing.values()))[1] + assert payload["runs"][0]["actually_ran"] is False and payload["runs"][0]["scheduled_idle"] is True + assert "order_count" not in payload["runs"][0] and not payload["notification_events"] + monkeypatch.setattr(policy, "filter_due_targets", lambda *args, **kw: ([{}], True)) + with pytest.raises(ValueError, match="candidate_due_source_absent"): + unified.emit_archived(env, now=now, read=lambda args: [], store=MemoryStore()) + + +def test_workflow_keeps_schedule_target_matrix_and_canary_unchanged(): + root = Path(__file__).parents[1] + workflow = (root / ".github/workflows/execution-report-heartbeat.yml").read_text() + assert '- cron: "20 22 * * *"' in workflow + assert "matrix: ${{ fromJSON(needs.resolve-matrix.outputs.matrix) }}" in workflow + assert "LONGBRIDGE_UNIFIED_NOTIFICATION_ENABLED: ${{ vars.LONGBRIDGE_UNIFIED_NOTIFICATION_ENABLED || 'false' }}" in workflow + assert "DIGEST_MATRIX_TARGET_ID: ${{ matrix.target.id }}" in workflow + assert "DIGEST_MATRIX_ACCOUNT_SCOPE: ${{ matrix.target.label }}" in workflow + assert "INSPECT_SG_PROBE_PERMISSIONS != 'true'" in workflow + assert "QSL_NOTIFICATION_MARKER_GCS_URI:" in workflow + + +def test_new_cycle_reason_alerts_and_healthy_run_recovers_all_observed_reasons(): + env = binding_env("sg", "SG", False) + store, calls = MemoryStore(), [] + event = unified.make_event("blocked_run", env=env, report=source_report(env), incident="cycle_attention:blocked_run:reason_a") + second = unified.replace(event, incident_reason="cycle_attention:blocked_run:reason_b") + for item in (event, second): + assert unified.send_incident(item, lambda: calls.append(1) or True, env=env, store=store) + unified.recover_cycle_incidents(unified.replace(event, occurred_at=event.occurred_at + dt.timedelta(minutes=1)), env=env, store=store) + assert unified.send_incident(unified.replace(event, occurred_at=event.occurred_at + dt.timedelta(minutes=2)), + lambda: calls.append(1) or True, env=env, store=store) + assert len(calls) == 3 + + +def test_gcs_adapter_uses_atomic_generations_and_does_not_retry_uncertain_writes(monkeypatch): + from google.api_core.exceptions import NotFound, PreconditionFailed, Forbidden + from google.cloud import storage + from notifications.unified_store import GCSNotificationStore + objects, operations = {}, [] + class Blob: + def __init__(self, uri): + self.uri, self.generation = uri, None + def reload(self, **kwargs): + operations.append(["reload", kwargs]) + if self.uri not in objects: + raise NotFound("synthetic absent") + self.generation = objects[self.uri][0] + def download_as_bytes(self, **kwargs): + operations.append(["download", kwargs]) + assert kwargs["if_generation_match"] == objects[self.uri][0] + return objects[self.uri][1].encode() + def upload_from_string(self, body, **kwargs): + operations.append(["upload", kwargs]) + generation = objects.get(self.uri, (0, ""))[0] + if kwargs["if_generation_match"] != generation: + raise PreconditionFailed("synthetic CAS race") + objects[self.uri] = (generation + 1, body) + monkeypatch.setattr(storage.Blob, "from_string", lambda uri, **kwargs: Blob(uri)) + store = GCSNotificationStore("gs://synthetic-reports/markers", client=object()) + assert store.read("synthetic") == (None, None) + assert store.compare_and_set("synthetic", None, {"active": True}) + version, value = store.read("synthetic") + assert version == 1 and value == {"active": True} + assert not store.compare_and_set("synthetic", None, {"active": False}) + assert all(operation[1]["retry"] is None for operation in operations) + assert next(operation for operation in operations if operation[0] == "upload")[1]["if_generation_match"] == 0 + failures = [] + class Uncertain(Blob): + def upload_from_string(self, *args, **kwargs): + failures.append(1) + raise TimeoutError("synthetic unknown write result") + monkeypatch.setattr(storage.Blob, "from_string", lambda uri, **kwargs: Uncertain(uri)) + with pytest.raises(TimeoutError): + store.compare_and_set("uncertain", None, {}) + assert failures == [1] + class Incomplete(Blob): + def reload(self, **kwargs): + raise Forbidden("synthetic unreadable history") + monkeypatch.setattr(storage.Blob, "from_string", lambda uri, **kwargs: Incomplete(uri)) + with pytest.raises(Forbidden): + store.read("unreadable") + class Disappeared(Blob): + def download_as_bytes(self, **kwargs): + raise NotFound("synthetic marker disappeared after metadata read") + monkeypatch.setattr(storage.Blob, "from_string", lambda uri, **kwargs: Disappeared(uri)) + with pytest.raises(NotFound): + store.read("synthetic") + + +def test_central_payload_exports_per_run_events_and_keeps_ledger_private(): + env = binding_env("sg", "SG", False) + candidate, ledger = MemoryStore(), MemoryStore() + fact = unified.make_event("routine_cycle", env=env, report=source_report(env), order_count=0, fill_count=0).to_candidate() + unified.persist_candidate(fact, env=env, store=candidate, state_store=ledger) + payload = next(iter(candidate.backing.values()))[1] + assert set(payload) == {"schema_version", "producer_status", "runs"} + row = payload["runs"][0] + assert row["execution_status"] == "ok_no_order" + assert row["evidence_persistence_status"] == "persisted" and row["evidence_delivery_status"] == "pending_central" + assert not ({"source_run_ids", "dry_run", "preview_count", "window_id"} & set(row)) + assert row["source_run_id"] == fact["source_run_id"] + assert row["notification_events"] == [fact] + assert next(iter(ledger.backing.values()))[1]["notification_events"] + + +def test_candidate_export_cannot_overwrite_a_newer_ledger_or_legacy_history(): + env = binding_env("sg", "SG", False) + candidate, ledger = MemoryStore(), MemoryStore() + fact = unified.make_event("routine_cycle", env=env, report=source_report(env), order_count=0, fill_count=0).to_candidate() + key = "longbridge/2026-10-16/sg" + candidate.compare_and_set(key, None, {"schema_version": "qsl.digest_candidates.v1", "runs": []}) + with pytest.raises(ValueError, match="history_unknown"): + unified.persist_candidate(fact, env=env, store=candidate, state_store=ledger) + assert not ledger.backing + + +@pytest.mark.parametrize("language", ["zh", "en"]) +def test_risk_attention_persists_transitions_without_raw_strategy_keys(monkeypatch, language): + from quant_platform_kit.risk.attention import AttentionAxes, evaluate_attention + env = binding_env("sg", "SG", False) + env["NOTIFY_LANG"] = language + fake_stores(monkeypatch) + sent = [] + action = evaluate_attention(AttentionAxes(new_risk_prohibited=True)) + halt = evaluate_attention(AttentionAxes(hard_park=True)) + healthy = evaluate_attention(AttentionAxes(new_risk_prohibited=False, operational_uncertain=False)) + def send(**kwargs): + sent.append(kwargs["text"]) + return True + with patch.dict(os.environ, env, clear=True): + for index, (decision, recover, expected) in enumerate([ + (action, False, 1), (action, False, 0), (halt, False, 1), + (healthy, True, 0), (action, False, 1), + ]): + report = source_report(env, f"risk-run-{index}", f"2026-10-16T20:0{index}:00Z") + with unified.bind_report(report): + assert unified.publish_attention(decision, recover=recover, telegram_sender=send)["sent"] == expected + assert len(sent) == 3 and all("synthetic-core" not in text and "synthetic-uid" not in text for text in sent) + assert all(("未命名策略" in text) if language == "zh" else ("Unnamed strategy" in text) for text in sent) + + +def test_runtime_incident_recovery_requires_successfully_persisted_terminal_proof(monkeypatch): + env = binding_env("sg", "SG", False) + fake_stores(monkeypatch) + sent = [] + with patch.dict(os.environ, env, clear=True): + report = source_report(env) + def send(): + sent.append(1) + return True + assert unified.dispatch_immediate(send, kind="real_error", incident="runtime_failure:strategy_cycle:unknown", report=report) + assert not unified.dispatch_immediate(send, kind="real_error", incident="runtime_failure:strategy_cycle:unknown", report=report) + failed = source_report(env, "failed", "2026-10-16T20:01:00Z") + failed["status"] = "error" + unified.recover_runtime(report=failed) + assert not unified.dispatch_immediate(send, kind="real_error", incident="runtime_failure:strategy_cycle:unknown", report=failed) + good = source_report(env, "healthy", "2026-10-16T20:02:00Z") + good["summary"] = {"execution_status": "no_action", "orders_pending_count": 0} + unified.recover_runtime(report=good) + worse = source_report(env, "worse", "2026-10-16T20:03:00Z") + assert unified.dispatch_immediate(send, kind="real_error", incident="runtime_failure:strategy_cycle:unknown", report=worse) + assert len(sent) == 2 + + +def test_runtime_issue_callback_dedupes_categories_and_recovers_after_a_clean_run(monkeypatch): + from application.runtime_notification_adapters import build_runtime_notification_adapters + env = binding_env("sg", "SG", False) + fake_stores(monkeypatch) + sent = [] + adapters = build_runtime_notification_adapters(with_prefix=lambda text: text, + send_message=lambda text: sent.append(text) or True, translator=build_translator("en"), + fetch_order_status=lambda *a, **kw: pytest.fail("broker request"), + order_poll_interval_sec=0, order_poll_max_attempts=0, sleeper=lambda seconds: None, + log_message=lambda text: None) + with patch.dict(os.environ, env, clear=True): + for run in ("run-a", "run-b"): + with unified.bind_report(source_report(env, run)): + adapters.notify_issue("Synthetic failure", "synthetic detail") + assert len(sent) == 1 + with unified.bind_report(source_report(env, "run-b")): + adapters.notify_issue("Another synthetic failure", "synthetic detail") + assert len(sent) == 2 + report = source_report(env, "clean-run", "2026-10-16T20:01:00Z") + report["summary"] = {"execution_status": "no_action"} + unified.recover_runtime(report=report) + with unified.bind_report(source_report(env, "run-c", "2026-10-16T20:02:00Z")): + adapters.notify_issue("Synthetic failure", "synthetic detail") + assert len(sent) == 3 and len(adapters.delivery_events) == 3 + + +def test_boolean_ack_is_uncertain_without_receipt_and_not_resent(): + env = binding_env("sg", "SG", False) + store, calls = MemoryStore(), [] + event = unified.make_event("real_error", env=env, report=source_report(env), incident="failure") + assert unified.send_incident(event, lambda: calls.append(1) or True, env=env, store=store) + marker = next(iter(store.backing.values()))[1] + assert marker["delivery_status"] == "uncertain" + assert marker["receipt_id"] is None + assert not unified.send_incident(event, lambda: calls.append(1) or True, env=env, store=store) + assert calls == [1] + + +def test_only_sender_supplied_receipt_marks_delivered(): + env = binding_env("sg", "SG", False) + store = MemoryStore() + event = unified.make_event("real_error", env=env, report=source_report(env), incident="failure") + receipt = "synthetic-send-history-receipt-1" + assert unified.send_incident(event, lambda: {"receipt_id": receipt}, env=env, store=store) + marker = next(iter(store.backing.values()))[1] + assert marker["delivery_status"] == "delivered" + assert marker["receipt_id"] == receipt + + +def test_export_keeps_one_row_and_inline_events_for_each_business_run(): + env = binding_env("sg", "SG", False) + candidate, ledger = MemoryStore(), MemoryStore() + for run_id, count in (("synthetic-a", 0), ("synthetic-b", 2), ("synthetic-b", 2)): + event = unified.make_event("routine_cycle", env=env, report=source_report(env, run_id), + order_count=count, fill_count=count) + unified.persist_candidate(event.to_candidate(), env=env, store=candidate, state_store=ledger) + payload = next(iter(candidate.backing.values()))[1] + assert len(payload["runs"]) == 2 + rows = {row["source_run_id"]: row for row in payload["runs"]} + for run_id, count in (("synthetic-a", 0), ("synthetic-b", 2)): + row = rows[run_id] + assert row["order_count"] == count + assert row["cycle_count"] == 1 + assert len(row["notification_events"]) == 1 + assert row["notification_events"][0]["source_run_id"] == run_id + + +def test_conflicting_run_counts_do_not_taint_another_run(): + env = binding_env("sg", "SG", False) + candidate, ledger = MemoryStore(), MemoryStore() + for run_id, count in (("synthetic-a", 1), ("synthetic-b", 0), ("synthetic-a", 2), ("synthetic-a", 1)): + event = unified.make_event("routine_cycle", env=env, report=source_report(env, run_id), + order_count=count, fill_count=0) + unified.persist_candidate(event.to_candidate(), env=env, store=candidate, state_store=ledger) + rows = {row["source_run_id"]: row for row in next(iter(candidate.backing.values()))[1]["runs"]} + assert rows["synthetic-a"]["order_count"] is None + assert rows["synthetic-a"]["field_status"]["order_count"] == "unknown" + assert rows["synthetic-a"]["fill_count"] == 0 + assert rows["synthetic-b"]["order_count"] == rows["synthetic-b"]["fill_count"] == 0 + assert rows["synthetic-b"]["status"] == "ok" + + +@pytest.mark.parametrize("receipt_kind", ["transport_ack", "transition", "event"]) +def test_local_identity_is_not_a_delivery_receipt(receipt_kind): + env = binding_env("sg", "SG", False) + store = MemoryStore() + event = unified.make_event("real_error", env=env, report=source_report(env), incident="failure") + def send(): + marker = next(iter(store.backing.values()))[1] + receipt = {"transport_ack": f"transport-ack:{marker['transition_key']}", + "transition": marker["transition_key"], "event": event.to_candidate()["dedupe_key"]}[receipt_kind] + return {"receipt_id": receipt} + assert not unified.send_incident(event, send, env=env, store=store) + marker = next(iter(store.backing.values()))[1] + assert marker["receipt_id"] is None and marker["delivery_status"] == "uncertain" diff --git a/tests/test_notification_qrs_roundtrip.py b/tests/test_notification_qrs_roundtrip.py new file mode 100644 index 0000000..d46880d --- /dev/null +++ b/tests/test_notification_qrs_roundtrip.py @@ -0,0 +1,200 @@ +"""Real LongBridge exports through a pinned, offline, test-only QRS receiver.""" +from __future__ import annotations + +import hashlib +import importlib +import json +import os +import sys +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + +from notifications import unified +from notifications.digest_snapshot.digest_candidates import cycle_evidence +from test_notification_consolidation import MemoryStore, binding_env, fake_stores, source_report + + +VENDOR = Path(__file__).parent / "vendor" / "qrs_notification" +QRS_SHA = "fda2333316593fa6a1585695021ab33ed76a9bcc" + + +@pytest.fixture +def qrs(monkeypatch): + names = ("merge_digest_candidates", "notification_candidate_adapter", "daily_digest_notify", + "display_names", "notification_mode", "notification_snapshot", "unified_daily_digest") + previous = {name: module for name, module in sys.modules.items() + if any(name == prefix or name.startswith(prefix + ".") for prefix in names)} + for name in previous: + del sys.modules[name] + monkeypatch.syspath_prepend(str(VENDOR)) + monkeypatch.setenv("QRS_UNIFIED_NOTIFICATION_ENABLED", "true") + try: + yield SimpleNamespace( + merge=importlib.import_module("merge_digest_candidates"), + intake=importlib.import_module("notification_candidate_adapter"), + digest=importlib.import_module("daily_digest_notify"), + renderer=importlib.import_module("unified_daily_digest"), + ) + finally: + for name in list(sys.modules): + if any(name == prefix or name.startswith(prefix + ".") for prefix in names): + del sys.modules[name] + sys.modules.update(previous) + + +def archived_report(env, run_id, orders=0): + """Synthetic broker response facts; no broker, transport or runtime call.""" + report = source_report(env, run_id) + responses = tuple({"status": "filled", "fills": [{"execution_id": f"synthetic-fill-{index}"}]} + for index in range(orders)) + report["summary"] = {"notification_digest": cycle_evidence( + {"execution_status": "completed" if orders else "no_action", "broker_submission_done": bool(orders)}, + responses, dry_run=False)} + return report + + +def export_reports(env, reports, store=None): + store = store or MemoryStore() + by_uri = {f"gs://synthetic-reports/archive/longbridge/core/2026-10/{index}.json": report + for index, report in enumerate(reports)} + def read(args): + if args[0] == "ls": + return [{"url": uri, "metadata": {"updated": "2026-10-16T20:01:00Z"}} for uri in by_uri] + return by_uri[args[1]] + result = unified.emit_archived({**env, "EXECUTION_REPORT_GCS_URI": "gs://synthetic-reports/archive"}, + now=unified._stamp("2026-10-17T00:00:00Z"), read=read, store=store) + assert result["uploaded"] + return store, candidate_from(store) + + +def candidate_from(store): + return next(value for key, (_, value) in store.backing.items() if not key.startswith("candidate_state/")) + + +def receive(qrs, tmp_path, env, payload, *, duplicate=False): + directory = tmp_path / "candidates" + directory.mkdir() + (directory / "producer.json").write_text(json.dumps(payload)) + if duplicate: + (directory / "duplicate-observation.json").write_text(json.dumps(payload)) + merged, stats = qrs.merge.merge(None, directory) + expected = [unified.target_binding(env)[0]] + qrs.intake.validate_expected(expected) + raw = qrs.merge._rows(merged) + for row in raw: + qrs.intake.validate_run_row(row) + current, stale = qrs.intake.partition(raw, "2026-10-16") + assert not stale + rows, summary = qrs.intake.aggregate(current, expected) + assert summary["source_inventory"]["longbridge"] == { + "expected_targets": 1, "covered_targets": 1, "runs": len(rows)} + assert not summary["run_count_unknown"] + assert not summary["counts_scope_unknown"] + return rows, summary, stats + + +@pytest.mark.parametrize("language", ["zh", "en"]) +@pytest.mark.parametrize("run_ids", [("synthetic-a",), ("synthetic-a", "synthetic-b")]) +def test_healthy_archived_runs_roundtrip_are_known_zero(qrs, tmp_path, language, run_ids): + env = binding_env("sg", "SG", False) + _, payload = export_reports(env, [archived_report(env, run_id) for run_id in run_ids]) + rows, summary, _ = receive(qrs, tmp_path, env, payload) + assert len(rows) == len(run_ids) and summary["event_count"] == len(run_ids) + assert summary["immediate_events"] == 0 and not summary["counts_unknown"] + assert all(row["order_count"] == row["fill_count"] == 0 and row["cycle_count"] == 1 for row in rows) + entries = qrs.digest.filter_runs_for_digest(rows) + assert all(entry.order_count_status == entry.fill_count_status == "known" for entry in entries) + text = qrs.renderer.render(qrs.digest.DailyDigestInput( + business_day="2026-10-16", locale=language, runs=entries, notification_summary=summary, + evidence_coverage=qrs.digest.EvidenceCoverage(candidates_configured=True, candidates_loaded=True, + candidates_count=len(rows)))) + # Healthy zero-order runs render known zero, rather than unknown statistics. + assert ("订单:0" if language == "zh" else "Orders: 0") in text + assert ("未知" if language == "zh" else "unknown") not in text + zero = (("本轮 0 单" if len(run_ids) == 1 else "窗口内各轮均为 0 单") if language == "zh" else + ("0 orders this run" if len(run_ids) == 1 else "0 orders in every observed run")) + assert zero in text + + +def test_zero_and_real_order_runs_roundtrip_with_duplicate_observations(qrs, tmp_path): + env = binding_env("sg", "SG", False) + reports = [archived_report(env, "synthetic-zero"), archived_report(env, "synthetic-orders", orders=2)] + store, _ = export_reports(env, reports) + # The immediate individual order event is evidence, not another run counter. + event = unified.make_event("order_submitted", env=env, report=reports[1], order_count=1, + order_execution_digest=unified._hash("synthetic-order")) + unified.persist_candidate(event.to_candidate(), env=env, actually_ran=False, store=store) + store, payload = export_reports(env, reports, store) + rows, summary, stats = receive(qrs, tmp_path, env, payload, duplicate=True) + assert stats["files"] == 2 and stats["file_rows"] == 4 + assert len(rows) == 2 and sum(row["order_count"] for row in rows) == 2 + assert sum(row["fill_count"] for row in rows) == 2 + assert summary["event_count"] == 3 and summary["immediate_events"] == 1 + assert not summary["counts_unknown"] and summary["count_conflicts"] == 0 + for row in rows: + assert row["cycle_count"] == 1 + assert all(fact["source_run_id"] == row["source_run_id"] for fact in row["notification_events"]) + + +def test_next_window_heartbeat_attaches_to_receipt_business_run(qrs, tmp_path, monkeypatch): + env = binding_env("sg", "SG", False) + env["GITHUB_RUN_ID"] = "synthetic-monitor-job" + stores = fake_stores(monkeypatch) + report = archived_report(env, "synthetic-business-run") + with patch.dict(os.environ, env, clear=True): + for _ in range(2): + unified.heartbeat_observation(now=unified._stamp("2026-10-17T00:20:00Z"), report=report, + recovery_at=report["started_at"]) + payload = candidate_from(stores[env["DIGEST_CANDIDATES_GCS_URI"]]) + rows, summary, _ = receive(qrs, tmp_path, env, payload) + assert len(rows) == 1 and summary["event_count"] == 2 and not summary["counts_unknown"] + assert rows[0]["source_run_id"] == "synthetic-business-run" + assert rows[0]["business_day"] == "2026-10-16" + assert {fact["event_kind"] for fact in rows[0]["notification_events"]} == {"routine_cycle", "healthy_heartbeat"} + assert all(fact["source_run_id"] != env["GITHUB_RUN_ID"] for fact in rows[0]["notification_events"]) + + +def test_missing_receipt_monitor_never_exports_a_synthetic_business_run(monkeypatch): + env = binding_env("sg", "SG", False) + stores = fake_stores(monkeypatch) + calls = [] + with patch.dict(os.environ, env, clear=True): + assert unified.dispatch_immediate(lambda: calls.append(1) or True, kind="receipt_missing", + incident="execution_receipt", occurred_at="2026-10-17T00:20:00Z", run_id="synthetic-monitor") + assert calls == [1] + assert env["DIGEST_CANDIDATES_GCS_URI"] not in stores + marker = next(iter(stores[env["QSL_NOTIFICATION_MARKER_GCS_URI"]].backing.values()))[1] + assert marker["delivery_status"] == "uncertain" and marker["receipt_id"] is None + + +def test_qrs_test_only_sources_match_recorded_hashes(): + manifest = json.loads((VENDOR / "upstream.json").read_text()) + assert manifest["commit"] == QRS_SHA and manifest["test_only"] is True + for name, source in manifest["files"].items(): + assert hashlib.sha256((VENDOR / name).read_bytes()).hexdigest() == source["sha256"] + + +@pytest.mark.parametrize("archive_first", [False, True]) +def test_archive_counts_resolve_runtime_unknown_for_only_the_same_run(qrs, tmp_path, archive_first): + env = binding_env("sg", "SG", False) + report = archived_report(env, "synthetic-orders", orders=2) + store = MemoryStore() + event = unified.make_event("routine_cycle", env=env, report=report) + if not archive_first: + unified.persist_candidate(event.to_candidate(), env=env, store=store) + store, _ = export_reports(env, [report, archived_report(env, "synthetic-zero")], store) + if archive_first: + unified.persist_candidate(event.to_candidate(), env=env, store=store) + rows, summary, _ = receive(qrs, tmp_path, env, candidate_from(store)) + counts = {row["source_run_id"]: row["order_count"] for row in rows} + assert counts == {"synthetic-orders": 2, "synthetic-zero": 0} + assert not summary["counts_unknown"] and summary["event_count"] == 2 + + +def test_heartbeat_without_business_receipt_stays_unknown_and_off_is_unchanged(): + assert unified.heartbeat_observation(now=None, env={}) is None + with pytest.raises(ValueError, match="receipt_business_run_unknown"): + unified.heartbeat_observation(now=unified._stamp("2026-10-17T00:20:00Z"), env=binding_env("sg", "SG", False)) diff --git a/tests/vendor/qrs_notification/daily_digest_notify.py b/tests/vendor/qrs_notification/daily_digest_notify.py new file mode 100644 index 0000000..cf14ccb --- /dev/null +++ b/tests/vendor/qrs_notification/daily_digest_notify.py @@ -0,0 +1,1646 @@ +#!/usr/bin/env python3 +"""QuantSentinel daily digest message contract (zh/en). + +Pure formatter: no Telegram I/O, no secret reads. Aggregators collect +which platforms/strategies actually ran (plus optional observation +fields when producers supply them), then call ``render_daily_digest`` +and send via the unified bot secret ``quant-sentinel-telegram-bot-token``. + +Observation fields (equity, holdings, signal, rebalance) are rendered +only when present on the evidence row — never invented. + +Count semantics: + - int 0 with known status / legacy producer row → verified zero(无成交) + - None / field_status counts_unknown → unknown(未知), never rendered as 0 +""" + +from __future__ import annotations + +from display_names import display_name + +import re + +from dataclasses import dataclass, field +from typing import Any, Literal, Mapping, Sequence + +DigestLocale = Literal["zh", "en"] +DigestLayout = Literal["v1", "sections_v2"] +DIGEST_LAYOUTS: tuple[str, ...] = ("v1", "sections_v2") +DIGEST_SECTIONS: tuple[str, ...] = ("us_daily", "dca", "crypto_intraday") +RebalanceKind = Literal[ + "", + "no_rebalance", + "rebalance", + "no_order", + "pending", +] +EvidenceStatus = Literal[ + "verified_idle", + "evidence_unknown", + "has_runs", +] +CountFieldStatus = Literal["known", "unknown", ""] +MISSING_EVIDENCE_REASONS = frozenset({ + "EVIDENCE_NOT_FOUND", "EVIDENCE_UNREADABLE", "EVIDENCE_STALE_BUSINESS_DAY", "SOURCE_NOT_CONFIGURED", +}) + +_TEXTS: dict[str, dict[str, str]] = { + "zh": { + "daily_digest_title": "📡 {sender} · 收盘日报", + "daily_digest_heartbeat_title": "📡 {sender} · 心跳", + "date_label": "业务日", + "window_label": "窗口", + "ran_section": "【当日已运行】", + "fills_label": "成交", + "orders_label": "订单", + "cycles_label": "周期", + "no_fill": "无成交", + "trade_count": "{label} {count} 笔", + "no_orders": "无订单", + "counts_unknown": "未知", + "counts_not_connected": "未接入统计", + "status_ok": "正常", + "status_alert": "异常", + "heartbeat_no_run_verified": "今日已验证无平台/策略实际运行。", + "heartbeat_evidence_unknown": ( + "今日运行证据未知或读取失败,不能判定为链路正常。" + ), + "heartbeat_no_fill_verified": "今日有运行且已验证零成交。", + "heartbeat_fills_unknown": "今日有运行,成交数未知(证据未覆盖)。", + "heartbeat_orders_unknown": "今日有运行,订单证据缺失(证据未覆盖)。", + # Legacy keys kept for callers/tests that still reference them. + "heartbeat_no_run": "今日已验证无平台/策略实际运行。", + "heartbeat_no_fill": "今日有运行且已验证零成交。", + "footer": "通道:{sender}(统一 bot)· 仅含当日实际运行项", + "platform_line": "· {platform} / {strategy} · {detail}", + "block_heartbeat": "💓 【心跳检测】", + "block_rebalance": "🔔 【调仓指令】", + "block_no_order": "⚠️ 【未下单】", + "block_pending": "⏳ 【待确认】", + "block_blocked": "⚠️ 【已拦截】", + "strategy_label": "🧭 策略", + "equity_label": "💰 账户总权益", + "holdings_label": "💼 持仓", + "holdings_scope_strategy_symbols_only": "仅策略标的", + "holdings_scope_stocks_only": "仅股票", + "signal_prefix": "🎯 信号", + "shares_unit": "股", + "tip_prefix": "小账户提示", + "conclusion_no_rebalance": "✅ 无需调仓", + "conclusion_fills": "✅ 成交 {count}", + "conclusion_no_order_prefix": "未下单", + "platform_tag": "平台", + "identity_tag": "身份", + "stale_candidates_header": "⚠️ 以下账户今天的数据缺失或过期:", + "no_trades": "无交易", + "missing_evidence": "数据缺失:{code}({explanation})", + "EVIDENCE_NOT_FOUND": "未找到当天的成交或订单证据", + "EVIDENCE_UNREADABLE": "证据读取失败或格式无效", + "EVIDENCE_STALE_BUSINESS_DAY": "证据业务日与日报不一致", + "SOURCE_NOT_CONFIGURED": "未配置成交或订单证据来源", + "test_label": "【测试】", + "guard_recovery": "⚠️ 此前 {count} 次日报因无法确认发送记录而停止发送,请查看工作流回执。", + "stale_candidate_line": "· {platform} / {strategy}({detail})", + "stale_detail_day": "数据业务日 {day}", + "stale_detail_undated": "未注明业务日", + "heartbeat_all_candidates_stale": "今日候选数据均未更新,不能判定为链路正常。", + "v2_title": "📡 {sender} · 日报 {date}", + "v2_window": "窗口 {start} → {end}(上海时间)", + "v2_section_attention": "⚠️ 待处理", + "v2_stale_header": "以下账户今天的数据缺失或过期:", + "v2_section_us_daily": "📈 美股日策略(纽约交易日 {day})", + "v2_section_dca": "💰 定投", + "v2_section_crypto": "🪙 加密小时策略", + "v2_section_coverage": "🧾 覆盖", + "v2_us_market_closed": "休市", + "v2_no_evidence": "本窗口无运行证据", + "v2_attention_alert": "· {tag} {strategy}:状态异常{reason}", + "v2_attention_status": "· {tag} {strategy}:{detail}", + "v2_coverage_line": "已收录 {runs} 条运行;候选 {fresh} 条可用、{stale} 条过期已略去", + "v2_coverage_github_failures": "GitHub 证据读取失败 {count} 处", + "candidate_source_failures": "⚠️ 候选读取失败 {count} 处;本窗口覆盖不完整。", + "v2_evidence_unknown": "运行证据未知或读取失败,不能判定为链路正常。", + "v2_verified_idle": "已验证本窗口无平台/策略实际运行。", + }, + "en": { + "daily_digest_title": "📡 {sender} · Daily digest", + "daily_digest_heartbeat_title": "📡 {sender} · Heartbeat", + "date_label": "Business day", + "window_label": "Window", + "ran_section": "[Ran today]", + "fills_label": "fills", + "orders_label": "orders", + "cycles_label": "cycles", + "no_fill": "no fills", + "counts_unknown": "unknown", + "counts_not_connected": "not connected", + "trade_count": "{label} {count}", + "no_orders": "no orders", + "status_ok": "ok", + "status_alert": "alert", + "heartbeat_no_run_verified": ( + "Verified: no platform/strategy actually ran today." + ), + "heartbeat_evidence_unknown": ( + "Run evidence is unknown or a source read failed; " + "monitoring link health is not confirmed." + ), + "heartbeat_no_fill_verified": ( + "Runs completed with verified zero fills today." + ), + "heartbeat_fills_unknown": ( + "Runs completed but fill counts are unknown (evidence uncovered)." + ), + "heartbeat_orders_unknown": "Runs completed but order evidence is missing (evidence uncovered).", + "heartbeat_no_run": ( + "Verified: no platform/strategy actually ran today." + ), + "heartbeat_no_fill": "Runs completed with verified zero fills today.", + "footer": "Channel: {sender} (unified bot) · only entries that actually ran", + "platform_line": "· {platform} / {strategy} · {detail}", + "block_heartbeat": "💓 [Heartbeat]", + "block_rebalance": "🔔 [Rebalance]", + "block_no_order": "⚠️ [No order]", + "block_pending": "⏳ [Pending]", + "block_blocked": "⚠️ [Blocked]", + "strategy_label": "🧭 Strategy", + "equity_label": "💰 Account equity", + "holdings_label": "💼 Holdings", + "holdings_scope_strategy_symbols_only": "strategy symbols only", + "holdings_scope_stocks_only": "stocks only", + "signal_prefix": "🎯 Signal", + "shares_unit": "sh", + "tip_prefix": "Small-account note", + "conclusion_no_rebalance": "✅ No rebalance needed", + "conclusion_fills": "✅ fills {count}", + "conclusion_no_order_prefix": "No order", + "platform_tag": "Platform", + "identity_tag": "Identity", + "stale_candidates_header": "⚠️ These accounts have missing or stale data today:", + "no_trades": "No trades", + "missing_evidence": "Data missing: {code} ({explanation})", + "EVIDENCE_NOT_FOUND": "No fill or order evidence for this business day", + "EVIDENCE_UNREADABLE": "Evidence could not be read or has an invalid format", + "EVIDENCE_STALE_BUSINESS_DAY": "Evidence belongs to a different business day", + "SOURCE_NOT_CONFIGURED": "No fill or order evidence source is configured", + "test_label": "[TEST]", + "guard_recovery": "⚠️ {count} earlier digest attempts were blocked because send history could not be verified; check the workflow receipts.", + "stale_candidate_line": "· {platform} / {strategy} ({detail})", + "stale_detail_day": "data business day {day}", + "stale_detail_undated": "no business day", + "heartbeat_all_candidates_stale": ( + "All candidate data is stale today; monitoring link health is not confirmed." + ), + "v2_title": "📡 {sender} · Daily report {date}", + "v2_window": "Window {start} → {end} (Asia/Shanghai)", + "v2_section_attention": "⚠️ Needs attention", + "v2_stale_header": "These accounts have missing or stale data today:", + "v2_section_us_daily": "📈 US daily strategies (NY session {day})", + "v2_section_dca": "💰 DCA", + "v2_section_crypto": "🪙 Crypto intraday", + "v2_section_coverage": "🧾 Coverage", + "v2_us_market_closed": "Market closed", + "v2_no_evidence": "No run evidence in this window", + "v2_attention_alert": "· {tag} {strategy}: alert{reason}", + "v2_attention_status": "· {tag} {strategy}: {detail}", + "v2_coverage_line": "{runs} runs included; candidates {fresh} usable, {stale} stale omitted", + "v2_coverage_github_failures": "GitHub evidence read failures: {count}", + "candidate_source_failures": "⚠️ Candidate source read failures: {count}; window coverage is incomplete.", + "v2_evidence_unknown": "Run evidence is unknown or a source read failed; link health is not confirmed.", + "v2_verified_idle": "Verified: no platform/strategy actually ran in this window.", + }, +} + +UNKNOWN_ACCOUNT_UID = "unknown" +UNKNOWN_TARGET_ID = "unknown" + +# Producer machine tokens (snake_case) → locale UI labels. +# Free-form Chinese/English prose is never rewritten — only exact whole-string tokens. +_SIGNAL_TOKEN_DISPLAY: dict[str, dict[str, str]] = { + "zh": { + "no_action": "无需操作", + "no_signal": "无信号", + "no_rebalance": "无需调仓", + "no_submission": "未提交", + "not_due": "未到执行时点", + "hold": "持有", + "order_submitted": "订单已提交", + "order_acknowledged": "券商已确认", + "partially_filled": "部分成交", + "filled": "已成交", + "previewed": "仅预览", + "blocked": "已拦截", + "failed": "失败", + "unknown": "未知", + "reconciliation_required": "需对账", + "risk_blocked": "风控拦截", + }, + "en": { + "no_action": "No action", + "no_signal": "No signal", + "no_rebalance": "No rebalance", + "no_submission": "No submission", + "not_due": "Not due", + "hold": "Hold", + "order_submitted": "Order submitted", + "order_acknowledged": "Broker acknowledged", + "partially_filled": "Partially filled", + "filled": "Filled", + "previewed": "Preview only", + "blocked": "Blocked", + "failed": "Failed", + "unknown": "Unknown", + "reconciliation_required": "Reconciliation required", + "risk_blocked": "Risk blocked", + }, +} + +# Bare conclusion tokens. Empty string → omit line (block title already covers it). +_CONCLUSION_TOKEN_DISPLAY: dict[str, dict[str, str]] = { + "zh": { + "no_order": "", + "no_rebalance": "✅ 无需调仓", + "pending": "⏳ 待确认", + "rebalance": "🔔 调仓", + "filled": "✅ 已成交", + "partially_filled": "✅ 部分成交", + "submitted": "已提交", + "broker_acknowledged": "券商已确认", + "previewed": "仅预览", + "blocked": "已拦截", + "risk_blocked": "风控拦截", + "failed": "失败", + "unknown": "未知", + "reconciliation_required": "需对账", + "not_due": "未到执行时点", + "no_action": "无需操作", + "no_signal": "无信号", + "no_submission": "未提交", + }, + "en": { + "no_order": "", + "no_rebalance": "✅ No rebalance needed", + "pending": "⏳ Pending", + "rebalance": "🔔 Rebalance", + "filled": "✅ Filled", + "partially_filled": "✅ Partially filled", + "submitted": "Submitted", + "broker_acknowledged": "Broker acknowledged", + "previewed": "Preview only", + "blocked": "Blocked", + "risk_blocked": "Risk blocked", + "failed": "Failed", + "unknown": "Unknown", + "reconciliation_required": "Reconciliation required", + "not_due": "Not due", + "no_action": "No action", + "no_signal": "No signal", + "no_submission": "No submission", + }, +} + + +def _localize_producer_token( + locale: DigestLocale, raw: str, table: Mapping[str, Mapping[str, str]] +) -> str | None: + """If ``raw`` is exactly a known machine token, return its locale label. + + Returns ``None`` when ``raw`` is free-form prose (caller keeps original). + A mapped empty string means \"omit this line\". + """ + + text = str(raw or "").strip() + if not text: + return "" + key = text.lower() + locale_map = table.get(locale) or table.get("zh") or {} + if key in locale_map: + return locale_map[key] + # Also accept when en table defines the token but zh missed it (defensive). + if key in (table.get("en") or {}) or key in (table.get("zh") or {}): + return (table.get(locale) or {}).get(key, text) + return None + + +def normalize_locale(raw: str | None) -> DigestLocale: + value = (raw or "zh").strip().lower() + return "en" if value.startswith("en") else "zh" + + +def _t(locale: DigestLocale, key: str) -> str: + return _TEXTS[locale][key].replace("{sender}", display_name("senders", "quant_sentinel", locale)) + + +def _optional_float(raw: Any) -> float | None: + if raw is None or raw == "": + return None + try: + return float(raw) + except (TypeError, ValueError) as exc: + raise ValueError(f"expected numeric value, got {raw!r}") from exc + + +def _optional_nonneg_int(raw: Any) -> int | None: + """Parse an optional non-negative int; None stays unknown.""" + + if raw is None or raw == "": + return None + try: + value = int(raw) + except (TypeError, ValueError) as exc: + raise ValueError(f"expected integer count, got {raw!r}") from exc + if value < 0: + raise ValueError("counts must be non-negative") + return value + + +def _format_qty(qty: float | None) -> str | None: + if qty is None: + return None + if float(qty).is_integer(): + return str(int(qty)) + text = f"{qty:.6f}".rstrip("0").rstrip(".") + return text or "0" + + +def _format_money(value: float, *, currency: str = "USD") -> str: + code = (currency or "USD").strip().upper() or "USD" + amount = f"{value:,.2f}" + if code == "USD": + return f"${amount}" + return f"{code} {amount}" + + +def _format_equity(value: float, *, currency: str = "USD") -> str: + code = (currency or "USD").strip().upper() or "USD" + amount = f"{value:,.2f}" + return f"{code} {amount}" + + +def _field_status_map(raw: Mapping[str, Any] | None) -> dict[str, str]: + if not isinstance(raw, Mapping): + return {} + out: dict[str, str] = {} + for key, value in raw.items(): + status = str(value or "").strip().lower() + if status: + out[str(key)] = status + return out + + +def count_is_unknown( + raw: Mapping[str, Any], + field_name: str, + *, + parsed: int | None, +) -> bool: + """True when producer/stub marks the count unknown (not verified zero).""" + + status_map = _field_status_map( + raw.get("field_status") if isinstance(raw.get("field_status"), Mapping) else None + ) + status = status_map.get(field_name, "") + if status in {"unknown", "counts_unknown", "unverified"}: + return True + reason = str(raw.get("reason_code") or "").strip().lower() + if reason in {"counts_unknown", "github_workflow_existence_only"} and field_name in { + "fill_count", + "order_count", + }: + # Existence-only stubs never assert verified zero fills/orders. + if field_name not in raw or raw.get(field_name) is None: + return True + if status in {"unknown", "counts_unknown", "unverified"}: + return True + if field_name not in raw and parsed is None: + # Absent key with no legacy int → unknown (new producers). + # Legacy rows that omit the key but lack reason_code are treated as + # explicit 0 only when filter supplies default via present int. + return True + return parsed is None + + +def resolve_count_field( + raw: Mapping[str, Any], + field_name: str, + *, + legacy_default_zero: bool = False, +) -> tuple[int | None, CountFieldStatus]: + """Return (value, status). Legacy int 0 without field_status → known zero.""" + + status_map = _field_status_map( + raw.get("field_status") if isinstance(raw.get("field_status"), Mapping) else None + ) + status_hint = status_map.get(field_name, "") + if status_hint in {"unknown", "counts_unknown", "unverified"}: + return None, "unknown" + + if field_name not in raw: + reason = str(raw.get("reason_code") or "").strip().lower() + if reason in {"counts_unknown", "github_workflow_existence_only"}: + return None, "unknown" + if legacy_default_zero: + # Backward compat: old candidates omitting the key meant 0. + return 0, "known" + return None, "unknown" + + value = _optional_nonneg_int(raw.get(field_name)) + if value is None: + return None, "unknown" + return value, "known" + + +@dataclass(frozen=True) +class DigestHolding: + """One holding line when a producer supplies position evidence.""" + + symbol: str + market_value: float | None = None + quantity: float | None = None + currency: str = "USD" + + def __post_init__(self) -> None: + if not str(self.symbol).strip(): + raise ValueError("holding.symbol required") + if self.market_value is not None and self.market_value < 0: + raise ValueError("holding.market_value must be non-negative") + if self.quantity is not None and self.quantity < 0: + raise ValueError("holding.quantity must be non-negative") + + +@dataclass(frozen=True) +class DigestRunEntry: + """One platform/strategy that actually ran in the digest window. + + Optional observation fields are schema-ready for producer evidence. + Render omits any field that is empty/None — never invents numbers. + + Counts may be None (= unknown). Verified zero is int 0 with known status. + """ + + platform_id: str + strategy_profile: str + fill_count: int | None = 0 + order_count: int | None = 0 + cycle_count: int | None = 0 + status: Literal["ok", "alert"] = "ok" + note: str = "" + strategy_label: str = "" + equity: float | None = None + equity_currency: str = "USD" + holdings: tuple[DigestHolding, ...] = () + # Optional coverage label for holdings (strategy_symbols_only | stocks_only). + holdings_scope: str = "" + signal_summary: str = "" + rebalance_kind: RebalanceKind = "" + rebalance_conclusion: str = "" + tips: tuple[str, ...] = () + account_hint: str = "" + opaque_account_uid: str = "" + target_id: str = "" + account_scope: str = "" + execution_mode: str = "" + fill_count_status: CountFieldStatus = "known" + order_count_status: CountFieldStatus = "known" + cycle_count_status: CountFieldStatus = "known" + reason_code: str = "" + missing_evidence_reason: str = "" + evidence_provenance: str = "" + # B11 optional producer axes (pass-through; central projects when empty). + execution_status: str = "" + evidence_persistence_status: str = "" + evidence_delivery_status: str = "" + # NOTIFY-04 B1: digest section (us_daily | dca | crypto_intraday); "" = infer. + section: str = "" + # One atomic notification observation; never used as count evidence. + block_reason: Mapping[str, object] | None = None + + def __post_init__(self) -> None: + if not str(self.platform_id).strip(): + raise ValueError("platform_id required") + if not str(self.strategy_profile).strip(): + raise ValueError("strategy_profile required") + for name, value in ( + ("fill_count", self.fill_count), + ("order_count", self.order_count), + ("cycle_count", self.cycle_count), + ): + if value is not None and value < 0: + raise ValueError(f"{name} must be non-negative") + if self.equity is not None and self.equity < 0: + raise ValueError("equity must be non-negative") + kind = str(self.rebalance_kind or "") + allowed = {"", "no_rebalance", "rebalance", "no_order", "pending"} + if kind not in allowed: + raise ValueError(f"rebalance_kind must be one of {sorted(allowed)}") + + +@dataclass(frozen=True) +class EvidenceCoverage: + """Source coverage for distinguishing verified idle vs read failure.""" + + candidates_configured: bool = False + candidates_loaded: bool = False + candidates_count: int = 0 + github_enabled: bool = False + github_sources_queried: int = 0 + github_sources_matched: int = 0 + github_failures: tuple[dict[str, Any], ...] = () + candidate_failures: tuple[dict[str, Any], ...] = () + # NOTIFY-04: candidate rows dropped because their business day did not match + # the digest business day (or was missing). Each item is a redacted mapping + # with platform/strategy/day/reason and optional account display hint. + # Full details remain private; the public projection exports counts only. + stale_candidates: tuple[dict[str, Any], ...] = () + missing_targets: tuple[dict[str, Any], ...] = () + suppressed_candidates_count: int = 0 + legacy_candidates_only: bool = False + + @property + def stale_candidates_count(self) -> int: + return len(self.stale_candidates) + + @property + def has_source_failures(self) -> bool: + return bool(self.github_failures or self.candidate_failures) + + @property + def is_complete_for_verified_idle(self) -> bool: + """True only when configured sources were consulted without failure.""" + + if self.has_source_failures: + return False + # Stale producer data means the producer did not refresh: we cannot + # claim a verified idle day. + if self.stale_candidates or self.missing_targets or self.legacy_candidates_only: + return False + if self.github_enabled and self.github_sources_queried <= 0: + return False + if self.candidates_loaded or ( + self.github_enabled and self.github_sources_queried > 0 + ): + return True + return False + + def to_dict(self) -> dict[str, Any]: + return { + "candidates_configured": self.candidates_configured, + "candidates_loaded": self.candidates_loaded, + "candidates_count": self.candidates_count, + "github_enabled": self.github_enabled, + "github_sources_queried": self.github_sources_queried, + "github_sources_matched": self.github_sources_matched, + "github_failures": list(self.github_failures), + "candidate_failures": list(self.candidate_failures), + "has_source_failures": self.has_source_failures, + "is_complete_for_verified_idle": self.is_complete_for_verified_idle, + "stale_candidates_count": self.stale_candidates_count, + "stale_candidates": [dict(item) for item in self.stale_candidates], + "missing_targets_count": len(self.missing_targets), + "suppressed_candidates_count": self.suppressed_candidates_count, + } + + +@dataclass(frozen=True) +class DailyDigestInput: + business_day: str + window_label: str = "" + locale: DigestLocale | str = "zh" + runs: Sequence[DigestRunEntry] = field(default_factory=tuple) + evidence_coverage: EvidenceCoverage | None = None + evidence_status: EvidenceStatus | str = "" + # NOTIFY-04 B1: opt-in layout + Shanghai cut-off window (start/end local + # "YYYY-MM-DD HH:MM" strings, see daily_digest_aggregator.digest_window_for). + layout: DigestLayout | str = "v1" + window: Mapping[str, Any] | None = None + notification_summary: Mapping[str, Any] | None = None + + +def normalize_layout(raw: str | None) -> DigestLayout: + value = str(raw or "").strip().lower() + return "sections_v2" if value in {"sections_v2", "v2", "sections"} else "v1" + + +def total_fills(runs: Sequence[DigestRunEntry]) -> int | None: + """Sum known fills. Returns None when any run has unknown fill_count.""" + + total = 0 + saw_known = False + for entry in runs: + if entry.fill_count is None or entry.fill_count_status == "unknown": + return None + saw_known = True + total += entry.fill_count + return total if saw_known or not runs else 0 + + +def fills_are_verified_zero(runs: Sequence[DigestRunEntry]) -> bool: + if not runs: + return False + for entry in runs: + if entry.fill_count is None or entry.fill_count_status == "unknown": + return False + if entry.fill_count != 0: + return False + return True + + +def has_known_positive_fills(runs: Sequence[DigestRunEntry]) -> bool: + """True when any run reports a known fill_count > 0. + + Used for digest title selection so a successful-trade block is not + hidden behind the heartbeat title when another run's fills are unknown. + """ + + for entry in runs: + if ( + entry.fill_count is not None + and entry.fill_count_status != "unknown" + and entry.fill_count > 0 + ): + return True + return False + + +def has_observation(entry: DigestRunEntry) -> bool: + """True when producer evidence includes displayable observation fields.""" + + return bool( + entry.equity is not None + or entry.holdings + or entry.signal_summary.strip() + or entry.rebalance_conclusion.strip() + or entry.rebalance_kind + or entry.block_reason + or entry.tips + ) + + +def observation_score(row: Mapping[str, Any]) -> int: + """Rank evidence richness for merge (known counts + optional observation).""" + + score = 0 + for field_name in ("fill_count", "order_count", "cycle_count"): + value, status = resolve_count_field(row, field_name, legacy_default_zero=False) + if status == "known" and value is not None: + score += value + if row.get("equity") is not None and row.get("equity") != "": + score += 10 + holdings = row.get("holdings") or row.get("positions") or () + if isinstance(holdings, (list, tuple)): + score += min(len(holdings), 8) + if str(row.get("signal_summary") or "").strip(): + score += 5 + if str(row.get("rebalance_conclusion") or "").strip() or str( + row.get("rebalance_kind") or "" + ).strip(): + score += 5 + if str(row.get("strategy_label") or "").strip(): + score += 1 + # Prefer producer rows over github existence stubs. + provenance = str(row.get("evidence_provenance") or "").strip().lower() + if provenance in {"candidates", "producer"}: + score += 20 + if provenance in {"github_workflow_stub", "github_stub"}: + score -= 5 + return score + + +def identity_key(row: Mapping[str, Any]) -> tuple[str, str, str, str]: + """Aggregation identity: platform + strategy + account/target. + + Missing account/target use explicit sentinels. Callers must not freely + cross-fill observation fields across unknown-identity rows. + """ + + platform_id = str(row.get("platform_id") or "").strip() + strategy = str(row.get("strategy_profile") or "").strip() + account_uid = str(row.get("opaque_account_uid") or "").strip() + target = str(row.get("target_id") or "").strip() + return ( + platform_id, + strategy, + account_uid or UNKNOWN_ACCOUNT_UID, + target or UNKNOWN_TARGET_ID, + ) + + +def identity_is_unknown(row: Mapping[str, Any]) -> bool: + account_uid = str(row.get("opaque_account_uid") or "").strip() + target = str(row.get("target_id") or "").strip() + return not account_uid and not target + + +def _format_count_part( + locale: DigestLocale, + *, + label_key: str, + value: int | None, + status: CountFieldStatus, +) -> str | None: + if status == "unknown" or value is None: + if label_key == "fills_label": + return f"{_t(locale, 'fills_label')} {_t(locale, 'counts_unknown')}" + return f"{_t(locale, label_key)} {_t(locale, 'counts_unknown')}" + if label_key == "fills_label" and value == 0: + return _t(locale, "no_fill") + if value == 0 and label_key != "fills_label": + return None + return f"{_t(locale, label_key)} {value}" + + +def _detail_for(entry: DigestRunEntry, locale: DigestLocale) -> str: + parts: list[str] = [] + cycle_part = _format_count_part( + locale, + label_key="cycles_label", + value=entry.cycle_count, + status=entry.cycle_count_status, + ) + if cycle_part and not ( + entry.cycle_count_status == "known" + and entry.cycle_count == 0 + ): + if entry.cycle_count_status == "unknown" or ( + entry.cycle_count is not None and entry.cycle_count > 0 + ): + parts.append(cycle_part) + + parts.append(_trade_counts(entry, locale)) + + parts.append(_t(locale, "status_ok" if entry.status == "ok" else "status_alert")) + if entry.note.strip(): + parts.append(entry.note.strip()) + return " · ".join(parts) + + +def _trade_counts(entry: DigestRunEntry, locale: DigestLocale) -> str: + if (entry.fill_count_status == "unknown" or entry.fill_count is None + or entry.order_count_status == "unknown" or entry.order_count is None): + # Keep a partially known count visible without claiming the other is zero. + known = [] + for label, value, status in ( + ("fills_label", entry.fill_count, entry.fill_count_status), + ("orders_label", entry.order_count, entry.order_count_status), + ): + if status != "unknown" and value is not None and value > 0: + known.append(_t(locale, "trade_count").format(label=_t(locale, label), count=value)) + if counts_not_connected(entry): + labels = [_t(locale, label) for label, value, status in ( + ("orders_label", entry.order_count, entry.order_count_status), + ("fills_label", entry.fill_count, entry.fill_count_status), + ) if status == "unknown" or value is None] + separator = "、" if locale == "zh" else "/" + unknown = f"{separator.join(labels)}:{_t(locale, 'counts_not_connected')}" if locale == "zh" else ( + f"{separator.join(labels)}: {_t(locale, 'counts_not_connected')}" + ) + else: + unknown = missing_evidence_text(locale, entry.missing_evidence_reason or entry.reason_code) + return " / ".join(known + [unknown]) + if entry.fill_count == 0 and entry.order_count == 0: + return _t(locale, "no_trades") + parts = [] + for label, value, status in ( + ("fills_label", entry.fill_count, entry.fill_count_status), + ("orders_label", entry.order_count, entry.order_count_status), + ): + if label == "orders_label" and value == 0: + parts.append(_t(locale, "no_orders")) + else: + parts.append(_t(locale, "trade_count").format(label=_t(locale, label), count=value)) + return " / ".join(parts) + + +def counts_not_connected(entry: DigestRunEntry) -> bool: + """A known unconnected counter is distinct from unreadable/absent evidence.""" + return ( + entry.platform_id == "schwab" + and "schwab_fills_not_connected" in entry.reason_code.split("+") + and entry.missing_evidence_reason in {"", "EVIDENCE_NOT_FOUND"} + and (entry.fill_count is None or entry.fill_count_status == "unknown" + or entry.order_count is None or entry.order_count_status == "unknown") + ) + + +def is_blocked(entry: DigestRunEntry) -> bool: + return bool(entry.block_reason) or entry.rebalance_conclusion in {"blocked", "risk_blocked"} or entry.signal_summary in {"blocked", "risk_blocked"} + + +def _parse_block_reason(raw: object) -> dict[str, object] | None: + """Ignore malformed/unknown producer facts without changing legacy copy.""" + if not isinstance(raw, Mapping): + return None + code = raw.get("reason_code") + if not isinstance(code, str) or code not in {"consecutive_loss_limit_exceeded", "strategy_risk_rejected"}: + return None + observed, limit = raw.get("observed_value"), raw.get("limit") + if code == "consecutive_loss_limit_exceeded": + if (type(observed) is not int or type(limit) is not int + or not 0 <= limit < observed <= 999_999_999): + return None + elif observed is not None or limit is not None: + return None + submitted = raw.get("order_submitted_this_run") + if submitted is not None and type(submitted) is not bool: + return None + return {"reason_code": code, "observed_value": observed, "limit": limit, + "order_submitted_this_run": submitted} + + +def block_reason_text(entry: DigestRunEntry, locale: DigestLocale | None = None) -> str: + """Explicit locale selects one language; default preserves legacy bytes.""" + reason = _parse_block_reason(entry.block_reason) if entry.platform_id in {"schwab", "ibkr"} else None + if not reason: + return "" + if locale is not None: + parts = [] + if reason["reason_code"] == "consecutive_loss_limit_exceeded": + observed, limit = reason["observed_value"], reason["limit"] + parts.append(f"连续亏损 {observed} 次,超过上限 {limit} 次" if locale == "zh" else + f"Consecutive losses {observed} exceed limit {limit}") + else: + parts.append("策略风控拦截" if locale == "zh" else "Strategy risk rejected") + if reason["order_submitted_this_run"] is False: + parts.append("本轮 0 单" if locale == "zh" else "0 orders this run") + elif reason["order_submitted_this_run"] is True: + parts.append("本轮已提交订单" if locale == "zh" else "Orders submitted this run") + return " · ".join(parts) + parts = [] + if reason["reason_code"] == "consecutive_loss_limit_exceeded": + observed, limit = reason["observed_value"], reason["limit"] + parts.append(f"连续亏损 {observed} 次 > 上限 {limit} 次 / Consecutive losses {observed} > limit {limit}") + elif entry.platform_id == "ibkr": + parts.append("策略风控拦截 / Strategy risk rejected") + if reason["order_submitted_this_run"] is False: + parts.append("本轮未下单 / No orders submitted this run") + elif reason["order_submitted_this_run"] is True: + parts.append("本轮已提交订单 / Orders submitted this run") + return " · ".join(parts) + + +def _counts_notice(payload: DailyDigestInput, locale: DigestLocale) -> str: + unknown = [entry for entry in payload.runs if entry.fill_count is None or entry.fill_count_status == "unknown" + or entry.order_count is None or entry.order_count_status == "unknown"] + coverage = payload.evidence_coverage + if unknown and all(counts_not_connected(entry) for entry in unknown) and not ( + coverage and (coverage.has_source_failures or coverage.stale_candidates or coverage.missing_targets) + ): + # Each account already names its unconnected counters. + return "" + if global_total_fills(payload) is None: + return _t(locale, "heartbeat_fills_unknown") + return _t(locale, "heartbeat_orders_unknown") if unknown else "" + + +def missing_evidence_text(locale: DigestLocale, reason: str = "") -> str: + code = reason if reason in MISSING_EVIDENCE_REASONS else "EVIDENCE_NOT_FOUND" + return _t(locale, "missing_evidence").format(code=code, explanation=_t(locale, code)) + + +def _source_missing_reason(coverage: EvidenceCoverage | None) -> str: + if coverage is not None and coverage.has_source_failures: + return "EVIDENCE_UNREADABLE" + return "EVIDENCE_NOT_FOUND" if coverage is not None and coverage.candidates_configured else "SOURCE_NOT_CONFIGURED" + + +def label_delivery_text(text: str, locale: str, *, test: bool = False, previous_failures: int = 0) -> str: + loc = normalize_locale(locale) + prefix = [_t(loc, "test_label")] if test else [] + if previous_failures: + prefix.append(_t(loc, "guard_recovery").format(count=previous_failures)) + return "\n".join(prefix + [text]) + + +def _block_title(entry: DigestRunEntry, locale: DigestLocale) -> str: + if is_blocked(entry): + return _t(locale, "block_blocked") + kind = entry.rebalance_kind or "" + if kind == "rebalance": + return _t(locale, "block_rebalance") + if kind == "no_order": + return _t(locale, "block_no_order") + if kind == "pending": + return _t(locale, "block_pending") + if kind == "no_rebalance": + return _t(locale, "block_heartbeat") + fill = entry.fill_count if entry.fill_count_status != "unknown" else None + order = entry.order_count if entry.order_count_status != "unknown" else None + if (fill is not None and fill > 0) or (order is not None and order > 0): + return _t(locale, "block_rebalance") + return _t(locale, "block_heartbeat") + + +_SCOPE_TAG_ALLOWED = frozenset({"live", "paper", "shadow", "research"}) +_HEX_HINT_RE = re.compile(r"^[0-9a-fA-F]{12,}$") +# Broker account ids like IBKR ``U00000008`` / ``u00000008``. +_BROKER_ACCOUNT_ID_RE = re.compile(r"^[Uu]\d{5,}$") +# Scope forms that embed the broker id: ``live-u00000008``, ``paper_U00000009``. +_SCOPE_EMBEDDED_ACCOUNT_RE = re.compile( + r"^(?:live|paper|shadow|research)[_-]([Uu]\d{5,})$", + re.IGNORECASE, +) + + +def _normalize_scope_tag(raw: str) -> str: + """Return lowercase live/paper/shadow/research when value looks like a mode.""" + + value = str(raw or "").strip().lower() + if not value: + return "" + # Accept plain mode or prefixed forms like live_us / paper-us. + if value in _SCOPE_TAG_ALLOWED: + return value + for mode in _SCOPE_TAG_ALLOWED: + if value == mode or value.startswith(f"{mode}_") or value.startswith(f"{mode}-"): + return mode + return "" + + +def _normalize_broker_account_id(raw: str) -> str: + """Return ``U######`` when ``raw`` is a broker account id (IBKR-style). + + Existing ops config uses uppercase ``U``; lowercase input is normalized. + """ + + value = str(raw or "").strip() + if not value: + return "" + if _BROKER_ACCOUNT_ID_RE.fullmatch(value): + return "U" + value[1:] + match = _SCOPE_EMBEDDED_ACCOUNT_RE.fullmatch(value) + if match: + acc = match.group(1) + return "U" + acc[1:] + return "" + + +def _human_account_hint(raw: str) -> str: + """Return account_hint when it is already a good human label. + + Reject empty values and opaque hex hashes (those are not display tags). + IBKR-style ids such as ``U00000009`` are kept (normalized to ``U######``). + """ + + hint = str(raw or "").strip() + if not hint: + return "" + if _HEX_HINT_RE.fullmatch(hint): + return "" + broker = _normalize_broker_account_id(hint) + if broker: + return broker + return hint + + +def _account_label(entry: DigestRunEntry) -> str: + """Resolve the account half of ``[{platform_id} {account_label}]``. + + Preference (universal): + 1. human ``account_hint`` / broker id (e.g. IBKR ``U00000008``) + 2. broker id embedded in ``account_scope`` (``live-u00000008``) + 3. broker id on ``opaque_account_uid`` when it is already ``U######`` + 4. normalized scope ``live|paper|shadow|research`` + 5. short ``opaque_account_uid`` / ``target_id`` last resorts + """ + + hint = _human_account_hint(entry.account_hint) + if hint: + return hint + for raw in (entry.account_scope, entry.execution_mode, entry.opaque_account_uid): + broker = _normalize_broker_account_id(raw) + if broker: + return broker + for raw in (entry.account_scope, entry.execution_mode): + scope = _normalize_scope_tag(raw) + if scope: + return scope + uid = str(entry.opaque_account_uid or "").strip() + if uid and uid.lower() != UNKNOWN_ACCOUNT_UID: + if len(uid) > 12: + return uid[:8] + return uid + target = str(entry.target_id or "").strip() + if target and target.lower() != UNKNOWN_TARGET_ID: + return target + return "" + + +def account_block_tag(entry: DigestRunEntry) -> str: + """Account tag body for ``[tag]`` block titles. + + Universal form: ``{platform_id} {account_label}`` — e.g. ``ibkr U00000008``, + ``schwab live``, ``firstrade paper``. Never hard-code a single venue. + Returns empty string when nothing usable is present. + """ + + platform = str(entry.platform_id or "").strip() + label = display_name("accounts", _account_label(entry), platform=entry.platform_id) + if not label: + return "" + if not platform: + return label + # Avoid ``ibkr ibkr U…`` if label already carries the platform prefix. + prefix = platform.lower() + " " + if label.lower().startswith(prefix): + return label + return f"{platform} {label}" + + +def _display_strategy(entry: DigestRunEntry, locale="zh") -> str: + return display_name("strategies", entry.strategy_profile, locale, fallback=entry.strategy_label.strip() or entry.strategy_profile) + + +_HOLDINGS_SCOPES = frozenset({"strategy_symbols_only", "stocks_only"}) + + +def _parse_holdings_scope(raw: Any) -> str: + """Accept only known coverage tokens; anything else renders without a label.""" + + text = str(raw or "").strip() + return text if text in _HOLDINGS_SCOPES else "" + + +def _holdings_header(scope: str, locale: DigestLocale) -> str: + label = _t(locale, "holdings_label") + if scope in _HOLDINGS_SCOPES: + scope_text = _t(locale, f"holdings_scope_{scope}") + if locale == "en": + return f"{label} ({scope_text})" + return f"{label}({scope_text})" + return label + + +def _holding_line(holding: DigestHolding, locale: DigestLocale) -> str: + symbol = holding.symbol.strip() + pieces: list[str] = [] + if holding.market_value is not None: + pieces.append( + _format_money(holding.market_value, currency=holding.currency) + ) + qty = _format_qty(holding.quantity) + if qty is not None: + pieces.append(f"{qty}{_t(locale, 'shares_unit')}") + if pieces: + return f"- {symbol}: {' / '.join(pieces)}" + return f"- {symbol}" + + +def _default_conclusion(entry: DigestRunEntry, locale: DigestLocale) -> str: + raw = entry.rebalance_conclusion.strip() + if raw: + localized = _localize_producer_token(locale, raw, _CONCLUSION_TOKEN_DISPLAY) + if localized is not None: + # Bare machine token: use mapped label (may be "" to omit). + if localized: + return localized + # Empty mapping → fall through to kind-based defaults. + else: + # Free-form producer prose (often already zh) — keep as-is. + return raw + kind = entry.rebalance_kind or "" + if kind == "no_rebalance": + return _t(locale, "conclusion_no_rebalance") + # Trade-success fallback when producer left conclusion empty but counts known. + fill = entry.fill_count if entry.fill_count_status != "unknown" else None + if fill is not None and fill > 0: + return _t(locale, "conclusion_fills").format(count=fill) + return "" + + +def _render_observation_block(entry: DigestRunEntry, locale: DigestLocale) -> list[str]: + lines: list[str] = [] + title = _block_title(entry, locale) + tag = account_block_tag(entry) + if tag: + lines.append(f"[{tag}] {title}") + else: + lines.append(title) + lines.append(f"{_t(locale, 'strategy_label')}: {_display_strategy(entry, locale)}") + if entry.equity is not None: + lines.append( + f"{_t(locale, 'equity_label')}: " + f"{_format_equity(entry.equity, currency=entry.equity_currency)}" + ) + if entry.holdings: + lines.append(_holdings_header(entry.holdings_scope, locale)) + for holding in entry.holdings: + lines.append(_holding_line(holding, locale)) + if entry.signal_summary.strip() and not (is_blocked(entry) and entry.signal_summary in {"blocked", "risk_blocked"}): + signal_raw = entry.signal_summary.strip() + signal_display = _localize_producer_token( + locale, signal_raw, _SIGNAL_TOKEN_DISPLAY + ) + if signal_display is None: + signal_display = signal_raw + if signal_display: + lines.append( + f"- {_t(locale, 'signal_prefix')}: {signal_display}" + ) + for tip in entry.tips: + tip_text = str(tip).strip() + if tip_text: + lines.append(f"- {_t(locale, 'tip_prefix')}: {tip_text}") + lines.append(_trade_counts(entry, locale)) + from notification_mode import consolidation_enabled + reason = block_reason_text(entry, locale if consolidation_enabled() else None) + if reason: + lines.append(reason) + if entry.cycle_count_status == "unknown" or entry.cycle_count is None: + lines.append(_format_count_part(locale, label_key="cycles_label", value=entry.cycle_count, + status=entry.cycle_count_status)) + conclusion = _default_conclusion(entry, locale) + if conclusion and entry.rebalance_conclusion != "blocked": + lines.append(conclusion) + lines.append(f"{_t(locale, 'platform_tag')}: {entry.platform_id}") + return lines + + +def _render_run_line(entry: DigestRunEntry, locale: DigestLocale) -> list[str]: + if has_observation(entry): + return _render_observation_block(entry, locale) + return [ + _t(locale, "platform_line").format( + platform=entry.platform_id, + strategy=_display_strategy(entry, locale), + detail=_detail_for(entry, locale), + ) + ] + + +def resolve_evidence_status( + payload: DailyDigestInput, +) -> EvidenceStatus: + if payload.evidence_status in { + "verified_idle", + "evidence_unknown", + "has_runs", + }: + return payload.evidence_status # type: ignore[return-value] + runs = tuple(payload.runs) + if runs: + return "has_runs" + coverage = payload.evidence_coverage + if coverage is not None and coverage.is_complete_for_verified_idle: + return "verified_idle" + # Safer default: empty without proven coverage ≠ verified idle. + return "evidence_unknown" + + +def global_total_fills(payload: DailyDigestInput) -> int | None: + """An all-source total requires complete, current evidence; keep run facts.""" + coverage = payload.evidence_coverage + if coverage is not None and ( + coverage.has_source_failures or coverage.stale_candidates or coverage.missing_targets or coverage.legacy_candidates_only + ): + return None + if not payload.runs and resolve_evidence_status(payload) != "verified_idle": + return None + return total_fills(payload.runs) + + +def _render_stale_candidates( + coverage: EvidenceCoverage | None, locale: DigestLocale +) -> list[str]: + """Localized note listing candidate rows dropped as stale (NOTIFY-04).""" + + items = tuple(coverage.missing_targets or coverage.stale_candidates) if coverage is not None else () + if not items: + return [] + lines = [_t(locale, "stale_candidates_header")] + seen: set[tuple[str, str, str]] = set() + for item in items: + platform = str(item.get("platform_id") or "").strip() or "unknown" + strategy = ( + str(item.get("strategy_label") or "").strip() + or str(item.get("strategy_profile") or "").strip() + or "unknown" + ) + strategy = display_name("strategies", item.get("strategy_profile"), locale, fallback=strategy) + day = str(item.get("candidate_business_day") or "").strip() + detail = ( + _t(locale, "stale_detail_day").format(day=day) + if day + else _t(locale, "stale_detail_undated") + ) + reason = str(item.get("reason_code") or "") + code = reason if reason in MISSING_EVIDENCE_REASONS else "EVIDENCE_STALE_BUSINESS_DAY" + detail = missing_evidence_text(locale, code) + (f"; {detail}" if "candidate_business_day" in item else "") + hint = str(item.get("account_hint") or "").strip() + if hint: + strategy = f"{strategy} [{hint}]" + key = (platform, strategy, detail) + if key in seen: + continue + seen.add(key) + lines.append( + _t(locale, "stale_candidate_line").format( + platform=platform, strategy=strategy, detail=detail + ) + ) + return lines + + +def render_daily_digest(payload: DailyDigestInput) -> str: + """Render one Telegram message body (never includes tokens or chat ids). + + Omits schedule window and channel footer lines (product preference). + Multiple observation blocks are separated by a blank line. + """ + + if normalize_layout(str(payload.layout)) == "sections_v2": + return render_daily_digest_sections(payload) + locale = normalize_locale(str(payload.locale)) + runs = tuple(payload.runs) + lines: list[str] = [] + rich = any(has_observation(entry) for entry in runs) + evidence_status = resolve_evidence_status(payload) + known_fills = global_total_fills(payload) + coverage = payload.evidence_coverage + candidate_failure_line = ( + _t(locale, "candidate_source_failures").format(count=len(coverage.candidate_failures)) + if coverage is not None and coverage.candidate_failures else "" + ) + + if not runs: + lines.append(_t(locale, "daily_digest_heartbeat_title")) + lines.append(f"{_t(locale, 'date_label')}: {payload.business_day}") + stale_lines = _render_stale_candidates(payload.evidence_coverage, locale) + if evidence_status == "verified_idle": + lines.append(_t(locale, "heartbeat_no_run_verified")) + elif stale_lines: + lines.append(_t(locale, "heartbeat_all_candidates_stale")) + else: + lines.append(_t(locale, "heartbeat_evidence_unknown")) + if evidence_status != "verified_idle" and not stale_lines: + lines.append(missing_evidence_text(locale, _source_missing_reason(coverage))) + if stale_lines: + lines.append("") + lines.extend(stale_lines) + if candidate_failure_line: + lines.append(candidate_failure_line) + return "\n".join(lines) + + # Prefer 收盘日报 when any run has known fills > 0, even if other runs' + # fill counts are unknown (mixed multi-platform digest). + if has_known_positive_fills(runs) or (known_fills is not None and known_fills > 0): + lines.append(_t(locale, "daily_digest_title")) + else: + lines.append(_t(locale, "daily_digest_heartbeat_title")) + lines.append(f"{_t(locale, 'date_label')}: {payload.business_day}") + counts_missing = any(entry.order_count_status == "unknown" or entry.order_count is None for entry in runs) + notice = _counts_notice(payload, locale) + if notice: + lines.append(notice) + if not rich: + if known_fills is None or counts_missing: + lines.append(_t(locale, "ran_section")) + elif known_fills == 0: + lines.append(_t(locale, "heartbeat_no_fill_verified")) + lines.append(_t(locale, "ran_section")) + else: + lines.append(_t(locale, "ran_section")) + + for index, entry in enumerate(runs): + block = _render_run_line(entry, locale) + # Separate multi-platform / multi-run blocks for readability. + # Observation blocks always get a blank line; thin list rows stay compact. + if index > 0 and ( + has_observation(entry) + or has_observation(runs[index - 1]) + or rich + ): + lines.append("") + lines.extend(block) + stale_lines = _render_stale_candidates(payload.evidence_coverage, locale) + if stale_lines: + lines.append("") + lines.extend(stale_lines) + if candidate_failure_line: + lines.append("") + lines.append(candidate_failure_line) + return "\n".join(lines) + + +def _entry_section(entry: DigestRunEntry) -> str: + return entry.section if entry.section in DIGEST_SECTIONS else "us_daily" + + +def _render_section_runs(entries: Sequence[DigestRunEntry], locale: DigestLocale) -> list[str]: + lines: list[str] = [] + for index, entry in enumerate(entries): + if index > 0: + lines.append("") + lines.extend(_render_run_line(entry, locale)) + return lines + + +def _is_weekend(day: str) -> bool: + from datetime import date as _date + + try: + return _date.fromisoformat(day).weekday() >= 5 + except ValueError: + return False + + +def render_daily_digest_sections(payload: DailyDigestInput) -> str: + """NOTIFY-04 B1 layout: one report, four sections, Shanghai cut-off window. + + Sections: ⚠️ attention → 📈 US daily → 💰 DCA → 🪙 crypto intraday → 🧾 + coverage. Empty DCA / crypto sections are omitted (they only appear on days + they ran); the US section says market-closed on NY weekends and "no run + evidence" otherwise. Numbers only come from evidence (same rules as v1). + """ + + locale = normalize_locale(str(payload.locale)) + runs = tuple(payload.runs) + window = dict(payload.window or {}) + coverage = payload.evidence_coverage + evidence_status = resolve_evidence_status(payload) + report_date = str(window.get("report_date") or payload.business_day) + lines = [_t(locale, "v2_title").format(date=report_date)] + if window.get("start_local") and window.get("end_local"): + lines.append( + _t(locale, "v2_window").format( + start=window["start_local"], end=window["end_local"] + ) + ) + + if runs: + notice = _counts_notice(payload, locale) + if notice: + lines.append(notice) + + attention: list[str] = [] + if coverage is not None and coverage.candidate_failures: + attention.append(_t(locale, "candidate_source_failures").format(count=len(coverage.candidate_failures))) + for entry in runs: + if entry.status == "alert": + if is_blocked(entry) or counts_not_connected(entry): + detail = _default_conclusion(entry, locale) if is_blocked(entry) else "" + from notification_mode import consolidation_enabled + reason = block_reason_text(entry, locale if consolidation_enabled() else None) + if reason: + detail = f"{detail} · {reason}" if detail else reason + if counts_not_connected(entry): + detail = " · ".join(filter(None, (detail, _trade_counts(entry, locale)))) + attention.append(_t(locale, "v2_attention_status").format( + tag=account_block_tag(entry), strategy=_display_strategy(entry, locale), detail=detail, + )) + continue + reason = f"({entry.reason_code})" if locale == "zh" and entry.reason_code else ( + f" ({entry.reason_code})" if entry.reason_code else "" + ) + attention.append( + _t(locale, "v2_attention_alert").format( + tag=account_block_tag(entry), + strategy=_display_strategy(entry, locale), + reason=reason, + ) + ) + stale_lines = _render_stale_candidates(coverage, locale) + if not runs and evidence_status != "verified_idle": + attention.append( + _t(locale, "heartbeat_all_candidates_stale") + if stale_lines + else _t(locale, "v2_evidence_unknown") + ) + if not stale_lines: + attention.append(missing_evidence_text(locale, _source_missing_reason(coverage))) + if stale_lines: + attention.append(_t(locale, "v2_stale_header")) + attention.extend(stale_lines[1:]) + if attention: + lines.append("") + lines.append(_t(locale, "v2_section_attention")) + lines.extend(attention) + + grouped: dict[str, list[DigestRunEntry]] = {key: [] for key in DIGEST_SECTIONS} + for entry in runs: + grouped[_entry_section(entry)].append(entry) + + lines.append("") + lines.append(_t(locale, "v2_section_us_daily").format(day=payload.business_day)) + if grouped["us_daily"]: + lines.extend(_render_section_runs(grouped["us_daily"], locale)) + elif _is_weekend(payload.business_day): + lines.append(_t(locale, "v2_us_market_closed")) + elif evidence_status == "verified_idle": + lines.append(_t(locale, "v2_verified_idle")) + else: + lines.append(_t(locale, "v2_no_evidence")) + + for key, title_key in (("dca", "v2_section_dca"), ("crypto_intraday", "v2_section_crypto")): + if grouped[key]: + lines.append("") + lines.append(_t(locale, title_key)) + lines.extend(_render_section_runs(grouped[key], locale)) + + lines.append("") + lines.append(_t(locale, "v2_section_coverage")) + lines.append( + _t(locale, "v2_coverage_line").format( + runs=len(runs), + fresh=coverage.candidates_count if coverage is not None else 0, + stale=coverage.stale_candidates_count if coverage is not None else 0, + ) + ) + if coverage is not None and coverage.github_failures: + lines.append( + _t(locale, "v2_coverage_github_failures").format( + count=len(coverage.github_failures) + ) + ) + if coverage is not None and coverage.candidate_failures: + lines.append(_t(locale, "candidate_source_failures").format(count=len(coverage.candidate_failures))) + return "\n".join(lines) + + +def _parse_holdings(raw: Any) -> tuple[DigestHolding, ...]: + if raw is None or raw == "": + return () + if not isinstance(raw, (list, tuple)): + raise ValueError("holdings must be a list") + out: list[DigestHolding] = [] + for item in raw: + if not isinstance(item, Mapping): + raise ValueError("each holding must be an object") + symbol = str(item.get("symbol") or "").strip() + if not symbol: + continue + currency = str(item.get("currency") or item.get("ccy") or "USD").strip() or "USD" + qty_raw = item.get("quantity", item.get("qty", item.get("shares"))) + value_raw = item.get( + "market_value", item.get("value", item.get("market_value_usd")) + ) + out.append( + DigestHolding( + symbol=symbol, + market_value=_optional_float(value_raw), + quantity=_optional_float(qty_raw), + currency=currency, + ) + ) + return tuple(out) + + +def _parse_tips(raw: Any) -> tuple[str, ...]: + if raw is None or raw == "": + return () + if isinstance(raw, str): + text = raw.strip() + return (text,) if text else () + if isinstance(raw, (list, tuple)): + tips = [str(item).strip() for item in raw if str(item).strip()] + return tuple(tips) + raise ValueError("tips must be a string or list") + + +def _parse_rebalance_kind(raw: Any) -> RebalanceKind: + value = str(raw or "").strip().lower() + if not value: + return "" + aliases = { + "no_rebalance": "no_rebalance", + "none": "no_rebalance", + "heartbeat": "no_rebalance", + "rebalance": "rebalance", + "order": "rebalance", + "no_order": "no_order", + "skipped": "no_order", + "pending": "pending", + "pending_submit": "pending", + } + kind = aliases.get(value, value) + allowed: set[str] = {"", "no_rebalance", "rebalance", "no_order", "pending"} + if kind not in allowed: + raise ValueError(f"unsupported rebalance_kind: {raw!r}") + return kind # type: ignore[return-value] + + +def _parse_actually_ran(raw: Mapping[str, Any]) -> bool | None: + """Return True/False when key present; None when absent (do not default True).""" + + if "actually_ran" not in raw: + return None + value = raw.get("actually_ran") + if isinstance(value, bool): + return value + if value is None or value == "": + return None + if isinstance(value, (int, float)) and value in (0, 1): + return bool(value) + text = str(value).strip().lower() + if text in {"1", "true", "yes", "y"}: + return True + if text in {"0", "false", "no", "n"}: + return False + raise ValueError(f"unsupported actually_ran value: {value!r}") + + +def _parse_section(raw: Any) -> str: + value = str(raw or "").strip().lower() + return value if value in DIGEST_SECTIONS else "" + + +def filter_runs_for_digest( + candidates: Sequence[Mapping[str, object]], +) -> list[DigestRunEntry]: + """Keep only rows with explicit ``actually_ran=True``. + + Absent ``actually_ran`` is treated as incomplete evidence and excluded + (no longer defaults to True). Optional observation keys are copied when + present and never fabricated here. + + Count compat: an explicit int 0 without field_status remains verified zero. + New stubs must omit counts or set field_status/reason_code for unknown. + """ + + out: list[DigestRunEntry] = [] + for raw in candidates: + ran = _parse_actually_ran(raw) + if ran is not True: + continue + holdings_raw = raw.get("holdings", raw.get("positions")) + # Old explicit integer counts remain compatible. Missing fields and + # signal labels carry no count evidence, including producer rows. + fill_count, fill_status = resolve_count_field(raw, "fill_count") + order_count, order_status = resolve_count_field(raw, "order_count") + cycle_count, cycle_status = resolve_count_field(raw, "cycle_count") + + out.append( + DigestRunEntry( + platform_id=str(raw["platform_id"]), + strategy_profile=str(raw["strategy_profile"]), + fill_count=fill_count, + order_count=order_count, + cycle_count=cycle_count, + status="alert" if str(raw.get("status", "ok")) == "alert" else "ok", + note=str(raw.get("note", "") or ""), + strategy_label=str(raw.get("strategy_label") or "").strip(), + equity=_optional_float(raw.get("equity", raw.get("equity_usd"))), + equity_currency=str( + raw.get("equity_currency") or raw.get("currency") or "USD" + ).strip() + or "USD", + holdings=_parse_holdings(holdings_raw), + holdings_scope=_parse_holdings_scope(raw.get("holdings_scope")), + signal_summary=str(raw.get("signal_summary") or "").strip(), + rebalance_kind=_parse_rebalance_kind(raw.get("rebalance_kind")), + rebalance_conclusion=str(raw.get("rebalance_conclusion") or "").strip(), + block_reason=_parse_block_reason(raw.get("block_reason")) if raw.get("platform_id") in {"schwab", "ibkr"} else None, + tips=_parse_tips(raw.get("tips") or raw.get("tip")), + account_hint=str(raw.get("account_hint") or "").strip(), + opaque_account_uid=str(raw.get("opaque_account_uid") or "").strip(), + target_id=str(raw.get("target_id") or "").strip(), + account_scope=str( + raw.get("account_scope") or raw.get("accountScope") or "" + ).strip(), + execution_mode=str( + raw.get("execution_mode") or raw.get("executionMode") or "" + ).strip(), + fill_count_status=fill_status, + order_count_status=order_status, + cycle_count_status=cycle_status, + reason_code=str(raw.get("reason_code") or "").strip(), + missing_evidence_reason=str(raw.get("missing_evidence_reason") or "").strip(), + section=_parse_section(raw.get("section")), + evidence_provenance=str(raw.get("evidence_provenance") or "").strip(), + execution_status=str(raw.get("execution_status") or "").strip(), + evidence_persistence_status=str( + raw.get("evidence_persistence_status") or "" + ).strip(), + evidence_delivery_status=str( + raw.get("evidence_delivery_status") or "" + ).strip(), + ) + ) + return out + + +__all__ = [ + "DIGEST_LAYOUTS", + "DIGEST_SECTIONS", + "DailyDigestInput", + "DigestHolding", + "DigestRunEntry", + "EvidenceCoverage", + "UNKNOWN_ACCOUNT_UID", + "UNKNOWN_TARGET_ID", + "account_block_tag", + "filter_runs_for_digest", + "normalize_layout", + "render_daily_digest_sections", + "fills_are_verified_zero", + "has_known_positive_fills", + "has_observation", + "identity_is_unknown", + "identity_key", + "normalize_locale", + "observation_score", + "render_daily_digest", + "resolve_count_field", + "resolve_evidence_status", + "total_fills", + "global_total_fills", +] diff --git a/tests/vendor/qrs_notification/display_names.json b/tests/vendor/qrs_notification/display_names.json new file mode 100644 index 0000000..5f2e628 --- /dev/null +++ b/tests/vendor/qrs_notification/display_names.json @@ -0,0 +1,241 @@ +{ + "account_aliases": { + "hk": { + "en": "HK", + "zh": "香港" + }, + "live": { + "en": "live", + "zh": "实盘" + }, + "paper": { + "en": "paper", + "zh": "模拟" + }, + "sg": { + "en": "SG", + "zh": "新加坡" + }, + "soxl": { + "en": "SOXL", + "zh": "SOXL" + }, + "tqqq": { + "en": "TQQQ", + "zh": "TQQQ" + } + }, + "accounts": { + "binance": { + "Binance": { + "en": "live", + "zh": "实盘" + }, + "binance": { + "en": "live", + "zh": "实盘" + }, + "crypto_combo": { + "en": "live", + "zh": "实盘" + }, + "default": { + "en": "live", + "zh": "实盘" + } + } + }, + "domains": { + "cn_equity": { + "en": "CN A-share", + "zh": "A股" + }, + "crypto": { + "en": "Crypto", + "zh": "加密" + }, + "hk_equity": { + "en": "HK Equity", + "zh": "港股" + }, + "us_equity": { + "en": "US Equity", + "zh": "美股" + } + }, + "lanes": { + "dry_run": { + "en": "dry run", + "zh": "演练" + }, + "live": { + "en": "live", + "zh": "实盘" + }, + "paper": { + "en": "paper", + "zh": "模拟" + }, + "research": { + "en": "research", + "zh": "研究" + }, + "shadow": { + "en": "shadow", + "zh": "影子" + } + }, + "platforms": { + "binance": "Binance", + "firstrade": "Firstrade", + "ibkr": "IBKR", + "longbridge": "LongBridge", + "qmt": "QMT", + "schwab": "Schwab" + }, + "senders": { + "quant_sentinel": { + "en": "QuantSentinel", + "zh": "量化哨兵" + } + }, + "strategies": { + "cn_chinext_growth_momentum_quality": { + "en": "CN ChiNext Growth Momentum Quality", + "zh": "创业板成长动量质量" + }, + "cn_chinext_growth_momentum_quality_snapshot": { + "en": "CN ChiNext Growth Quality Snapshot", + "zh": "创业板成长质量快照" + }, + "cn_chinext_tactical_rotation": { + "en": "CN ChiNext Tactical Rotation", + "zh": "创业板战术轮动" + }, + "cn_csi500_multi_factor_snapshot": { + "en": "CN CSI 500 Multi-Factor Snapshot", + "zh": "A股中证500多因子" + }, + "cn_dividend_quality_snapshot": { + "en": "CN Dividend Quality", + "zh": "A股红利质量" + }, + "cn_equity_combo": { + "en": "CN Alpha Combo", + "zh": "A股进取组合" + }, + "cn_index_etf_tactical_rotation": { + "en": "CN Index ETF Tactical Rotation", + "zh": "A股宽基ETF战术轮动" + }, + "cn_industry_etf_rotation": { + "en": "CN Industry ETF Rotation", + "zh": "A股行业ETF轮动" + }, + "cn_industry_etf_rotation_aggressive": { + "en": "CN ETF Rotation", + "zh": "A股ETF轮动" + }, + "cn_star_growth_momentum_quality": { + "en": "CN STAR Growth Momentum Quality", + "zh": "科创板成长动量质量" + }, + "cn_stock_momentum_rotation": { + "en": "CN Stock Momentum Rotation", + "zh": "A股股票动量轮动" + }, + "crypto_btc_dca": { + "en": "BTC DCA", + "zh": "BTC定投" + }, + "crypto_equity_combo": { + "en": "Crypto Core Combo", + "zh": "加密动量组合" + }, + "crypto_leader_rotation": { + "en": "Crypto Leader Rotation", + "zh": "加密领涨轮动" + }, + "crypto_live_pool_rotation": { + "en": "Crypto Pool Rotation", + "zh": "加密池轮动" + }, + "crypto_trend_rotation": { + "en": "Altcoin Trend", + "zh": "山寨趋势轮动" + }, + "global_etf_confidence_vol_gate": { + "en": "Global ETF Confidence Vol Gate", + "zh": "全球 ETF 置信波动门控" + }, + "global_etf_rotation": { + "en": "Global ETF Rotation", + "zh": "全球ETF轮动" + }, + "hk_equity_combo": { + "en": "HK Core Combo", + "zh": "港股恒生组合" + }, + "hk_global_etf_tactical_rotation": { + "en": "HK ETF Tactical Rotation", + "zh": "港股ETF战术轮动" + }, + "hk_low_vol_dividend_quality_snapshot": { + "en": "HK Dividend Quality", + "zh": "港股红利质量" + }, + "ibit_smart_dca": { + "en": "IBIT Bitcoin DCA", + "zh": "IBIT比特币定投" + }, + "nasdaq_sp500_smart_dca": { + "en": "NASDAQ/S&P 500 DCA", + "zh": "纳指标普定投" + }, + "qqq_tech_enhancement": { + "en": "Tech/Communication Pullback Enhancement", + "zh": "科技通信回调增强" + }, + "russell_top50_leader_rotation": { + "en": "Russell Top50 Leaders", + "zh": "罗素Top50领涨" + }, + "soxl_soxx_core_only_p2_v7_longterm_compounding_cash_reserve": { + "en": "SOXL/SOXX Core Compounding (Cash Reserve)", + "zh": "SOXL/SOXX 核心复利(现金保留)" + }, + "soxl_soxx_trend_income": { + "en": "Semiconductor Trend Income", + "zh": "半导体趋势收益" + }, + "tech_communication_pullback_enhancement": { + "en": "Tech/Communication Pullback Enhancement", + "zh": "科技通信回调增强" + }, + "tecl_xlk_trend_income": { + "en": "TECL/XLK Trend Income", + "zh": "TECL/XLK趋势收益" + }, + "tqqq_growth_income": { + "en": "NASDAQ Growth Income", + "zh": "纳斯达克增长收益" + }, + "us_equity_combo": { + "en": "US Core Income Combo", + "zh": "美股核心收益组合" + }, + "us_equity_combo_core": { + "en": "US Core Combo", + "zh": "美股核心组合" + }, + "us_equity_combo_leveraged": { + "en": "US Alpha Combo", + "zh": "美股加速组合" + } + }, + "strategy_aliases": { + "bitcoin_etf_smart_dca": "ibit_smart_dca", + "global_etf_confidence_vol_gate": "global_etf_rotation", + "global_macro_etf_rotation": "global_etf_rotation" + } +} diff --git a/tests/vendor/qrs_notification/display_names.py b/tests/vendor/qrs_notification/display_names.py new file mode 100644 index 0000000..a045cee --- /dev/null +++ b/tests/vendor/qrs_notification/display_names.py @@ -0,0 +1,54 @@ +"""Display-only resolver; generated defaults come from QRS platform-config.json. + +Never use resolved names for execution identity, bindings, keys or deduplication. +QSL_DISPLAY_NAMES_JSON accepts an optional JSON object with per-section overrides. +Invalid overrides fall back to bundled names so an urgent alert can still render. +""" +from __future__ import annotations + +import json +import os +from functools import lru_cache +from pathlib import Path + + +@lru_cache(maxsize=1) +def _defaults(): + return json.loads(Path(__file__).with_suffix(".json").read_text(encoding="utf-8")) + + +@lru_cache(maxsize=8) +def _overrides(raw): + try: + value = json.loads(raw or "{}") + except (ValueError, TypeError): + return {} + return value if isinstance(value, dict) else {} + + +def _text(value, locale): + if isinstance(value, dict): + language = "zh" if str(locale).lower().startswith("zh") else "en" + value = value.get(language) or value.get("en") + return value.strip() if isinstance(value, str) else "" + + +def display_name(kind, key, locale="en", *, fallback="", platform=""): + """Resolve an explicit display key, retaining unknown operator labels.""" + key = str(key or "").strip() + defaults = _defaults() + overrides = _overrides(os.environ.get("QSL_DISPLAY_NAMES_JSON", "")) + aliases = {**defaults.get("strategy_aliases", {}), **(overrides.get("strategy_aliases", {}) if isinstance(overrides.get("strategy_aliases"), dict) else {})} + canonical = aliases.get(key, key) if kind == "strategies" else key + if not isinstance(canonical, str): + canonical = key + for source in (overrides, defaults): + section = source.get(kind, {}) + if kind == "accounts" and isinstance(section, dict): + section = section.get(str(platform).lower(), {}) + if isinstance(section, dict): + first, second = (key, canonical) if source is overrides else (canonical, key) + result = _text(section.get(first), locale) or _text(section.get(second), locale) + if result: + return result + return str(fallback or key) diff --git a/tests/vendor/qrs_notification/merge_digest_candidates.py b/tests/vendor/qrs_notification/merge_digest_candidates.py new file mode 100644 index 0000000..019a0d8 --- /dev/null +++ b/tests/vendor/qrs_notification/merge_digest_candidates.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +"""Merge producer DIGEST_CANDIDATES files into one candidates JSON (NOTIFY-04 B2). + +Inputs: an optional base file (e.g. materialized from the legacy Environment +secret) plus every ``*.json`` under ``--input-dir`` (files fetched from the +private producer store, one per platform/target). Output: a single +``qsl.digest_candidates.v1`` envelope with concatenated ``runs``. + +Configured inputs must all be readable candidate envelopes; failed/skipped +producer output is not evidence of an empty day. Any invalid input aborts the +merge before writing output, rather than claiming complete partial coverage. +Freshness is not decided here: the aggregator drops rows whose business day +differs from the digest business day (NOTIFY-04 B0). This script never prints +row contents — only counts — because rows may carry account equity. +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +from pathlib import Path +from typing import Any + +SCHEMA_VERSION = "qsl.digest_candidates.v1" + + +def _rows(payload: Any) -> list[dict[str, Any]]: + if isinstance(payload, list): + raw = payload + elif isinstance(payload, dict): + if payload.get("schema_version", SCHEMA_VERSION) != SCHEMA_VERSION: + raise ValueError("candidate_schema_unsupported") + if "producer_status" in payload and ( + not isinstance(payload["producer_status"], str) + or payload["producer_status"] not in {"projected", "empty", "skipped"} + ): + raise ValueError("candidate_producer_status_invalid") + if payload.get("producer_status") == "skipped": + raise ValueError("candidate_producer_skipped") + if "runs" not in payload and "candidates" not in payload: + raise ValueError("candidate_rows_missing") + raw = payload.get("runs", payload.get("candidates")) + else: + raise ValueError("candidates payload must be an object or list") + if not isinstance(raw, list) or not all(isinstance(item, dict) for item in raw): + raise ValueError("candidates runs must be a list of objects") + for item in raw: + for name, limit in (("platform_id", 64), ("strategy_profile", 128)): + value = item.get(name) + if not isinstance(value, str) or not value.strip() or len(value) > limit: + raise ValueError("candidate_required_fields_invalid") + if type(item.get("actually_ran")) is not bool: + raise ValueError("candidate_required_fields_invalid") + from notification_mode import consolidation_enabled + if consolidation_enabled(): + from notification_candidate_adapter import validate_run_row + if isinstance(payload, dict) and any( + name in payload for name in ("source_runs", "source_run_ids", "events_by_run", "notification_source_runs", "notification_events_by_run", "notification_events") + ): + raise ValueError("notification_candidate_per_run_required") + raw = [validate_run_row(item) for item in raw] + return [dict(item, legacy=True) if isinstance(payload, dict) and payload.get("legacy") is True + else dict(item) for item in raw] + + +def _read_rows(path: Path) -> list[dict[str, Any]]: + try: + return _rows(json.loads(path.read_text(encoding="utf-8"))) + except (OSError, ValueError): + # Paths and parser details can contain private target/account material. + raise ValueError("candidate_input_unavailable_or_invalid") from None + + +def _walk_error(_error: OSError) -> None: + raise ValueError("candidate_input_directory_unavailable") from None + + +def merge(base: Path | None, input_dir: Path | None) -> tuple[dict[str, Any], dict[str, int]]: + if base is None and input_dir is None: + raise ValueError("candidate_source_unconfigured") + runs: list[dict[str, Any]] = [] + stats = {"base_rows": 0, "files": 0, "file_rows": 0, "invalid_files": 0} + if base is not None: + rows = _read_rows(base) + stats["base_rows"] = len(rows) + runs.extend(rows) + if input_dir is not None: + if not input_dir.is_dir(): + raise ValueError("candidate_input_directory_unavailable") + # pathlib glob can silently suppress directory-read failures. + paths = [Path(root) / name for root, _dirs, names in os.walk(input_dir, onerror=_walk_error) + for name in names if name.endswith(".json")] + for path in sorted(paths): + rows = _read_rows(path) + stats["files"] += 1 + stats["file_rows"] += len(rows) + runs.extend(rows) + return {"schema_version": SCHEMA_VERSION, "runs": runs}, stats + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--base", type=Path, default=None) + parser.add_argument("--input-dir", type=Path, default=None) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args(argv) + try: + payload, stats = merge(args.base, args.input_dir) + except (OSError, ValueError): + print(json.dumps({"status": "failed", "reason_code": "candidate_input_unavailable_or_invalid"}), file=sys.stderr) + return 2 + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8") + print(json.dumps({"merged_rows": len(payload["runs"]), **stats}, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/vendor/qrs_notification/notification_candidate_adapter.py b/tests/vendor/qrs_notification/notification_candidate_adapter.py new file mode 100644 index 0000000..4242938 --- /dev/null +++ b/tests/vendor/qrs_notification/notification_candidate_adapter.py @@ -0,0 +1,244 @@ +"""QPK event facts -> existing private digest rows, without IO or alert sends.""" +from __future__ import annotations + +from collections import defaultdict +from dataclasses import fields +from datetime import datetime +from typing import Any, Mapping, Sequence + +import daily_digest_notify as digest +from notification_snapshot.unified_contract import NotificationEvent, sgt_business_window + +PLATFORMS = ("ibkr", "schwab", "longbridge", "binance", "firstrade", "qrs") +ROUTINE_KINDS = {"routine_cycle", "healthy_heartbeat"} +IDENTITY = ("platform_id", "opaque_account_uid", "target_id", "strategy_profile", "source_run_id") + + +def _event(raw: Mapping[str, Any]) -> NotificationEvent: + if raw.get("schema_version") != "qsl.notification_event.v1": + raise ValueError("notification_event_schema_invalid") + try: + if not isinstance(raw.get("event_kind"), str) or not raw["event_kind"].strip(): + raise ValueError("notification_event_kind_invalid") + for name in (*IDENTITY, "incident_reason", "expected_block_evidence", "order_execution_digest"): + if raw.get(name) is not None and not isinstance(raw[name], str): + raise ValueError("notification_event_identity_invalid") + values = {field.name: raw[field.name] for field in fields(NotificationEvent) if field.name in raw} + values["occurred_at"] = datetime.fromisoformat(values["occurred_at"].replace("Z", "+00:00")) + event = NotificationEvent(**values) + projected = event.to_candidate() + for name in ("business_day", "report_date", "window_id", "dedupe_key", "coverage_status"): + if name in raw and raw[name] != projected[name]: + raise ValueError("notification_event_projection_mismatch") + return event + except (KeyError, TypeError, ValueError, AttributeError): + raise ValueError("notification_event_invalid") from None + + +def events_for(row: Mapping[str, Any]) -> list[NotificationEvent]: + if "notification_events" in row: + raw = row["notification_events"] + if not isinstance(raw, list) or not all(isinstance(item, Mapping) for item in raw): + raise ValueError("notification_events_invalid") + elif "notification_event" in row: + raw = [row["notification_event"]] + elif row.get("schema_version") == "qsl.notification_event.v1": + raw = [row] + else: + return [] + if not all(isinstance(item, Mapping) for item in raw): + raise ValueError("notification_events_invalid") + return [_event(item) for item in raw] + + +def _bind_identity(row, events): + for event in events: + for name in IDENTITY: + value = getattr(event, name) + if row.get(name) and value and row[name] != value: + raise ValueError("notification_candidate_identity_mismatch") + if value and not row.get(name): + row[name] = value + + +def validate_run_row(raw: Mapping[str, Any]) -> dict[str, Any]: + """Validate one business run; never accept a window aggregate as a run.""" + row = dict(raw) + events = events_for(row) + _bind_identity(row, events) + if type(row.get("actually_ran")) is not bool: + raise ValueError("notification_candidate_run_evidence_required") + if not row["actually_ran"] and events: + raise ValueError("notification_candidate_noncounting_events_unsupported") + if row["actually_ran"]: + run_id = row.get("source_run_id") + if not isinstance(run_id, str) or not run_id.strip(): + raise ValueError("notification_candidate_source_run_id_required") + for event in events: + if event.source_run_id != run_id: + raise ValueError("notification_candidate_identity_mismatch") + if row.get("schema_version") != "qsl.notification_event.v1": + for name in ("order_count", "fill_count", "cycle_count"): + if name not in row or (row[name] is not None and (type(row[name]) is not int or row[name] < 0)): + raise ValueError("notification_candidate_count_invalid") + cycles, status = digest.resolve_count_field(row, "cycle_count") + if status == "known" and cycles != 1: + raise ValueError("notification_candidate_per_run_required") + if any(name in row for name in ( + "source_runs", "source_run_ids", "events_by_run", "notification_source_runs", "notification_events_by_run" + )): + raise ValueError("notification_candidate_per_run_required") + return row + + +def partition(rows: Sequence[Mapping[str, Any]], business_day: str): + """Use event timestamps for new facts; legacy rows retain existing dates.""" + current, stale = [], [] + for raw in rows: + row = validate_run_row(raw) + events = events_for(row) + _bind_identity(row, events) + if events: + days = {sgt_business_window(event.occurred_at).business_day.isoformat() for event in events} + if len(days) != 1: + raise ValueError("notification_events_span_windows") + day = days.pop() + if row.get("business_day") and row["business_day"] != day: + raise ValueError("notification_candidate_window_mismatch") + row["business_day"] = day + else: + day = str(row.get("business_day") or "") + if day == business_day: + current.append(row) + else: + row["business_day"] = day + stale.append(row) + return current, stale + + +def aggregate(rows: Sequence[Mapping[str, Any]], expected: Sequence[Mapping[str, Any]]): + """Count each protected run once, keeping conflicting counts unknown. + + Counts belong to archive-projected run rows. Order/fill event counts are + never added to those run counts or interpreted as fills. The same run's + multiple producer/event observations are evidence, not additional runs. + """ + grouped = {} + event_ids = set() + immediate_ids = set() + unknown_event = False + observed_targets = set() + run_counts = defaultdict(int) + unknown_run_platforms = set() + allowed = {tuple(str(row[name]) for name in IDENTITY[:4]) for row in expected} + for index, raw in enumerate(rows): + row = validate_run_row(raw) + events = events_for(row) + _bind_identity(row, events) + if row.get("schema_version") == "qsl.notification_event.v1": + # A raw event fact is not an archive-projected run counter. + # Accept its identity/incident, but require producer projection for + # whole-run order/fill counts rather than treating event counts as it. + row.update(order_count=None, fill_count=None, cycle_count=None) + row["field_status"] = {"order_count": "unknown", "fill_count": "unknown", "cycle_count": "unknown"} + for event in events: + if event.coverage_status != "known": + unknown_event = True + key = event.to_candidate()["dedupe_key"] + if key: + event_ids.add(key) + if event.event_kind not in ROUTINE_KINDS: + immediate_ids.add(key) + else: + # Keep unrelated unknown events separate; do not invent identity. + if event.event_kind not in ROUTINE_KINDS: + immediate_ids.add(("unknown", index, len(immediate_ids))) + if event.event_kind not in ROUTINE_KINDS | {"order_submitted", "fill"}: + row["status"] = "alert" + if event.event_kind == "blocked_run": + row["rebalance_conclusion"] = "blocked" + if row.get("actually_ran") is not True: + # An explicit not-due row covers a target but is never a run. + if row.get("actually_ran") is False and all(row.get(name) for name in IDENTITY[:4]): + observed_targets.add(tuple(str(row[name]) for name in IDENTITY[:4])) + continue + identity = tuple(str(row.get(name) or "") for name in IDENTITY) + known_identity = all(identity) + if all(identity[:4]) and identity[:4] not in allowed: + raise ValueError("notification_candidate_target_unexpected") + if not known_identity: + unknown_run_platforms.add(row["platform_id"]) + else: + observed_targets.add(identity[:4]) + key = identity if known_identity else ("unknown", index) + blocked = bool(row.get("block_reason")) or row.get("rebalance_conclusion") in {"blocked", "risk_blocked"} or row.get("signal_summary") in {"blocked", "risk_blocked"} + if (row.get("status") == "alert" or blocked) and not any( + event.event_kind not in ROUTINE_KINDS for event in events): + immediate_ids.add(("run", key)) + previous = grouped.get(key) + if previous is None: + grouped[key] = row + run_counts[row["platform_id"]] += 1 + continue + merged = dict(previous) + if (previous.get("schema_version") == "qsl.notification_event.v1" + and row.get("schema_version") != "qsl.notification_event.v1"): + # The merged result is an archive run row once archive facts arrive. + merged.pop("schema_version", None) + for name in ("order_count", "fill_count", "cycle_count"): + old_value, old_status = digest.resolve_count_field(previous, name) + value, status = digest.resolve_count_field(row, name) + conflict_key = "_conflict_" + name + if previous.get(conflict_key) or (old_status == status == "known" and old_value != value): + merged[name] = None + merged[conflict_key] = True + merged["field_status"] = {**merged.get("field_status", {}), name: "unknown"} + elif old_status != "known" and status == "known": + merged[name] = value + merged["field_status"] = {**merged.get("field_status", {}), name: "known"} + # Keep only this run's events, including later monitor observations. + facts = {} + for event in (*events_for(previous), *events): + fact = event.to_candidate() + facts[event] = fact + if facts: + merged.pop("notification_event", None) + merged["notification_events"] = list(facts.values()) + # Never overwrite an earlier alert with a later healthy snapshot. + if row.get("status") == "alert": + merged["status"] = "alert" + for name in ("block_reason", "rebalance_conclusion", "execution_status"): + if row.get(name) and (not merged.get(name) or name == "block_reason"): + merged[name] = row[name] + grouped[key] = merged + # Raw observations do not permanently taint an archive-authoritative run. + # Conflicts remain sticky on the final row even after later duplicates. + count_fields = ("order_count", "fill_count", "cycle_count") + conflicts = sum(bool(row.get("_conflict_" + name)) + for row in grouped.values() for name in count_fields) + unknown = unknown_event or bool(unknown_run_platforms) or any( + digest.resolve_count_field(row, name)[1] != "known" + for row in grouped.values() for name in count_fields + ) + observed_targets &= allowed + scope_unknown = bool(unknown_run_platforms or conflicts or allowed - observed_targets) or unknown_event + unknown = unknown or scope_unknown + inventory = {} + for platform in PLATFORMS: + targets = {tuple(str(row.get(name) or "") for name in IDENTITY[:4]) + for row in expected if row.get("platform_id") == platform} + inventory[platform] = dict(expected_targets=len(targets), covered_targets=len(targets & observed_targets), + runs=None if platform in unknown_run_platforms else run_counts[platform]) + return list(grouped.values()), dict(source_inventory=inventory, counts_unknown=unknown, counts_scope_unknown=scope_unknown, + run_count_unknown=bool(unknown_run_platforms), + event_count=len(event_ids), immediate_events=len(immediate_ids), + count_conflicts=conflicts) + + +def validate_expected(expected): + if not isinstance(expected, list) or not expected: + raise ValueError("digest_expected_targets_required") + for row in expected: + if not isinstance(row, Mapping) or row.get("platform_id") not in PLATFORMS or not all( + isinstance(row.get(name), str) and row[name].strip() for name in IDENTITY[:4]): + raise ValueError("digest_expected_targets_invalid") diff --git a/tests/vendor/qrs_notification/notification_mode.py b/tests/vendor/qrs_notification/notification_mode.py new file mode 100644 index 0000000..73ef463 --- /dev/null +++ b/tests/vendor/qrs_notification/notification_mode.py @@ -0,0 +1,18 @@ +"""Default-off release selector for the existing single notification mode.""" +from __future__ import annotations + +import os +from typing import Mapping +from notification_snapshot.unified_contract import unified_notification_enabled + + +def consolidation_enabled(env: Mapping[str, str] | None = None) -> bool: + values = os.environ if env is None else env + return unified_notification_enabled(values, "QRS_UNIFIED_NOTIFICATION_ENABLED") + + +def routine_mode(env: Mapping[str, str] | None = None) -> str: + values = os.environ if env is None else env + if consolidation_enabled(values): + return "daily_digest" + return values.get("QSL_ROUTINE_NOTIFICATION_MODE", "immediate") diff --git a/tests/vendor/qrs_notification/notification_snapshot/__init__.py b/tests/vendor/qrs_notification/notification_snapshot/__init__.py new file mode 100644 index 0000000..3e87683 --- /dev/null +++ b/tests/vendor/qrs_notification/notification_snapshot/__init__.py @@ -0,0 +1 @@ +"""Notification-only QPK source snapshot; provenance in source.json.""" diff --git a/tests/vendor/qrs_notification/notification_snapshot/digest_routing.py b/tests/vendor/qrs_notification/notification_snapshot/digest_routing.py new file mode 100644 index 0000000..3d6929d --- /dev/null +++ b/tests/vendor/qrs_notification/notification_snapshot/digest_routing.py @@ -0,0 +1,92 @@ +"""Opt-in routing for routine cycles whose persisted reports feed a daily digest. + +This decides notification delivery only. It never changes an execution result, +calls a broker, grants order permissions, or treats deferral as delivery. +""" + +from __future__ import annotations + +import os +from collections.abc import Mapping, Sequence + + +def daily_digest_enabled(mode: str | None = None) -> bool: + return (mode if mode is not None else os.getenv("QSL_ROUTINE_NOTIFICATION_MODE", "immediate")) == "daily_digest" + + +def defer_routine_cycle_to_digest( + execution: Mapping[str, object], + *, + submitted_order_count: int, + mode: str | None = None, + submitted_orders: Sequence[Mapping[str, object]] | None = None, + include_orders: bool | None = None, +) -> bool: + """Defer healthy cycles and confirmed routine orders; urgent states stay live. + + Default ``QSL_ROUTINE_NOTIFICATION_MODE=immediate`` preserves existing + callers. An owner can opt a verified producer/recipient into ``daily_digest`` + after validating the existing private report-to-digest replacement path. + """ + if not daily_digest_enabled(mode) or type(submitted_order_count) is not int or submitted_order_count < 0: + return False + if not isinstance(execution, Mapping): + return False + if str(execution.get("execution_status") or "").lower() not in {"no_action", "no_op", "completed", "executed"}: + return False + if execution.get("no_op_reason") not in ( + None, "", "no_action", "no_signal", "no_rebalance", "below_threshold", + "rebalance_diff_below_threshold", "threshold_not_met", + ): + return False + if any(execution.get(key) for key in ( + "error", "errors", "fail_reason", "execution_blocked_reason", + "plugin_error", "persistence_error", "report_persistence_error", "notification_error", + "pending_reconciliation", "reconciliation_required", "market_data_retry_required", + "notification_attention_reason_codes", "orders_pending_count", + "account_new_risk_buy_blocked", "small_account_buy_blocked", "risk_flags", + "execution_blocked", "execution_blocking_skips", "strategy_run_persistence_error", + "strategy_plugin_error", "strategy_plugin_alert_error", "auth_expired", + "strategy_plugin_alert_failed_count", "strategy_plugin_alert_telegram_failed_count", + "data_error", "blocked_reason", "failed_order_count", + )): + # Only an explicit healthy no-order fact is eligible. Even a failed + # routine notification or unclassified no-op reason stays immediate. + return False + if str(execution.get("risk_gate") or "").upper() in {"BLOCK", "BLOCKED", "REJECT", "REJECTED", "DENY", "DENIED"}: + return False + if submitted_order_count: + orders_enabled = include_orders if include_orders is not None else os.getenv("QSL_DIGEST_ROUTINE_ORDERS", "true") == "true" + if not orders_enabled or submitted_orders is None or len(submitted_orders) != submitted_order_count: + return False + # A submission acknowledgement is not a completed fill. Pending, + # rejected and unknown outcomes retain the immediate channel. + if any(not isinstance(order, Mapping) or str(order.get("status") or "").lower() not in {"filled", "completed", "executed"} + for order in submitted_orders): + return False + elif execution.get("broker_submission_done"): + return False + return True + + +def immediate_plugin_signals(signals): + """Filter notification inputs only; callers retain original strategy signals.""" + if not daily_digest_enabled(): + return signals + return tuple(signal for signal in signals if not ( + str(getattr(signal, "canonical_route", "") or "").lower() in {"watch", "opportunity_watch", "panic_reversal", "taco_rebound"} + and str(getattr(signal, "suggested_action", "") or "").lower() in {"notify_manual_review", "watch", "observe"} + and not any(getattr(signal, key, None) for key in ("error", "errors", "risk_halt", "auth_expired")) + )) + + +def routine_notification(sender, runtime, report, text, *, routine_order=True): + """Route only explicitly classified routine call sites, never message text.""" + orders_enabled = os.getenv("QSL_DIGEST_ROUTINE_ORDERS", "true") == "true" + if daily_digest_enabled() and (not routine_order or orders_enabled): + report.setdefault("notifications", []).append({ + "delivery_status": "deferred_digest", "transport_acknowledged": False, + "sink": "telegram", "routine_order": routine_order, + }) + return False + return sender(runtime, report, text) diff --git a/tests/vendor/qrs_notification/notification_snapshot/unified_contract.py b/tests/vendor/qrs_notification/notification_snapshot/unified_contract.py new file mode 100644 index 0000000..94ec7ed --- /dev/null +++ b/tests/vendor/qrs_notification/notification_snapshot/unified_contract.py @@ -0,0 +1,284 @@ +"""Opt-in notification facts and routing; no broker, transport, or storage IO. + +Consumers inject their existing candidate producer and durable marker storage. +This module neither changes receipt acceptance nor replaces the central daily +recipient/report-date guard. Unknown evidence is never converted to zero. +""" +from __future__ import annotations + +import hashlib +import json +from collections.abc import Callable, Mapping +from dataclasses import asdict, dataclass +from datetime import date, datetime, time, timedelta +from typing import Literal, Protocol +from zoneinfo import ZoneInfo + +from .digest_routing import daily_digest_enabled + +Route = Literal["immediate", "digest_candidate", "legacy"] +EVENT_KINDS = frozenset({ + "routine_cycle", "healthy_heartbeat", "order_submitted", "fill", + "receipt_missing", "receipt_stale", "receipt_unacceptable", "auth_failure", + "order_status_unknown", "risk_escalation", "real_error", "blocked_run", + "expected_block_observed", +}) +ROUTINE_EVENT_KINDS = frozenset({"routine_cycle", "healthy_heartbeat"}) +ORDER_EVENT_KINDS = frozenset({"order_submitted", "fill"}) +SGT = ZoneInfo("Asia/Singapore") + + +def unified_notification_enabled(env: Mapping[str, object], key: str) -> bool: + """Read a release flag: only boolean True or the string 'true' enables it.""" + value = env.get(key) + return value is True or isinstance(value, str) and value.strip().lower() == "true" + + +def effective_notification_mode(env: Mapping[str, object], flag_key: str) -> str: + """Map the release boolean onto the existing single routing mode.""" + if unified_notification_enabled(env, flag_key): + return "daily_digest" + return str(env.get("QSL_ROUTINE_NOTIFICATION_MODE") or "immediate") + + +def select_notification_route(*, event_kind: str, env: Mapping[str, object], flag_key: str) -> Route: + """OFF delegates untouched to legacy. Unknown/blocked/urgent stays immediate. + + The new contract defaults routine orders to immediate. Legacy routing's + historical order default is deliberately not changed by this API. + """ + if not unified_notification_enabled(env, flag_key): + return "legacy" + if daily_digest_enabled(effective_notification_mode(env, flag_key)): + if event_kind in ROUTINE_EVENT_KINDS: + return "digest_candidate" + if event_kind in ORDER_EVENT_KINDS and unified_notification_enabled(env, "QSL_DIGEST_ROUTINE_ORDERS"): + return "digest_candidate" + return "immediate" + + +@dataclass(frozen=True) +class BusinessWindow: + start: datetime + end: datetime + business_day: date + report_date: date + window_id: str + + +def sgt_business_window(occurred_at: datetime) -> BusinessWindow: + """Containing half-open [D 06:00, D+1 06:00) window; not a send guard.""" + if occurred_at.tzinfo is None or occurred_at.utcoffset() is None: + raise ValueError("notification_time_timezone_missing") + day = (occurred_at.astimezone(SGT) - timedelta(hours=6)).date() + start = datetime.combine(day, time(6), SGT) + end = start + timedelta(days=1) + return BusinessWindow(start, end, day, end.date(), f"sgt-0600:{day.isoformat()}") + + +def _digest(parts: list[object]) -> str: + return hashlib.sha256(json.dumps(parts, ensure_ascii=False, separators=(",", ":")).encode()).hexdigest() + + +def order_identity_digest(order_or_execution_id: str) -> str: + """Irreversible, domain-separated identity; never put raw broker IDs in keys.""" + if not isinstance(order_or_execution_id, str) or not order_or_execution_id.strip(): + raise ValueError("notification_order_identity_missing") + return _digest(["qsl.notification_order.v1", order_or_execution_id]) + + +@dataclass(frozen=True) +class NotificationEvent: + event_kind: str + occurred_at: datetime + platform_id: str | None = None + opaque_account_uid: str | None = None + target_id: str | None = None + strategy_profile: str | None = None + source_run_id: str | None = None + order_execution_digest: str | None = None + incident_reason: str | None = None + order_count: int | None = None + fill_count: int | None = None + expected_block_evidence: str | None = None + + def __post_init__(self) -> None: + sgt_business_window(self.occurred_at) + for count in (self.order_count, self.fill_count): + if count is not None and (type(count) is not int or count < 0): + raise ValueError("notification_count_invalid") + digest = self.order_execution_digest + if digest is not None and (len(digest) != 64 or any(c not in "0123456789abcdef" for c in digest)): + raise ValueError("notification_order_digest_invalid") + if self.event_kind == "expected_block_observed" and not _identity_present(self.expected_block_evidence): + raise ValueError("notification_expected_block_evidence_missing") + + @property + def coverage_status(self) -> str: + return "known" if routine_dedupe_key(self) is not None else "unknown" + + def to_candidate(self) -> dict[str, object]: + """Private producer fact, to be projected with existing digest_candidates. + + This event envelope is not a replacement for qsl.digest_candidates.v1; + the existing archive projector remains responsible for window counts. + """ + result = asdict(self) + window = sgt_business_window(self.occurred_at) + result.update( + schema_version="qsl.notification_event.v1", occurred_at=self.occurred_at.isoformat(), + business_day=window.business_day.isoformat(), report_date=window.report_date.isoformat(), + window_id=window.window_id, dedupe_key=routine_dedupe_key(self), + coverage_status=self.coverage_status, + ) + if self.coverage_status == "unknown": + result.update(order_count=None, fill_count=None) + return result + + +def _identity_present(value: object) -> bool: + return isinstance(value, str) and bool(value.strip()) + + +def routine_dedupe_key(event: NotificationEvent) -> str | None: + parts = [event.platform_id, event.opaque_account_uid, event.target_id, + event.strategy_profile, event.source_run_id, event.event_kind] + if event.event_kind in ORDER_EVENT_KINDS: + parts.append(event.order_execution_digest) + return _digest(["qsl.notification_routine.v1", *parts]) if all(map(_identity_present, parts)) else None + + +class CandidatePersistenceError(RuntimeError): + pass + + +@dataclass(frozen=True) +class RoutingResult: + route: Route + candidate_persisted: bool + result: object = None + + +def route_notification_event( + event: NotificationEvent, *, env: Mapping[str, object], flag_key: str, + persist_candidate: Callable[[dict[str, object]], bool], + legacy: Callable[[], object], immediate: Callable[[], object], +) -> RoutingResult: + """Persist before deferral. Alert delivery survives candidate-write failure. + + The immediate callback owns durable incident reservation and receipt ACK. + Callers must inspect candidate_persisted; deferral is never delivery. + """ + route = select_notification_route(event_kind=event.event_kind, env=env, flag_key=flag_key) + if route == "legacy": + return RoutingResult(route, False, legacy()) + if route == "digest_candidate": + if event.coverage_status != "known": + raise CandidatePersistenceError("notification_candidate_identity_unknown") + if persist_candidate(event.to_candidate()) is not True: + raise CandidatePersistenceError("notification_candidate_write_unconfirmed") + return RoutingResult(route, True) + try: + persisted = persist_candidate(event.to_candidate()) is True + except Exception: + # A routine evidence-store failure cannot suppress a real alert. + persisted = False + return RoutingResult(route, persisted, immediate()) + + +class IncidentMarkerStore(Protocol): + """Durable JSON state, with atomic compare-and-set across workers. + + Missing key -> (None, None); read failures MUST raise, not act like missing. + A version is an opaque storage generation. No non-atomic has/write adapter + is provided: existing alert_marker has no atomic claim or readable receipt. + """ + def read(self, key: str) -> tuple[object, Mapping[str, object] | None]: ... + + def compare_and_set(self, key: str, version: object, value: Mapping[str, object]) -> bool: ... + + +@dataclass(frozen=True) +class IncidentDecision: + action: Literal["send", "duplicate", "recovered", "coverage_unknown"] + incident_key: str | None = None + transition_key: str | None = None + + +class IncidentTracker: + """Reserve before sending; pending/uncertain is never blindly resent. + + The caller supplies ordered observations and explicit recovery evidence. + Merely seeing a healthy label elsewhere is not proof of recovery. Severity + is a caller-owned nonnegative rank; a rise is a new escalation. Storage + errors propagate; a process-local set is never a production fallback. + """ + def __init__(self, store: IncidentMarkerStore): + self.store = store + + def _read_marker(self, key: str) -> tuple[object, dict[str, object] | None]: + version, value = self.store.read(key) + if version is None and value is None: + return version, None + required = {"schema_version", "active", "state", "severity", "generation", + "transition_key", "delivery_status", "receipt_id"} + if (version is None or not isinstance(value, Mapping) or not required.issubset(value) + or value["schema_version"] != "qsl.notification_incident.v1" + or type(value["active"]) is not bool + or not _identity_present(value["state"]) + or any(type(value[k]) is not int or value[k] < 0 for k in ("severity", "generation")) + or value["delivery_status"] not in {"not_attempted", "pending", "uncertain", "delivered"} + or (value["generation"] > 0 and not _identity_present(value["transition_key"]))): + raise RuntimeError("notification_marker_history_incomplete") + return version, dict(value) + + def observe(self, event: NotificationEvent, *, state: str, severity: int = 1, + recovered: bool = False) -> IncidentDecision: + parts = [event.platform_id, event.target_id, event.opaque_account_uid, + event.strategy_profile, event.incident_reason] + if not all(map(_identity_present, parts)): + return IncidentDecision("coverage_unknown") + if not _identity_present(state) or type(severity) is not int or severity < 0: + raise ValueError("notification_incident_state_invalid") + key = _digest(["qsl.notification_incident.v1", *parts]) + for _ in range(8): + version, previous = self._read_marker(key) + previous = previous or { + "schema_version": "qsl.notification_incident.v1", "active": False, + "state": None, "severity": 0, "generation": 0, + "transition_key": None, "delivery_status": "not_attempted", "receipt_id": None, + } + active = previous.get("active") is True + generation = int(previous.get("generation", 0)) + transition = previous.get("transition_key") + if recovered: + value = {**previous, "active": False, "state": state, "severity": severity} + action = "recovered" + elif active and severity <= int(previous["severity"]): + # A downgrade updates observation state without sending. Later + # severity-up remains visible, even before full recovery. + value = {**previous, "state": state, "severity": severity} + action = "duplicate" + else: + generation += 1 + transition = _digest([key, generation, previous.get("state"), state, severity]) + value = {"schema_version": "qsl.notification_incident.v1", + "active": True, "state": state, "severity": severity, + "generation": generation, "transition_key": transition, + "delivery_status": "pending", "receipt_id": None} + action = "send" + if value == previous or self.store.compare_and_set(key, version, value): + return IncidentDecision(action, key, transition) + raise RuntimeError("notification_marker_concurrent_update") + + def record_delivery(self, decision: IncidentDecision, *, receipt_id: str | None = None) -> None: + """Only an explicit receipt is delivered; absent receipt stays uncertain.""" + if decision.action != "send" or not decision.incident_key: + raise ValueError("notification_delivery_without_reservation") + version, previous = self._read_marker(decision.incident_key) + if not previous or previous.get("transition_key") != decision.transition_key: + raise RuntimeError("notification_marker_transition_changed") + value = {**previous, "delivery_status": "delivered" if _identity_present(receipt_id) else "uncertain", + "receipt_id": receipt_id if _identity_present(receipt_id) else None} + if not self.store.compare_and_set(decision.incident_key, version, value): + raise RuntimeError("notification_marker_concurrent_update") diff --git a/tests/vendor/qrs_notification/unified_daily_digest.py b/tests/vendor/qrs_notification/unified_daily_digest.py new file mode 100644 index 0000000..4e879e6 --- /dev/null +++ b/tests/vendor/qrs_notification/unified_daily_digest.py @@ -0,0 +1,125 @@ +"""Compact bilingual digest. Pure rendering; never split one digest into sends.""" +from __future__ import annotations + +import os +from notification_mode import consolidation_enabled, routine_mode +from display_names import display_name +from daily_digest_notify import DailyDigestInput, _trade_counts, block_reason_text, counts_not_connected, is_blocked + + +def enabled() -> bool: + return routine_mode() == "daily_digest" + + +def _units(text: str) -> int: + return len(text.encode("utf-16-le")) // 2 + + +def render(payload: DailyDigestInput, *, max_units: int = 3900) -> str: + if max_units < 256: + raise ValueError("digest_budget_too_small") + if consolidation_enabled(): + return _render_consolidated(payload, max_units=max_units) + trades, problems = [], [] + for row in payload.runs: + label = " / ".join(" ".join(str(v).split())[:70] for v in + (display_name("platforms", row.platform_id), display_name("accounts", row.account_hint or row.account_scope or row.execution_mode, platform=row.platform_id), display_name("strategies", row.strategy_profile, "zh", fallback=row.strategy_label or row.strategy_profile) + " / " + display_name("strategies", row.strategy_profile, "en", fallback=row.strategy_label or row.strategy_profile))) + known_orders = row.order_count is not None and row.order_count_status == "known" + known_fills = row.fill_count is not None and row.fill_count_status == "known" + if (known_orders and row.order_count > 0) or (known_fills and row.fill_count > 0): + orders = str(row.order_count) if known_orders else ("未接入统计 / not connected" if counts_not_connected(row) else "?") + completion = row.execution_status in {"executed", "completed"} + fill_text = f"成交 Fills {row.fill_count}" if known_fills else ( + "成交未接入统计 / Fill counting not connected" if counts_not_connected(row) else + "已完成 / Completed" if completion else "成交数量未知 / Fill count unknown" + ) + trades.append(f"• {label}:订单 Orders {orders} · {fill_text}") + complete_orders = known_orders and row.order_count > 0 and row.execution_status in {"executed", "completed"} + if row.status == "alert" or is_blocked(row) or not known_orders or (not known_fills and not complete_orders): + # Do not expose producer prose, raw exceptions or private identities. + reason = "执行异常 / Execution issue" if row.status == "alert" and known_orders and known_fills else "执行证据不完整 / Incomplete execution evidence" + if is_blocked(row): + reason = "已拦截 / Blocked" + detail = block_reason_text(row) + if detail: + reason += f" · {detail}" + if counts_not_connected(row): + counts = f"{_trade_counts(row, 'zh')} / {_trade_counts(row, 'en')}" + reason = f"{reason} · {counts}" if is_blocked(row) else counts + problems.append(f"• {label}:{reason}") + coverage = payload.evidence_coverage + if coverage: + for count, text in ( + (len(coverage.github_failures) + len(coverage.candidate_failures), "来源读取失败 / Source read failure"), + (len(coverage.stale_candidates), "候选过期 / Stale candidates"), + (len(coverage.missing_targets), "缺少目标报告 / Target report missing"), + ): + if count: + problems.append(f"• {text} ({count})") + if not payload.runs and not coverage.is_complete_for_verified_idle and not problems: + problems.append("• 缺少执行证据 / Execution evidence unavailable") + elif not payload.runs: + problems.append("• 缺少执行证据 / Execution evidence unavailable") + if not trades and not problems: + return "" + header = f"每日交易摘要 / Daily trading digest · {payload.business_day}" + rows = (["需关注 / Diagnostics"] + problems if problems else []) + (["交易 / Trading"] + trades if trades else []) + lines = [header] + for i, row in enumerate(rows): + suffix = f"\n其余 {len(rows) - i} 项已省略 / {len(rows) - i} more items omitted" + if _units("\n".join(lines + [row]) + suffix) > max_units: + lines.append(suffix.strip()) + break + lines.append(row) + return "\n".join(lines) + + +def _render_consolidated(payload: DailyDigestInput, *, max_units: int) -> str: + """One selected language and one message, including healthy zero-order runs.""" + if not payload.runs: + return "" + zh = not str(payload.locale).lower().startswith("en") + summary = payload.notification_summary or {} + coverage = payload.evidence_coverage + complete = bool(coverage and coverage.is_complete_for_verified_idle and not summary.get("counts_scope_unknown", summary.get("counts_unknown"))) + lines = [f"每日交易摘要 · {payload.business_day}" if zh else f"Daily trading digest · {payload.business_day}"] + lines.append(("窗口内运行次数未知" if zh else "Run count in window unknown") if summary.get("run_count_unknown") else + f"已收录运行 {len(payload.runs)} 次" if zh else f"Observed runs in window: {len(payload.runs)}") + counts = [] + for name, label in (("order", "订单" if zh else "Orders"), ("fill", "成交" if zh else "Fills")): + values = [getattr(row, name + "_count") for row in payload.runs] + known = complete and all(value is not None and getattr(row, name + "_count_status") == "known" + for row, value in zip(payload.runs, values)) + value = str(sum(values)) if known else ("全窗口统计未知" if zh else "whole-window count unknown") + counts.append(f"{label}:{value}" if zh else f"{label}: {value}") + lines.append(" · ".join(counts)) + if complete and all(row.order_count == 0 and row.order_count_status == "known" for row in payload.runs): + lines.append(("本轮 0 单" if len(payload.runs) == 1 else "窗口内各轮均为 0 单") if zh else + ("0 orders this run" if len(payload.runs) == 1 else "0 orders in every observed run")) + for platform, source in summary.get("source_inventory", {}).items(): + label = display_name("platforms", platform, "zh" if zh else "en") + expected, covered, runs = source["expected_targets"], source["covered_targets"], source["runs"] + runs = runs if runs is not None else ("未知" if zh else "unknown") + state = f"{covered}/{expected}" if expected else ("未配置" if zh else "not configured") + lines.append(f"{label}:运行 {runs} 次,覆盖 {state}" if zh else f"{label}: {runs} runs, coverage {state}") + immediate = summary.get("immediate_events", 0) + if immediate: + lines.append(f"即时事件 {immediate} 项(仅记录)" if zh else f"Immediate events: {immediate} (record only)") + for row in payload.runs: + if not is_blocked(row): + continue + detail = block_reason_text(row, "zh" if zh else "en") + detail = detail or ("已拦截,具体原因未知" if zh else "Blocked; specific reason unknown") + label = display_name("platforms", row.platform_id, "zh" if zh else "en") + lines.append(f"• {label}:{detail}" if zh else f"• {label}: {detail}") + if not complete: + lines.append("覆盖不完整,缺失统计保留未知" if zh else "Coverage incomplete; missing counts remain unknown") + result = [lines[0]] + for index, line in enumerate(lines[1:], 1): + remaining = len(lines) - index + suffix = f"其余 {remaining} 项已省略" if zh else f"{remaining} more items omitted" + if _units("\n".join(result + [line, suffix])) > max_units: + result.append(suffix) + break + result.append(line) + return "\n".join(result) diff --git a/tests/vendor/qrs_notification/upstream.json b/tests/vendor/qrs_notification/upstream.json new file mode 100644 index 0000000..7dc1ce9 --- /dev/null +++ b/tests/vendor/qrs_notification/upstream.json @@ -0,0 +1,47 @@ +{ + "repository": "QuantStrategyLab/QuantRuntimeSettings", + "commit": "fda2333316593fa6a1585695021ab33ed76a9bcc", + "test_only": true, + "files": { + "merge_digest_candidates.py": { + "source": "python/scripts/merge_digest_candidates.py", + "sha256": "8feb0fb58ee31d49f3e4e8b0bc602333ae62e9e0262f102aa9c7f7f839a2b812" + }, + "notification_candidate_adapter.py": { + "source": "python/scripts/notification_candidate_adapter.py", + "sha256": "8f0806165f7d99ffd9db7ef41879d9eb7d37e6551678fd0463d29dd7e7bbc19e" + }, + "daily_digest_notify.py": { + "source": "python/scripts/daily_digest_notify.py", + "sha256": "189c3c5d86bf9a153141779ddaba9a28be2615fe80b6646368e407fb91a300cc" + }, + "display_names.py": { + "source": "python/scripts/display_names.py", + "sha256": "20e26298cf48c77431257a4d73d2c9ef5ce0eda2a88323a23852d0119bdacf8f" + }, + "display_names.json": { + "source": "python/scripts/display_names.json", + "sha256": "b453f762fff0186342c4eb2b60e95a24cce56bf988d6cfaeea47afe8b8077b54" + }, + "unified_daily_digest.py": { + "source": "python/scripts/unified_daily_digest.py", + "sha256": "71ba99a8ee21448822bf1d464060e84d6287e8ce7f2595f33b813ce852a45ca5" + }, + "notification_mode.py": { + "source": "python/scripts/notification_mode.py", + "sha256": "4f3a77af9b677095589c69f976ea0ecf633322b2b41c8eb18457afdaec87d2df" + }, + "notification_snapshot/__init__.py": { + "source": "python/scripts/notification_snapshot/__init__.py", + "sha256": "67dfde39f061966cdde08f51663d0e7e413a88abab8a82a9b29825cb6f7dfa10" + }, + "notification_snapshot/unified_contract.py": { + "source": "python/scripts/notification_snapshot/unified_contract.py", + "sha256": "7ba48d1d7c74e66be8cd3ff983c2117002776e82cda6675a084cfe0dc51aecc0" + }, + "notification_snapshot/digest_routing.py": { + "source": "python/scripts/notification_snapshot/digest_routing.py", + "sha256": "60c3f258c4d1a03bc49c7d03b392c02c316541726dfacb0b1d4fae6dbd2e5017" + } + } +}