From 906da68609ec62db858a6da55123bdfb344ffe94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E6=80=9D=E6=87=BF?= Date: Sat, 17 Oct 2026 15:56:07 +0800 Subject: [PATCH] Fix archived notification null fields after real report finalization --- notifications/unified.py | 15 +- tests/archive_finalizer_support.py | 338 ++++ tests/test_notification_archive_finalizer.py | 358 ++++ .../backport_qrs/daily_digest_aggregator.py | 1033 +++++++++++ .../daily_digest_delivery_axes.py | 300 +++ .../backport_qrs/daily_digest_notify.py | 1651 +++++++++++++++++ tests/vendor/backport_qrs/display_names.json | 241 +++ tests/vendor/backport_qrs/display_names.py | 54 + .../backport_qrs/merge_digest_candidates.py | 121 ++ .../notification_approved_gates.py | 133 ++ .../notification_candidate_adapter.py | 257 +++ .../vendor/backport_qrs/notification_mode.py | 18 + .../notification_snapshot/__init__.py | 1 + .../notification_snapshot/digest_routing.py | 92 + .../notification_snapshot/unified_contract.py | 284 +++ .../qsl-digest-candidates.v1.schema.json | 527 ++++++ .../backport_qrs/unified_daily_digest.py | 126 ++ tests/vendor/backport_qrs/upstream.json | 63 + 18 files changed, 5611 insertions(+), 1 deletion(-) create mode 100644 tests/archive_finalizer_support.py create mode 100644 tests/test_notification_archive_finalizer.py create mode 100644 tests/vendor/backport_qrs/daily_digest_aggregator.py create mode 100644 tests/vendor/backport_qrs/daily_digest_delivery_axes.py create mode 100644 tests/vendor/backport_qrs/daily_digest_notify.py create mode 100644 tests/vendor/backport_qrs/display_names.json create mode 100644 tests/vendor/backport_qrs/display_names.py create mode 100644 tests/vendor/backport_qrs/merge_digest_candidates.py create mode 100644 tests/vendor/backport_qrs/notification_approved_gates.py create mode 100644 tests/vendor/backport_qrs/notification_candidate_adapter.py create mode 100644 tests/vendor/backport_qrs/notification_mode.py create mode 100644 tests/vendor/backport_qrs/notification_snapshot/__init__.py create mode 100644 tests/vendor/backport_qrs/notification_snapshot/digest_routing.py create mode 100644 tests/vendor/backport_qrs/notification_snapshot/unified_contract.py create mode 100644 tests/vendor/backport_qrs/qsl-digest-candidates.v1.schema.json create mode 100644 tests/vendor/backport_qrs/unified_daily_digest.py create mode 100644 tests/vendor/backport_qrs/upstream.json diff --git a/notifications/unified.py b/notifications/unified.py index 3cd8515..5fb9b80 100644 --- a/notifications/unified.py +++ b/notifications/unified.py @@ -524,9 +524,22 @@ def _archived_event(report, env): 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") + # The run summary owns counts; a saved event cannot contradict it. + if any(saved.get(name) != getattr(event, name) for name in ("order_count", "fill_count")): + 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: + canonical = event.to_candidate() + normalized = dict(saved) + # QPK 07e24c0 NotificationEvent declares these five payload fields + # nullable. The pinned runtime-report finalizer omits their nulls. + # Normalize only this event envelope, never identity/window fields or + # known counts, and preserve every present value and unexpected key. + for name in ("incident_reason", "order_execution_digest", "expected_block_evidence", + "order_count", "fill_count"): + if name not in normalized and canonical[name] is None: + normalized[name] = None + if canonical != normalized: raise ValueError("notification_archived_event_mismatch") return event, facts diff --git a/tests/archive_finalizer_support.py b/tests/archive_finalizer_support.py new file mode 100644 index 0000000..e2fdaf0 --- /dev/null +++ b/tests/archive_finalizer_support.py @@ -0,0 +1,338 @@ +"""Synthetic boundaries around the real composer, service and archive producer.""" + +import ast +import copy +import importlib +import json +import sys +from pathlib import Path +from types import SimpleNamespace as NS + +import pytest + +from application import rebalance_service as service +from application import runtime_composer as composer_module +from application.runtime_composer import LongBridgeRuntimeComposer +from notifications import unified +from notifications.telegram import build_translator + +ROOT = Path(__file__).resolve().parents[1] +QRS_SHA = "bd3dc79f10b2c0b8048721695885c3f9333a53c6" +VENDOR = ROOT / "tests/vendor/backport_qrs" + + +class MemoryStore: + def __init__(self): + self.backing = {} + + def read(self, key): + return copy.deepcopy(self.backing.get(key, (None, None))) + + def compare_and_set(self, key, version, value): + if self.read(key)[0] != version: + return False + self.backing[key] = (str(int(version or 0) + 1), copy.deepcopy(value)) + return True + + +@pytest.fixture +def bound(monkeypatch): + target = dict( + platform_id="longbridge", + strategy_profile="synthetic_profile", + service_name="synthetic-service", + account_scope="SYNTHETIC", + account_selector=["SYNTHETIC"], + deployment_selector="SYNTHETIC", + dry_run_only=False, + ) + env = { + unified.FLAG: "true", + "RUNTIME_TARGET_ENABLED": "true", + "RUNTIME_TARGET_JSON": json.dumps(target), + "DIGEST_TARGET_ID": "synthetic", + "DIGEST_OPAQUE_ACCOUNT_UID": "synthetic-account", + "DIGEST_STORE_TARGET": "synthetic", + "DIGEST_CANDIDATES_GCS_URI": "gs://synthetic-candidates/notification", + "QSL_NOTIFICATION_MARKER_GCS_URI": "gs://synthetic-markers/notification", + } + for key, value in env.items(): + monkeypatch.setenv(key, value) + stores = {} + monkeypatch.setattr( + unified, + "GCSNotificationStore", + lambda uri: stores.setdefault(uri, MemoryStore()), + ) + return env, target, stores + + +def function_from(source, name, namespace): + node = next( + n + for n in ast.parse(source).body + if isinstance(n, ast.FunctionDef) and n.name == name + ) + scope = dict(namespace) + exec( + compile( + ast.Module(body=[node], type_ignores=[]), "actual-source:" + name, "exec" + ), + scope, + ) + return scope[name] + + +def run_cycle(monkeypatch, case, *, report, authorized=False): + calls = [] + + def record(name, value=None): + calls.append([name, value]) + + def forbidden(*a, **kw): + pytest.fail("unexpected broker/storage boundary") + + execution = {"execution_status": "no_action"} + action = case in {"order", "pending"} + if action: + execution["execution_status"] = "completed" + if case in {"blocked", "unknown", "error"}: + execution.update( + execution_status={ + "blocked": "blocked", + "unknown": "execution_unknown", + "error": "failed", + }[case] + ) + if case == "blocked": + execution["blocked_reason"] = "synthetic_rejection" + if case == "error": + execution["error"] = "synthetic_failure" + plan = {"portfolio": {}, "allocation": {"target_mode": "value"}, "execution": {}} + result = service.ExecutionCycleResult( + plan=plan, + portfolio={}, + allocation=plan["allocation"], + execution=execution, + logs=(), + skip_logs=(), + note_logs=(), + action_done=action, + pending_orders=({"order_id": "synthetic-order", "status": "unknown"},) + if case == "pending" + else (), + ) + + def bootstrap(): + record("bootstrap") + return "synthetic-quote", "synthetic-trade", {} + + def snapshot(): + record("snapshot") + return NS(as_of="2026-10-16", metadata={}) + + def resolve(**kw): + record("plan", sorted(kw)) + return plan + + def execute(**kw): + record( + "execute", + { + key: value + for key, value in kw.items() + if isinstance(value, (bool, int, float, str, type(None))) + }, + ) + return result + + def sender(**kw): + return lambda text: record("send", text) or True + + monkeypatch.setattr(composer_module, "build_cycle_sender", sender) + for name in ( + "build_execution_command_store_from_env", + "build_strategy_risk_state_store_from_env", + ): + monkeypatch.setattr(composer_module, name, lambda **kw: None) + monkeypatch.setattr( + composer_module, + "build_execution_marker_store_from_env", + lambda **kw: NS(cloud_prefix_uri="gs://synthetic-never-contacted/claims"), + ) + composer = LongBridgeRuntimeComposer( + project_id="synthetic-project", + secret_name="synthetic-unused", + token_refresh_threshold_days=30, + account_prefix="SYNTHETIC", + account_region="SYNTHETIC", + strategy_profile="synthetic_profile", + strategy_display_name="Synthetic", + strategy_display_name_localized="Synthetic", + strategy_domain="us_equity", + notify_lang="zh", + tg_token=None, + tg_chat_id=None, + managed_symbols=(), + benchmark_symbol="QQQ", + signal_effective_after_trading_days=1, + separator="-", + limit_sell_discount=0.99, + limit_buy_premium=1.01, + order_poll_interval_sec=1, + order_poll_max_attempts=1, + safe_haven_cash_substitute_threshold_usd=0, + min_order_notional_usd=1, + dry_run_only=False, + broker_adapters=NS( + build_market_data_port=lambda *_: NS(get_quote=forbidden), + build_portfolio_port=lambda *_: NS(get_portfolio_snapshot=snapshot), + build_execution_port=lambda *_: NS(submit_order=forbidden), + ), + strategy_adapters=NS( + resolve_rebalance_plan=resolve, calculate_strategy_indicators=forbidden + ), + estimate_max_purchase_quantity_fn=forbidden, + fetch_order_status_fn=forbidden, + fetch_token_from_secret_fn=forbidden, + refresh_token_if_needed_fn=forbidden, + build_contexts_fn=forbidden, + run_id_builder=lambda: "synthetic-run", + event_logger=forbidden, + report_builder=forbidden, + report_persister=forbidden, + translator=build_translator("zh"), + runtime_target=None, + env_reader=lambda name, default="": default, + sleeper=forbidden, + bootstrap_builder=lambda **kw: bootstrap, + ) + runtime = composer.build_rebalance_runtime() + config = composer.build_rebalance_config( + live_execution_session_authorized=authorized + ) + monkeypatch.setattr(service, "execute_rebalance_cycle", execute) + monkeypatch.setattr(service, "build_signal_snapshot", lambda **kw: {}) + run = service.run_strategy + with unified.bind_report(report): + returned = run(runtime=runtime, config=config) + assert returned is result + summarize = function_from( + (ROOT / "main.py").read_text(), + "_summarize_cycle_result_for_report", + {"unified_notifications": unified}, + ) + summary = summarize(result, dry_run=False) + if report is not None: + unified.attach_cycle_event(summary, result, report=report, config=config) + report["summary"] = summary + return calls, result, summary + + +def export(env, reports, store=None): + store = store or MemoryStore() + by_uri = { + f"gs://synthetic-archive/archive/longbridge/core/2026-10/{i}.json": r + for i, r in enumerate(reports) + } + + def read(args): + return ( + [ + {"url": uri, "metadata": {"updated": "2026-10-16T20:01:00Z"}} + for uri in by_uri + ] + if args[0] == "ls" + else by_uri[args[1]] + ) + + result = unified.emit_archived( + {**env, "EXECUTION_REPORT_GCS_URI": "gs://synthetic-archive/archive"}, + now=unified._stamp("2026-10-17T00:00:00Z"), + read=read, + store=store, + ) + return result, next( + ( + v + for k, (_, v) in store.backing.items() + if not k.startswith("candidate_state/") + ), + None, + ) + + +@pytest.fixture +def qrs(monkeypatch): + names = ( + "merge_digest_candidates", + "notification_candidate_adapter", + "daily_digest_notify", + "display_names", + "notification_mode", + "notification_snapshot", + "unified_daily_digest", + "notification_approved_gates", + "daily_digest_aggregator", + "daily_digest_delivery_axes", + ) + previous = { + n: m + for n, m in sys.modules.items() + if any(n == p or n.startswith(p + ".") for p in names) + } + for name in previous: + del sys.modules[name] + monkeypatch.syspath_prepend(str(VENDOR)) + monkeypatch.setenv("QRS_UNIFIED_NOTIFICATION_ENABLED", "true") + try: + yield NS( + merge=importlib.import_module(names[0]), + intake=importlib.import_module(names[1]), + digest=importlib.import_module(names[2]), + renderer=importlib.import_module("unified_daily_digest"), + aggregator=importlib.import_module("daily_digest_aggregator"), + ) + finally: + for name in list(sys.modules): + if any(name == p or name.startswith(p + ".") for p in names): + del sys.modules[name] + sys.modules.update(previous) + + +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.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, payload["runs"][0]["business_day"]) + assert not stale + rows, summary = qrs.intake.aggregate(current, expected) + text = qrs.renderer.render( + qrs.digest.DailyDigestInput( + business_day=payload["runs"][0]["business_day"], + locale="zh", + runs=qrs.digest.filter_runs_for_digest(rows), + notification_summary=summary, + evidence_coverage=qrs.digest.EvidenceCoverage( + candidates_configured=True, + candidates_loaded=True, + candidates_count=len(rows), + ), + ) + ) + (tmp_path / "receiver.json").write_text( + json.dumps( + {"qrs_sha": QRS_SHA, "summary": summary, "rows": rows}, + default=str, + indent=2, + ) + ) + return rows, summary, text, stats diff --git a/tests/test_notification_archive_finalizer.py b/tests/test_notification_archive_finalizer.py new file mode 100644 index 0000000..f18f2c9 --- /dev/null +++ b/tests/test_notification_archive_finalizer.py @@ -0,0 +1,358 @@ +"""LB-01: real reporting/finalization, never a hand-written archive envelope.""" + +import copy +import hashlib +import importlib.metadata +import json +import tomllib +from datetime import datetime, timezone + +import pytest + +import archive_finalizer_support as support +from application.runtime_reporting_adapters import build_runtime_reporting_adapters +from notifications import unified +from quant_platform_kit.common import runtime_reports +from quant_platform_kit.common.runtime_target import resolve_runtime_target_from_env + + +bound = support.bound +qrs = support.qrs + + +@pytest.fixture +def finalized_cycle(monkeypatch, bound, tmp_path): + env, _, _ = bound + actual_composer = support.LongBridgeRuntimeComposer + + def run(case="healthy", run_id="synthetic-business-run", status="ok"): + report = {} + + def compose(**kwargs): + kwargs.update( + runtime_target=resolve_runtime_target_from_env( + env=env, expected_platform_id="longbridge" + ), + report_builder=runtime_reports.build_runtime_report_base, + run_id_builder=lambda: run_id, + env_reader=lambda name, default="": ( + "synthetic-service" if name == "K_SERVICE" else default + ), + reporting_adapter_builder=lambda **args: ( + build_runtime_reporting_adapters( + clock=lambda: datetime(2026, 10, 16, 20, tzinfo=timezone.utc), + **args, + ) + ), + ) + composer = actual_composer(**kwargs) + _, started = composer.build_reporting_adapters().start_run() + report.update(started) + return composer + + monkeypatch.setattr(support, "LongBridgeRuntimeComposer", compose) + calls, result, summary = support.run_cycle(monkeypatch, case, report=report) + before = copy.deepcopy(summary) + runtime_reports.finalize_runtime_report( + report, + status=status, + finished_at=datetime(2026, 10, 16, 20, 1, tzinfo=timezone.utc), + summary=summary, + ) + # Preserve the actual normalized bytes that an archive reader sees. + serialized = json.dumps(report, sort_keys=True) + saved = json.loads(serialized) + (tmp_path / (run_id + "-archive.json")).write_text(serialized) + (tmp_path / (run_id + "-raw-summary.json")).write_text(json.dumps(before)) + (tmp_path / (run_id + "-calls.json")).write_text(json.dumps(calls)) + return saved, before, calls, result + + return run + + +def receive_final(qrs, tmp_path, env, payload): + rows, summary, text, stats = support.receive( + qrs, tmp_path, env, payload, duplicate=True + ) + expected = [unified.target_binding(env)[0]] + config = tmp_path / "config.json" + config.write_text( + json.dumps( + { + "notifications": { + "quant_sentinel": { + "daily_digest": {"aggregator": {"expected_targets": expected}} + } + } + } + ) + ) + merged, _ = qrs.merge.merge(None, tmp_path / "candidates") + merged_path = tmp_path / "merged.json" + merged_path.write_text(json.dumps(merged)) + full, full_text, collected = qrs.aggregator.build_digest_payload( + business_day=payload["runs"][0]["business_day"], + candidates_path=merged_path, + config_path=config, + include_github=False, + ) + receipt = qrs.aggregator.build_receipt( + full, collected, message_chars=len(full_text) + ) + assert receipt["notification_summary"] == summary + assert receipt["notification_summary"]["source_inventory"]["longbridge"][ + "runs" + ] == len(rows) + (tmp_path / "final-qrs-receipt.json").write_text(json.dumps(receipt, default=str)) + assert stats["files"] == 2 + return rows, summary, text + + +def test_real_finalizer_healthy_two_runs(finalized_cycle, bound, qrs, tmp_path): + env, _, _ = bound + reports = [] + for run_id in ("synthetic-a", "synthetic-b"): + report, raw, calls, _ = finalized_cycle(run_id=run_id) + raw_event = raw["notification_digest"]["event"] + event = report["summary"]["notification_digest"]["event"] + removed = set(raw_event) - set(event) + assert removed == { + "incident_reason", + "order_execution_digest", + "expected_block_evidence", + } + assert all(raw_event[key] is None for key in removed) + assert not [c for c in calls if c[0] == "send"] + reports.append(report) + _, payload = support.export(env, reports) + rows, summary, text = receive_final(qrs, tmp_path, env, payload) + assert len(rows) == 2 and summary["event_count"] == 2 + assert not summary["counts_unknown"] + assert summary["source_inventory"]["longbridge"]["covered_targets"] == 1 + assert all(row["order_count"] == row["fill_count"] == 0 for row in rows) + assert "窗口内各轮均为 0 单" in text + + +@pytest.mark.parametrize( + "case", ["healthy", "order", "pending", "blocked", "unknown", "error"] +) +def test_real_finalizer_terminal_cases(finalized_cycle, bound, qrs, tmp_path, case): + env, _, _ = bound + report, raw, calls, _ = finalized_cycle(case) + event = report["summary"]["notification_digest"]["event"] + assert "expected_block_evidence" not in event + _, payload = support.export(env, [report, report]) + rows, summary, text = receive_final(qrs, tmp_path, env, payload) + assert len(rows) == 1 and summary["event_count"] == 1 + assert rows[0]["source_run_id"] == report["run_id"] + assert summary["source_inventory"]["longbridge"]["covered_targets"] == 1 + for name in ("order_count", "fill_count"): + expected = raw["notification_digest"][name] + assert rows[0][name] == expected + if case != "healthy": + assert expected is None and name not in event + if case == "healthy": + assert not summary["counts_unknown"] and "本轮 0 单" in text + else: + assert summary["counts_unknown"] and "未知" in text + assert len([c for c in calls if c[0] == "send"]) == 1 + + +def test_real_policy_zero_run_window(bound, qrs, tmp_path): + env, target, _ = bound + store = support.MemoryStore() + # Sunday has no due weekday invocation; use the original policy, no mock. + configured = { + **env, + "EXECUTION_REPORT_GCS_URI": "gs://synthetic-archive/archive", + "DIGEST_NOTIFICATION_SCHEDULE_JSON": json.dumps( + { + **target, + "scheduler": { + "main_time": "0 10 * * 1-5", + "timezone": "Asia/Singapore", + }, + } + ), + } + result = unified.emit_archived( + configured, + now=datetime(2026, 10, 19, tzinfo=timezone.utc), + read=lambda args: [], + store=store, + ) + payload = next( + v for k, (_, v) in store.backing.items() if not k.startswith("candidate_state/") + ) + assert result["runs"] == 0 + rows, summary, text = receive_final(qrs, tmp_path, env, payload) + assert rows == [] and summary["event_count"] == 0 + assert not summary["counts_unknown"] + assert "本轮 0 单" not in text + + +@pytest.mark.parametrize( + "field,value", + [ + ("opaque_account_uid", "other-account"), + ("target_id", "other-target"), + ("source_run_id", "other-run"), + ("occurred_at", "2026-10-16T20:01:00Z"), + ("business_day", "2026-10-15"), + ("window_id", "different-window"), + ("schema_version", "different-schema"), + ("coverage_status", "unknown"), + ("expected_block_evidence", "unexpected-nonempty-evidence"), + ("order_execution_digest", "a" * 64), + ("order_count", 1), + ("fill_count", 1), + ("order_count", False), + ("unexpected_field", None), + ], +) +def test_archive_rejects_nonempty_or_identity_and_count_changes( + finalized_cycle, bound, field, value +): + env, _, _ = bound + report, _, _, _ = finalized_cycle() + report["summary"]["notification_digest"]["event"][field] = value + with pytest.raises(ValueError): + support.export(env, [report]) + + +@pytest.mark.parametrize( + "field", + [ + "platform_id", + "opaque_account_uid", + "target_id", + "strategy_profile", + "source_run_id", + "occurred_at", + "schema_version", + "event_kind", + "dedupe_key", + "business_day", + "report_date", + "window_id", + "coverage_status", + "order_count", + "fill_count", + ], +) +def test_archive_rejects_required_or_known_count_omissions( + finalized_cycle, bound, field +): + env, _, _ = bound + report, _, _, _ = finalized_cycle() + del report["summary"]["notification_digest"]["event"][field] + with pytest.raises((ValueError, KeyError)): + support.export(env, [report]) + + +def test_archive_rejects_cross_target_report(finalized_cycle, bound): + env, _, _ = bound + report, _, _, _ = finalized_cycle() + report["runtime_target"]["account_scope"] = "OTHER" + with pytest.raises(ValueError, match="source_binding_mismatch"): + unified._archived_event(report, env) + + +def test_finalized_alert_bool_ack_remains_uncertain_and_not_resent( + finalized_cycle, bound +): + _, _, stores = bound + for attempt in range(2): + report, _raw, calls, _result = finalized_cycle("unknown") + if attempt == 0: + assert len([c for c in calls if c[0] == "send"]) == 1 + else: + assert not [c for c in calls if c[0] == "send"] + markers = [ + value + for store in stores.values() + for _, value in store.backing.values() + if "delivery_status" in value and "transition_key" in value + ] + assert markers and all( + m["delivery_status"] == "uncertain" and m["receipt_id"] is None for m in markers + ) + assert report["summary"]["notification_digest"].get("order_count") is None + + +def test_finalizer_and_receiver_use_exact_recorded_sources(tmp_path): + lock = tomllib.loads((support.ROOT / "uv.lock").read_text()) + expected = next( + p["source"]["git"].rsplit("#", 1)[1] + for p in lock["package"] + if p["name"] == "quant-platform-kit" + ) + installed = json.loads( + importlib.metadata.distribution("quant-platform-kit").read_text( + "direct_url.json" + ) + ) + assert installed["vcs_info"]["commit_id"] == expected + path = support.ROOT / "tests/vendor/backport_qrs/upstream.json" + manifest = json.loads(path.read_text()) + assert manifest["commit"] == support.QRS_SHA + for name, source in manifest["files"].items(): + assert ( + hashlib.sha256((path.parent / name).read_bytes()).hexdigest() + == source["sha256"] + ) + (tmp_path / "finalizer-source.json").write_text( + json.dumps( + { + "qpk_commit": expected, + "module": runtime_reports.__file__, + "module_sha256": hashlib.sha256( + __import__("pathlib").Path(runtime_reports.__file__).read_bytes() + ).hexdigest(), + "qrs_commit": support.QRS_SHA, + } + ) + ) + + +def test_finalized_report_heartbeat_keeps_business_run( + finalized_cycle, bound, qrs, tmp_path +): + env, _, stores = bound + report, _, _, _ = finalized_cycle() + for _ in range(2): + unified.heartbeat_observation( + now=datetime(2026, 10, 17, 0, 20, tzinfo=timezone.utc), + report=report, + recovery_at=report["started_at"], + ) + payload = next( + v + for k, (_, v) in stores[env["DIGEST_CANDIDATES_GCS_URI"]].backing.items() + if not k.startswith("candidate_state/") + ) + rows, summary, _ = receive_final(qrs, tmp_path, env, payload) + assert len(rows) == 1 and summary["event_count"] == 2 + assert rows[0]["source_run_id"] == report["run_id"] + assert {f["event_kind"] for f in rows[0]["notification_events"]} == { + "routine_cycle", + "healthy_heartbeat", + } + + +def test_failed_finalization_retains_nonempty_incident( + finalized_cycle, bound, qrs, tmp_path +): + env, _, _ = bound + report, raw, calls, _ = finalized_cycle("error", status="error") + reason = raw["notification_digest"]["event"]["incident_reason"] + assert ( + reason + and report["summary"]["notification_digest"]["event"]["incident_reason"] + == reason + ) + _, payload = support.export(env, [report]) + rows, summary, _ = receive_final(qrs, tmp_path, env, payload) + assert rows[0]["notification_events"][0]["incident_reason"] == reason + assert rows[0]["order_count"] is None and summary["immediate_events"] == 1 + assert len([c for c in calls if c[0] == "send"]) == 1 diff --git a/tests/vendor/backport_qrs/daily_digest_aggregator.py b/tests/vendor/backport_qrs/daily_digest_aggregator.py new file mode 100644 index 0000000..8db52ac --- /dev/null +++ b/tests/vendor/backport_qrs/daily_digest_aggregator.py @@ -0,0 +1,1033 @@ +#!/usr/bin/env python3 +"""Cross-platform daily digest aggregator (evidence in → render out). + +Collects only platforms/strategies that actually ran from: + 1) candidates JSON file / DIGEST_CANDIDATES_JSON (preferred; producer-authored) + 2) optional GitHub Actions workflow conclusions (allowlisted; existence only — + fill/order counts stay unknown unless a candidate row supplies them) + +Does not trade, does not embed tokens, does not invent missing platforms. +Aggregation keys include account/target identity. Source read failures are +recorded in coverage/receipt and must not render as “link healthy / no runs”. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import sys +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import asdict, dataclass, field +from datetime import date, datetime, timedelta, timezone +from pathlib import Path +from typing import Any, Mapping, Sequence +from zoneinfo import ZoneInfo + +ROOT = Path(__file__).resolve().parents[2] +SCRIPTS = Path(__file__).resolve().parent +if str(SCRIPTS) not in sys.path: + sys.path.insert(0, str(SCRIPTS)) + +import daily_digest_notify as digest # noqa: E402 +import daily_digest_delivery_axes as delivery_axes # noqa: E402 +import merge_digest_candidates as candidate_contract # noqa: E402 +from notification_mode import consolidation_enabled, routine_mode # noqa: E402 + +DEFAULT_PLATFORM_CONFIG = ROOT / "platform-config.json" +NY_TZ = ZoneInfo("America/New_York") +SHANGHAI_TZ = ZoneInfo("Asia/Shanghai") + +_OBSERVATION_FIELDS = ( + "strategy_label", + "equity", + "equity_usd", + "equity_currency", + "currency", + "holdings", + "positions", + "signal_summary", + "rebalance_kind", + "rebalance_conclusion", + "tips", + "tip", + "account_hint", + "account_scope", + "execution_mode", +) + + +def _has_holdings(row: Mapping[str, Any]) -> bool: + return row.get("holdings") not in (None, "", [], ()) or row.get( + "positions" + ) not in (None, "", [], ()) + + +@dataclass +class GithubEvidenceResult: + rows: list[dict[str, Any]] = field(default_factory=list) + failures: list[dict[str, Any]] = field(default_factory=list) + sources_queried: int = 0 + sources_matched: int = 0 + + +@dataclass +class CollectResult: + runs: list[digest.DigestRunEntry] + coverage: digest.EvidenceCoverage + merged_rows: list[dict[str, Any]] = field(default_factory=list) + notification_summary: dict[str, Any] | None = None + + +def resolve_business_day( + *, + explicit: str | None = None, + now: datetime | None = None, +) -> str: + """US equity session date for post-close digest (America/New_York calendar day).""" + + if explicit and str(explicit).strip(): + value = str(explicit).strip() + date.fromisoformat(value) + return value + stamp = now or datetime.now(tz=timezone.utc) + if stamp.tzinfo is None: + stamp = stamp.replace(tzinfo=timezone.utc) + if consolidation_enabled(): + local = stamp.astimezone(ZoneInfo("Asia/Singapore")) + end_day = local.date() - timedelta(days=local.hour < DIGEST_CUTOFF_HOUR_SHANGHAI) + return (end_day - timedelta(days=1)).isoformat() + return stamp.astimezone(NY_TZ).date().isoformat() + + +DIGEST_CUTOFF_HOUR_SHANGHAI = 6 + + +def digest_window_for(business_day: str) -> dict[str, str]: + """NOTIFY-04 B1: Shanghai cut-off window for a New York business day. + + The report for NY session ``B`` is dated ``B+1`` in Shanghai and covers + ``[B 06:00, B+1 06:00)`` Asia/Shanghai — i.e. ``[B-1 22:00, B 22:00)`` UTC. + The US close (16:00 NY = 04:00/05:00 Shanghai) of ``B`` falls inside it, and + a 24/7 crypto runtime contributes the cycles started inside the window. + """ + + day = date.fromisoformat(business_day) + start_local = datetime( + day.year, day.month, day.day, DIGEST_CUTOFF_HOUR_SHANGHAI, tzinfo=SHANGHAI_TZ + ) + end_local = start_local + timedelta(days=1) + window = { + "timezone": "Asia/Shanghai", + "business_day": day.isoformat(), + "report_date": end_local.date().isoformat(), + "start_local": start_local.strftime("%Y-%m-%d %H:%M"), + "end_local": end_local.strftime("%Y-%m-%d %H:%M"), + "start_utc": start_local.astimezone(timezone.utc).isoformat().replace("+00:00", "Z"), + "end_utc": end_local.astimezone(timezone.utc).isoformat().replace("+00:00", "Z"), + } + if consolidation_enabled(): + window.update(timezone="Asia/Singapore", window_id=f"sgt-0600:{business_day}") + return window + + +def resolve_layout(raw: str | None = None) -> str: + return digest.normalize_layout(raw or os.environ.get("DIGEST_LAYOUT") or "v1") + + +def section_for_profile(config: Mapping[str, Any], strategy_profile: str) -> str: + """Infer the digest section from platform-config (explicit row value wins).""" + + meta = (config.get("strategies") or {}).get(strategy_profile) or {} + if not isinstance(meta, Mapping): + return "us_daily" + if str(meta.get("domain") or "") == "crypto": + return "dca" if "dca" in strategy_profile else "crypto_intraday" + if str(meta.get("scheduler_profile") or "") == "us_dca_month_end": + return "dca" + return "us_daily" + + +def resolve_locale(raw: str | None = None) -> digest.DigestLocale: + return digest.normalize_locale( + raw + or os.environ.get("QSL_NOTIFY_LANG") + or os.environ.get("NOTIFY_LANG") + or "zh" + ) + + +def load_platform_config(path: Path | None = None) -> dict[str, Any]: + config_path = path or Path( + os.environ.get("QSL_PLATFORM_CONFIG") or DEFAULT_PLATFORM_CONFIG + ) + return json.loads(config_path.read_text(encoding="utf-8")) + + +def digest_contract(config: Mapping[str, Any]) -> dict[str, Any]: + return dict(config["notifications"]["quant_sentinel"]["daily_digest"]) + + +def window_label_for(contract: Mapping[str, Any]) -> str: + schedule = contract.get("schedule") or {} + tz = str(schedule.get("timezone") or "Asia/Shanghai") + cron = str(schedule.get("cron") or "0 6 * * 2-6") + return f"{tz} cron `{cron}`" + + +def _as_candidate_rows(raw: Any) -> list[dict[str, Any]]: + out: list[dict[str, Any]] = [] + for item in candidate_contract._rows(raw): + row = dict(item) + if not str(row.get("evidence_provenance") or "").strip(): + row["evidence_provenance"] = "candidates" + out.append(row) + return out + + +def load_candidates_from_file(path: Path | None) -> tuple[list[dict[str, Any]], bool, bool]: + """Return (rows, configured, loaded). + + configured: env/path asked for candidates. + loaded: file/JSON was successfully parsed (may be empty list). + """ + + configured = False + if path is None: + env_path = (os.environ.get("DIGEST_CANDIDATES_PATH") or "").strip() + path = Path(env_path) if env_path else None + if path is not None: + configured = True + if not path.is_file(): + raise FileNotFoundError(f"candidates file not found: {path}") + return _as_candidate_rows(json.loads(path.read_text(encoding="utf-8"))), True, True + inline = (os.environ.get("DIGEST_CANDIDATES_JSON") or "").strip() + if inline: + configured = True + return _as_candidate_rows(json.loads(inline)), True, True + return [], False, False + + +CANDIDATE_STORE_FAILURE_REASONS = frozenset({ + "candidate_store_auth_failed", "candidate_store_list_failed", "candidate_store_copy_failed", + "candidate_store_merge_failed", "candidate_store_outcome_missing", "candidate_store_outcome_invalid", + "candidate_input_unavailable_or_invalid", +}) + + +def candidate_store_failures() -> list[dict[str, str]]: + """Read the workflow's explicit private-store outcome; never log its URI.""" + configured = bool((os.environ.get("DIGEST_CANDIDATES_GCS_URI") or "").strip()) + state = (os.environ.get("DIGEST_CANDIDATES_STORE_STATUS") or "").strip() + if state == "loaded" or (not configured and state in {"", "disabled"}): + return [] + reason = (os.environ.get("DIGEST_CANDIDATES_STORE_REASON") or "").strip() + if state != "failed" or reason not in CANDIDATE_STORE_FAILURE_REASONS: + reason = "candidate_store_outcome_missing" if not state else "candidate_store_outcome_invalid" + return [{"source_kind": "candidate_store", "reason_code": reason}] + + +def _gh_api_get(url: str, token: str) -> Any: + request = urllib.request.Request( + url, + headers={ + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {token}", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "QuantRuntimeSettings-daily-digest-aggregator", + }, + method="GET", + ) + try: + with urllib.request.urlopen(request, timeout=45) as response: + return json.loads(response.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + body = exc.read().decode("utf-8", errors="replace")[:300] + raise RuntimeError(f"GitHub API HTTP {exc.code} for {url}: {body}") from exc + + +def business_day_utc_window(business_day: str) -> tuple[datetime, datetime]: + """Inclusive NY business-day window expressed in UTC.""" + + day = date.fromisoformat(business_day) + start_ny = datetime(day.year, day.month, day.day, 0, 0, 0, tzinfo=NY_TZ) + end_ny = start_ny + timedelta(days=1) + return start_ny.astimezone(timezone.utc), end_ny.astimezone(timezone.utc) + + +def default_workflow_allowlist(config: Mapping[str, Any]) -> list[dict[str, str]]: + """Allowlisted workflows that may contribute *run existence* evidence only. + + Counts remain unknown unless a candidates file supplies them. Heartbeat-only + monitor workflows are intentionally omitted. + """ + + platforms = config.get("platforms") or {} + rows: list[dict[str, str]] = [] + # Fallback only when platform-config allowlist is absent. + # Keep in sync with calibrated present paths (P0-04); never list 404 stems. + defaults: dict[str, tuple[str, str, str]] = { + # workflow → strategy_profile → evidence_role + "longbridge": ( + "publish-runtime-daily-once.yml", + "russell_top50_leader_rotation", + "paper_projection_once", + ), + "schwab": ( + "runtime-daily-sync.yml", + "soxl_soxx_trend_income", + "manual_daily_projection", + ), + } + configured = ( + (config.get("notifications") or {}) + .get("quant_sentinel", {}) + .get("daily_digest", {}) + .get("aggregator", {}) + .get("github_workflow_allowlist") + ) + if isinstance(configured, list) and configured: + for item in configured: + if not isinstance(item, Mapping): + continue + platform_id = str(item.get("platform_id") or "").strip() + workflow = str(item.get("workflow") or "").strip() + strategy = str(item.get("strategy_profile") or "unspecified").strip() + repo = str(item.get("repository") or "").strip() + if not platform_id or not workflow: + continue + if not repo and platform_id in platforms: + repo = str(platforms[platform_id].get("repository") or "").strip() + if not repo: + continue + row = { + "platform_id": platform_id, + "repository": repo, + "workflow": workflow, + "strategy_profile": strategy or "unspecified", + } + evidence_role = str(item.get("evidence_role") or "").strip() + if evidence_role: + row["evidence_role"] = evidence_role + path_status = str(item.get("path_status") or "").strip() + if path_status: + row["path_status"] = path_status + rows.append(row) + return rows + + for platform_id, (workflow, strategy, evidence_role) in defaults.items(): + meta = platforms.get(platform_id) or {} + repo = str(meta.get("repository") or "").strip() + if not repo: + continue + rows.append( + { + "platform_id": platform_id, + "repository": repo, + "workflow": workflow, + "strategy_profile": strategy, + "evidence_role": evidence_role, + "path_status": "present", + } + ) + return rows + + +def collect_from_github_workflows( + *, + business_day: str, + config: Mapping[str, Any], + token: str | None = None, + allowlist: Sequence[Mapping[str, str]] | None = None, +) -> GithubEvidenceResult: + """Include a platform only when an allowlisted workflow succeeded that day. + + Never invents fill/order counts (null + counts_unknown). API errors are + recorded as source failures — callers must not treat empty rows alone as + verified idle / link-healthy. + """ + + result = GithubEvidenceResult() + gh_token = ( + token or os.environ.get("DIGEST_GH_TOKEN") or os.environ.get("GH_TOKEN") or "" + ).strip() + entries = list(allowlist or default_workflow_allowlist(config)) + if not entries: + return result + if not gh_token: + result.failures.append( + { + "platform_id": "*", + "workflow": "*", + "repository": "*", + "reason_code": "github_token_missing", + "message": "DIGEST_GH_TOKEN/GH_TOKEN absent; github evidence not queried", + } + ) + return result + + start_utc, end_utc = business_day_utc_window(business_day) + seen: set[tuple[str, str, str, str]] = set() + for entry in entries: + platform_id = entry["platform_id"] + repository = entry["repository"] + workflow = entry["workflow"] + strategy = entry["strategy_profile"] + evidence_role = str(entry.get("evidence_role") or "").strip() + owner_repo = repository.split("/", 1) + if len(owner_repo) != 2: + result.failures.append( + { + "platform_id": platform_id, + "workflow": workflow, + "repository": repository, + "reason_code": "invalid_repository", + "message": f"repository must be owner/repo, got {repository!r}", + } + ) + continue + owner, repo = owner_repo + result.sources_queried += 1 + matched = False + failure_reason = "github_api_error" + try: + # GitHub accepts one event value, not a comma-separated OR filter. + # Exhaust both event queries/pages before declaring no match. + for event in ("workflow_dispatch", "schedule"): + page = 1 + seen_run_count = 0 + while True: + query = urllib.parse.urlencode({ + "event": event, + "status": "completed", + "per_page": "100", + "page": str(page), + "created": f">={start_utc.date().isoformat()}", + }) + url = ( + f"https://api.github.com/repos/{owner}/{repo}/actions/workflows/" + f"{urllib.parse.quote(workflow)}/runs?{query}" + ) + payload = _gh_api_get(url, gh_token) + runs = payload.get("workflow_runs") if isinstance(payload, Mapping) else None + if not isinstance(runs, list): + failure_reason = "github_payload_invalid" + raise ValueError("workflow_runs missing or not a list") + for run in runs: + if not isinstance(run, Mapping): + continue + if str(run.get("conclusion") or "") != "success": + continue + updated = str(run.get("updated_at") or run.get("created_at") or "") + try: + stamp = datetime.fromisoformat(updated.replace("Z", "+00:00")) + if stamp.tzinfo is None: + raise ValueError("workflow timestamp lacks timezone") + except ValueError: + continue + if start_utc <= stamp < end_utc: + matched = True + break + seen_run_count += len(runs) + count = payload.get("total_count") + if matched: + break + if type(count) is not int or count < seen_run_count: + failure_reason = "github_payload_invalid" + raise ValueError("invalid GitHub total_count for received rows") + if count > seen_run_count and len(runs) < 100: + failure_reason = "github_pagination_incomplete" + raise ValueError("short GitHub page contradicts total_count") + if seen_run_count == count: + break + if page >= 10: + failure_reason = "github_pagination_incomplete" + raise ValueError("GitHub workflow result limit reached without complete evidence") + page += 1 + if matched: + break + except (RuntimeError, ValueError, urllib.error.URLError, TimeoutError) as exc: + failure = { + "platform_id": platform_id, + "workflow": workflow, + "repository": repository, + "reason_code": failure_reason, + "message": str(exc)[:300], + } + result.failures.append(failure) + print( + f"digest evidence failure {platform_id}/{workflow}: {exc}", + file=sys.stderr, + ) + continue + if not matched: + continue + stub = { + "platform_id": platform_id, + "strategy_profile": strategy, + "opaque_account_uid": "", + "target_id": "", + "actually_ran": True, + # Existence only — do not assert verified zero fills/orders. + "fill_count": None, + "order_count": None, + "cycle_count": 1, + "status": "ok", + "note": ( + f"github_workflow:{workflow}" + + (f";evidence_role:{evidence_role}" if evidence_role else "") + ), + "field_status": { + "fill_count": "counts_unknown", + "order_count": "counts_unknown", + "cycle_count": "known", + }, + "reason_code": "github_workflow_existence_only", + "evidence_provenance": "github_workflow_stub", + } + key = digest.identity_key(stub) + if key in seen: + continue + seen.add(key) + result.sources_matched += 1 + result.rows.append(stub) + return result + + +def _merge_count_field( + winner: dict[str, Any], + loser: dict[str, Any], + field_name: str, +) -> None: + """Fill known counts into unknown winner slots; never invent 0 from stub.""" + + w_value, w_status = digest.resolve_count_field( + winner, field_name, legacy_default_zero=False + ) + l_value, l_status = digest.resolve_count_field( + loser, field_name, legacy_default_zero=False + ) + if w_status == "known" and w_value is not None: + return + if l_status == "known" and l_value is not None: + winner[field_name] = l_value + status_map = dict(winner.get("field_status") or {}) + status_map[field_name] = "known" + winner["field_status"] = status_map + # Drop existence-only reason if we now have a known producer count. + if str(winner.get("reason_code") or "") == "github_workflow_existence_only": + if field_name in {"fill_count", "order_count"}: + winner["reason_code"] = str(loser.get("reason_code") or "") or ( + "merged_known_counts" + ) + + +def _should_cross_fill_observation(winner: Mapping[str, Any], loser: Mapping[str, Any]) -> bool: + """Only cross-fill observation fields when both sides share a real identity.""" + + if digest.identity_is_unknown(winner) or digest.identity_is_unknown(loser): + return False + return digest.identity_key(winner) == digest.identity_key(loser) + + +def merge_candidates(*groups: Sequence[Mapping[str, Any]]) -> list[dict[str, Any]]: + """Merge evidence by identity key; do not mix different accounts/targets. + + Same identity: dedupe, prefer richer known evidence. Unknown-identity rows + do not cross-fill observation fields onto each other. Different identities + never merge. + """ + + merged: dict[tuple[str, str, str, str], dict[str, Any]] = {} + unknown_rows: list[dict[str, Any]] = [] + + for group in groups: + for raw in group: + platform_id = str(raw.get("platform_id") or "").strip() + strategy = str(raw.get("strategy_profile") or "").strip() + if not platform_id or not strategy: + continue + candidate = dict(raw) + key = digest.identity_key(candidate) + + if digest.identity_is_unknown(candidate): + # Keep unknown-identity rows separate unless exact duplicate key + # already present with identical provenance note (dedupe only). + existing_unknown = None + for idx, row in enumerate(unknown_rows): + if digest.identity_key(row) == key and str(row.get("note") or "") == str( + candidate.get("note") or "" + ) and str(row.get("evidence_provenance") or "") == str( + candidate.get("evidence_provenance") or "" + ): + existing_unknown = (idx, row) + break + if existing_unknown is None: + unknown_rows.append(candidate) + continue + idx, existing = existing_unknown + if digest.observation_score(candidate) >= digest.observation_score(existing): + # Replace whole row; do not patch fields across unknowns. + unknown_rows[idx] = candidate + continue + + existing = merged.get(key) + if existing is None: + merged[key] = candidate + continue + if digest.observation_score(candidate) >= digest.observation_score(existing): + winner = dict(candidate) + loser = existing + else: + winner = dict(existing) + loser = candidate + if _should_cross_fill_observation(winner, loser): + winner_had_holdings = _has_holdings(winner) + for field_name in _OBSERVATION_FIELDS: + if winner.get(field_name) in (None, "", [], ()): + if loser.get(field_name) not in (None, "", [], ()): + winner[field_name] = loser[field_name] + # holdings_scope labels one holdings list; keep it paired with + # whichever side supplied the holdings that were kept. + if not winner_had_holdings and _has_holdings(winner): + if loser.get("holdings_scope") not in (None, ""): + winner["holdings_scope"] = loser["holdings_scope"] + else: + winner.pop("holdings_scope", None) + for count_field in ("fill_count", "order_count", "cycle_count"): + _merge_count_field(winner, loser, count_field) + # Preserve identity fields from either side if winner omitted. + for id_field in ("opaque_account_uid", "target_id"): + if not str(winner.get(id_field) or "").strip(): + if str(loser.get(id_field) or "").strip(): + winner[id_field] = loser[id_field] + merged[key] = winner + + return list(merged.values()) + unknown_rows + + +def strategy_label_from_config( + config: Mapping[str, Any], + strategy_profile: str, + *, + locale: str = "zh", +) -> str: + """Public display label from platform-config (not invented numbers).""" + + strategies = config.get("strategies") or {} + meta = strategies.get(strategy_profile) or {} + if not isinstance(meta, Mapping): + return "" + if str(locale).lower().startswith("en"): + return str(meta.get("label_en") or meta.get("label") or "").strip() + return str(meta.get("label") or meta.get("label_en") or "").strip() + + +def enrich_candidate_labels( + rows: Sequence[Mapping[str, Any]], + config: Mapping[str, Any], + *, + locale: str = "zh", +) -> list[dict[str, Any]]: + out: list[dict[str, Any]] = [] + for raw in rows: + row = dict(raw) + if not str(row.get("section") or "").strip(): + row["section"] = section_for_profile( + config, str(row.get("strategy_profile") or "") + ) + if str(row.get("strategy_profile") or "").strip(): + label = strategy_label_from_config( + config, str(row.get("strategy_profile") or ""), locale=locale + ) + if label: + row["strategy_label"] = label + out.append(row) + return out + + +CANDIDATE_STALE_REASON_OLDER = "candidate_business_day_stale" +CANDIDATE_STALE_REASON_FUTURE = "candidate_business_day_future" +CANDIDATE_STALE_REASON_UNDATED = "candidate_business_day_missing" +CANDIDATE_STALE_REASON_INVALID = "candidate_business_day_invalid" + + +def _target_key(row: Mapping[str, Any]) -> str: + """Match the target, then account fallback; never guess across anonymous rows.""" + platform = str(row.get("platform_id") or "").strip() + target = str(row.get("target_id") or "").strip() + account = str(row.get("opaque_account_uid") or "").strip() + if not platform or not (target or account): + return "" + identity = [platform, "target" if target else "account", target or account] + return hashlib.sha256(json.dumps(identity).encode()).hexdigest() + + +def _is_legacy(row: Mapping[str, Any]) -> bool: + return row.get("legacy") is True or row.get("source_legacy") is True or row.get("source_kind") == "legacy" + + +def candidate_business_day(row: Mapping[str, Any]) -> tuple[str, str]: + """Return ``(iso_day, status)`` for one producer candidate row. + + Sources, in order: ``business_day`` (ISO date, first 10 chars) then + ``as_of`` (ISO datetime → America/New_York calendar day; naive = UTC). + ``status`` is ``ok`` / ``missing`` / ``invalid``. + """ + + raw_day = str(row.get("business_day") or "").strip() + if raw_day: + try: + return date.fromisoformat(raw_day[:10]).isoformat(), "ok" + except ValueError: + return "", "invalid" + raw_as_of = str(row.get("as_of") or "").strip() + if raw_as_of: + try: + stamp = datetime.fromisoformat(raw_as_of.replace("Z", "+00:00")) + except ValueError: + return "", "invalid" + if stamp.tzinfo is None: + stamp = stamp.replace(tzinfo=timezone.utc) + return stamp.astimezone(NY_TZ).date().isoformat(), "ok" + return "", "missing" + + +def partition_candidates_by_business_day( + rows: Sequence[Mapping[str, Any]], + business_day: str, + *, + config: Mapping[str, Any] | None = None, + locale: str = "zh", + stats: dict[str, int] | None = None, +) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + """NOTIFY-04: keep only candidates whose business day equals the digest day. + + Producer candidates are injected out-of-band (Environment secret / file), so + a row that was valid yesterday must never be rendered as today's evidence. + Rows with a different, missing, or unparseable business day are dropped + (fail closed). Returns ``(fresh_rows, stale_notes)``; stale notes are + private receipt details (no amounts) for the digest note. The public receipt + projects counts only, never target fingerprints or display hints. + """ + + target = date.fromisoformat(business_day).isoformat() + fresh: list[dict[str, Any]] = [] + stale: list[dict[str, Any]] = [] + current_targets = { + _target_key(row) for row in rows + if not _is_legacy(row) and candidate_business_day(row) == (target, "ok") and _target_key(row) + } + suppressed = 0 + for raw in rows: + row = dict(raw) + day, status = candidate_business_day(row) + key = _target_key(row) + if _is_legacy(row) or (status == "ok" and day < target and key and key in current_targets): + suppressed += 1 + continue + if status == "ok" and day == target: + fresh.append(row) + continue + if status == "missing": + reason = CANDIDATE_STALE_REASON_UNDATED + elif status == "invalid": + reason = CANDIDATE_STALE_REASON_INVALID + elif day < target: + reason = CANDIDATE_STALE_REASON_OLDER + else: + reason = CANDIDATE_STALE_REASON_FUTURE + profile = str(row.get("strategy_profile") or "").strip() + label = str(row.get("strategy_label") or "").strip() + if not label and config is not None and profile: + label = strategy_label_from_config(config, profile, locale=locale) + stale.append( + { + "platform_id": str(row.get("platform_id") or "").strip(), + "strategy_profile": profile, + "strategy_label": label, + "candidate_business_day": day, + "digest_business_day": target, + "reason_code": reason, + "target_key": key, + "account_hint": str(row.get("account_hint") or "").strip(), + } + ) + if stats is not None: + stats["suppressed_candidates_count"] = suppressed + return fresh, stale + + +def collect_digest_runs( + *, + business_day: str, + config: Mapping[str, Any], + candidates_path: Path | None = None, + include_github: bool = True, + locale: str = "zh", +) -> CollectResult: + store_failures = candidate_store_failures() + try: + loaded_rows, candidates_configured, candidates_loaded = load_candidates_from_file(candidates_path) + except (OSError, ValueError): + # Only an already failed intake may downgrade an unusable fallback. + # Ordinary malformed/absent configured sources retain their rejection. + if (os.environ.get("DIGEST_CANDIDATES_STORE_STATUS") or "").strip() != "failed": + raise + loaded_rows, candidates_configured, candidates_loaded = [], True, False + store_failures.append({"source_kind": "candidate_store", "reason_code": "candidate_input_unavailable_or_invalid"}) + candidates_configured = candidates_configured or bool( + (os.environ.get("DIGEST_CANDIDATES_GCS_URI") or "").strip() + ) + partition_stats: dict[str, int] = {} + if consolidation_enabled(): + import notification_candidate_adapter as adapter + current, stale = adapter.partition(loaded_rows, business_day) + loaded_rows = current + stale + file_rows, stale_notes = partition_candidates_by_business_day( + loaded_rows, business_day, config=config, locale=locale, stats=partition_stats + ) + missing_targets = [] + stale_by_target = {item["target_key"]: item for item in stale_notes if item.get("target_key")} + current_targets = {_target_key(row) for row in file_rows if _target_key(row)} + for item in stale_notes: + missing_targets.append(dict(item, reason_code="EVIDENCE_STALE_BUSINESS_DAY")) + expected_raw = os.environ.get("DIGEST_EXPECTED_TARGETS_JSON", "").strip() + expected = json.loads(expected_raw) if expected_raw else digest_contract(config).get("aggregator", {}).get("expected_targets", []) + if routine_mode() == "daily_digest" and not expected: + raise ValueError("digest_expected_targets_required") + if not isinstance(expected, list): + raise ValueError("digest_expected_targets_invalid") + if consolidation_enabled(): + adapter.validate_expected(expected) + # In unified mode a different strategy/account on the same target is + # not evidence for the protected expected binding. + observed = {tuple(row.get(name) for name in adapter.IDENTITY[:4]) for row in file_rows} + for row in expected: + if not isinstance(row, Mapping) or not _target_key(row) or not row.get("strategy_profile"): + raise ValueError("digest_expected_targets_invalid") + key = _target_key(row) + absent = tuple(row.get(name) for name in adapter.IDENTITY[:4]) not in observed if consolidation_enabled() else key not in current_targets + if absent and key not in stale_by_target: + reason = "EVIDENCE_UNREADABLE" if store_failures else ( + "EVIDENCE_NOT_FOUND" if candidates_configured else "SOURCE_NOT_CONFIGURED" + ) + missing_targets.append({ + "platform_id": row["platform_id"], "strategy_profile": row["strategy_profile"], + "strategy_label": str(row.get("strategy_label") or ""), "target_key": key, + "account_hint": str(row.get("account_hint") or ""), "reason_code": reason, + }) + github_result = GithubEvidenceResult() + if include_github: + github_result = collect_from_github_workflows( + business_day=business_day, config=config + ) + notification_summary = None + if consolidation_enabled(): + merged, notification_summary = adapter.aggregate(file_rows, expected) + else: + merged = merge_candidates(file_rows, github_result.rows) + enriched = enrich_candidate_labels(merged, config, locale=locale) + for row in enriched: + if all(digest.resolve_count_field(row, name)[1] == "known" for name in ("fill_count", "order_count")): + continue + if row.get("reason_code") in digest.MISSING_EVIDENCE_REASONS: + continue + row["missing_evidence_reason"] = "EVIDENCE_UNREADABLE" if store_failures else ( + "EVIDENCE_STALE_BUSINESS_DAY" if _target_key(row) in stale_by_target else + "SOURCE_NOT_CONFIGURED" if not candidates_configured else "EVIDENCE_NOT_FOUND" + ) + runs = digest.filter_runs_for_digest(enriched) + coverage = digest.EvidenceCoverage( + candidates_configured=candidates_configured, + candidates_loaded=candidates_loaded, + candidates_count=len(file_rows), + github_enabled=include_github, + github_sources_queried=github_result.sources_queried, + github_sources_matched=github_result.sources_matched, + github_failures=tuple(github_result.failures), + candidate_failures=tuple(store_failures), + stale_candidates=tuple(stale_notes), + missing_targets=tuple(missing_targets), + suppressed_candidates_count=partition_stats.get("suppressed_candidates_count", 0), + legacy_candidates_only=bool(partition_stats.get("suppressed_candidates_count") and not file_rows), + ) + return CollectResult(runs=runs, coverage=coverage, merged_rows=enriched, notification_summary=notification_summary) + + +def _run_receipt_dict(entry: digest.DigestRunEntry) -> dict[str, Any]: + payload = asdict(entry) + if not consolidation_enabled(): + payload.pop("source_run_id", None) + payload.pop("approved_gate_reasons", None) + # holdings are nested dataclasses already flattened by asdict + payload["identity"] = { + "platform_id": entry.platform_id, + "strategy_profile": entry.strategy_profile, + "opaque_account_uid": entry.opaque_account_uid + or digest.UNKNOWN_ACCOUNT_UID, + "target_id": entry.target_id or digest.UNKNOWN_TARGET_ID, + } + provenance = entry.evidence_provenance or "unknown" + payload["field_provenance"] = { + "fill_count": ( + "unknown" + if entry.fill_count_status == "unknown" + else ("github_stub" if provenance.startswith("github") else "candidates") + ), + "order_count": ( + "unknown" + if entry.order_count_status == "unknown" + else ("github_stub" if provenance.startswith("github") else "candidates") + ), + "cycle_count": ( + "unknown" + if entry.cycle_count_status == "unknown" + else ("github_stub" if provenance.startswith("github") else "candidates") + ), + "equity": "candidates" if entry.equity is not None else "absent", + "holdings": "candidates" if entry.holdings else "absent", + "signal_summary": "candidates" if entry.signal_summary else "absent", + "rebalance": ( + "candidates" + if entry.rebalance_kind or entry.rebalance_conclusion + else "absent" + ), + "evidence_provenance": provenance, + } + return payload + + +def build_digest_payload( + *, + business_day: str | None = None, + locale: str | None = None, + candidates_path: Path | None = None, + include_github: bool = True, + config_path: Path | None = None, + layout: str | None = None, +) -> tuple[digest.DailyDigestInput, str, CollectResult]: + config = load_platform_config(config_path) + contract = digest_contract(config) + day = resolve_business_day(explicit=business_day) + loc = resolve_locale(locale) + collected = collect_digest_runs( + business_day=day, + config=config, + candidates_path=candidates_path, + include_github=include_github, + locale=loc, + ) + evidence_status = "has_runs" if collected.runs else ( + "verified_idle" + if collected.coverage.is_complete_for_verified_idle + else "evidence_unknown" + ) + payload = digest.DailyDigestInput( + business_day=day, + window_label=window_label_for(contract), + locale=loc, + runs=tuple(collected.runs), + evidence_coverage=collected.coverage, + evidence_status=evidence_status, + layout=resolve_layout(layout), + window=digest_window_for(day), + notification_summary=collected.notification_summary, + ) + from unified_daily_digest import enabled, render + return payload, render(payload) if enabled() else digest.render_daily_digest(payload), collected + + +def build_receipt( + payload: digest.DailyDigestInput, + collected: CollectResult, + *, + message_chars: int, +) -> dict[str, Any]: + evidence_status = digest.resolve_evidence_status(payload) + fills = digest.global_total_fills(payload) + if consolidation_enabled() and (collected.notification_summary or {}).get("counts_unknown"): + fills = None + receipt = { + "schema_version": "qsl.daily_digest_receipt.v1", + "business_day": payload.business_day, + "window_label": payload.window_label, + "layout": digest.normalize_layout(str(payload.layout)), + "window": dict(payload.window or {}), + "locale": digest.normalize_locale(str(payload.locale)), + "run_count": len(payload.runs), + "total_fills": fills, + "total_fills_status": "unknown" if fills is None else "known", + "evidence_status": evidence_status, + "source_coverage": collected.coverage.to_dict(), + "failures": list(collected.coverage.github_failures) + list(collected.coverage.candidate_failures), + "stale_candidates": [dict(item) for item in collected.coverage.stale_candidates], + "runs": [_run_receipt_dict(entry) for entry in payload.runs], + "message_chars": message_chars, + } + if consolidation_enabled(): + receipt["notification_summary"] = dict(collected.notification_summary or {}) + if receipt["notification_summary"].get("run_count_unknown"): + receipt["run_count"] = None + # B11-b: additive dual-write of delivery axes (projection only). + return delivery_axes.attach_delivery_axes(receipt) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Aggregate QuantSentinel daily digest evidence and render message body." + ) + parser.add_argument("--business-day", default=os.environ.get("DIGEST_BUSINESS_DAY")) + parser.add_argument("--locale", default=None) + parser.add_argument("--candidates", type=Path, default=None) + parser.add_argument( + "--no-github", + action="store_true", + help="Do not query GitHub Actions workflow conclusions.", + ) + parser.add_argument( + "--platform-config", + type=Path, + default=None, + help="Override platform-config.json path.", + ) + parser.add_argument( + "--write-json", + type=Path, + default=None, + help="Optional path to write structured digest receipt (no secrets).", + ) + parser.add_argument( + "--write-text", + type=Path, + default=None, + help="Optional path to write rendered Telegram body.", + ) + args = parser.parse_args(argv) + + payload, text, collected = build_digest_payload( + business_day=args.business_day, + locale=args.locale, + candidates_path=args.candidates, + include_github=not args.no_github, + config_path=args.platform_config, + ) + if args.write_text: + args.write_text.parent.mkdir(parents=True, exist_ok=True) + args.write_text.write_text(text + "\n", encoding="utf-8") + if args.write_json: + receipt = build_receipt(payload, collected, message_chars=len(text)) + args.write_json.parent.mkdir(parents=True, exist_ok=True) + args.write_json.write_text( + json.dumps(receipt, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + print(text) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/vendor/backport_qrs/daily_digest_delivery_axes.py b/tests/vendor/backport_qrs/daily_digest_delivery_axes.py new file mode 100644 index 0000000..1e6660e --- /dev/null +++ b/tests/vendor/backport_qrs/daily_digest_delivery_axes.py @@ -0,0 +1,300 @@ +#!/usr/bin/env python3 +"""B11 delivery-axis projection for daily digest receipts (dual-write only). + +Maps existing receipt / run fields onto four mutually non-impersonating axes: + +- execution_status +- evidence_persistence_status +- evidence_delivery_status +- notification_delivery_status + +Additive: never removes or renames legacy fields (delivered, dry_run, +evidence_status, per-run status, …). Readers that ignore unknown keys stay +compatible. Pure projection — does not invent equity/fills and does not +trigger order retries. +""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from typing import Any + +# --- Allowed enums (document freeze; keep stable for B12 consumers) --- + +EXECUTION_STATUSES = frozenset( + { + "ok_no_order", + "ok_filled", + "ok_rebalanced", + "execution_alert", + "execution_unknown", + "reconciliation_required", + "not_applicable", + } +) +PERSISTENCE_STATUSES = frozenset( + { + "persisted", + "persistence_failed", + "skipped", + "absent", + "unknown", + } +) +EVIDENCE_DELIVERY_STATUSES = frozenset( + { + "delivered_to_central", + "delivered_idle", + "not_loaded", + "not_configured", + "intake_degraded", + "delivery_failed", + "pending_central", + "omitted", + "unknown", + } +) +NOTIFICATION_DELIVERY_STATUSES = frozenset( + { + "dry_run_not_sent", + "sent_ok", + "send_failed", + "blocked", + "unknown", + "not_applicable", + } +) + + +def _norm(value: object) -> str: + return str(value or "").strip() + + +def _lower(value: object) -> str: + return _norm(value).lower() + + +def project_run_execution_status(run: Mapping[str, Any]) -> str: + """Project one run row → execution_status (§3.1).""" + + explicit = _norm(run.get("execution_status")) + if explicit: + return explicit if explicit in EXECUTION_STATUSES else explicit + + if _lower(run.get("status")) == "alert": + return "execution_alert" + + fill_status = _lower(run.get("fill_count_status")) + fill_count = run.get("fill_count") + reason = _lower(run.get("reason_code")) + kind = _lower(run.get("rebalance_kind")) + conclusion = _lower(run.get("rebalance_conclusion")) + + if fill_status == "unknown" or "fills_not_" in reason or "counts_unknown" in reason: + # Still allow verified no-order when fill is known 0 below. + if not (fill_status == "known" and fill_count == 0): + if fill_status == "unknown" or "fills_not_" in reason: + return "execution_unknown" + + if fill_status != "unknown" and isinstance(fill_count, int) and fill_count >= 1: + return "ok_filled" + + if kind == "rebalance" and ( + "success" in conclusion + or "filled" in conclusion + or "成交" in _norm(run.get("rebalance_conclusion")) + ): + return "ok_rebalanced" + + if kind in {"no_order", "no_rebalance"} and ( + fill_status == "known" and fill_count == 0 + ): + return "ok_no_order" + + if "reconciliation" in reason: + return "reconciliation_required" + + return "execution_unknown" + + +def project_run_persistence_status(run: Mapping[str, Any]) -> str: + """Project one run row → evidence_persistence_status (§3.2, weak).""" + + explicit = _norm(run.get("evidence_persistence_status")) + if explicit: + return explicit if explicit in PERSISTENCE_STATUSES else explicit + + reason = _lower(run.get("reason_code")) + note = _lower(run.get("note")) + provenance = _lower(run.get("evidence_provenance")) + producer = _lower(run.get("producer_status")) + + if "persistence_failed" in reason or "persistence_failed" in note: + return "persistence_failed" + if producer == "skipped" or "skipped" in reason: + return "skipped" + if producer == "empty": + return "absent" + if provenance.startswith("github"): + return "unknown" + if producer == "projected" or "archive" in reason or "archive" in note: + return "persisted" + if "facts" in reason or provenance in {"candidates", "archive_projection"}: + return "persisted" + if provenance: + return "unknown" + return "unknown" + + +def project_run_evidence_delivery_status( + run: Mapping[str, Any], + *, + in_central_runs: bool, +) -> str: + """Per-run evidence delivery into central (§3.3 / §4.1).""" + + explicit = _norm(run.get("evidence_delivery_status")) + if explicit: + return explicit if explicit in EVIDENCE_DELIVERY_STATUSES else explicit + if in_central_runs: + return "delivered_to_central" + return "pending_central" + + +def project_evidence_delivery_status(receipt: Mapping[str, Any]) -> str: + """Receipt-level evidence_delivery_status (§3.3).""" + + coverage = receipt.get("source_coverage") or {} + if not isinstance(coverage, Mapping): + coverage = {} + evidence_status = _lower(receipt.get("evidence_status")) + configured = bool(coverage.get("candidates_configured")) + loaded = bool(coverage.get("candidates_loaded")) + failures = coverage.get("github_failures") or receipt.get("failures") or [] + runs = receipt.get("runs") or [] + + if evidence_status == "evidence_unknown" or failures: + return "intake_degraded" + if configured and not loaded: + return "not_loaded" + if not configured and not loaded and not runs: + # May still have github-only evidence; if evidence_status has_runs with + # github stubs, treat as delivered_to_central when runs present. + if evidence_status == "verified_idle": + return "delivered_idle" + if not coverage.get("github_enabled"): + return "not_configured" + if loaded and runs: + return "delivered_to_central" + if evidence_status == "verified_idle": + return "delivered_idle" + if loaded and not runs: + return "delivered_idle" + if evidence_status == "has_runs" and runs: + return "delivered_to_central" + if configured: + return "not_loaded" + return "unknown" + + +def project_notification_delivery_status(receipt: Mapping[str, Any]) -> str: + """Receipt-level notification_delivery_status (§3.4).""" + + if "dry_run" not in receipt and "delivered" not in receipt and "status" not in receipt: + return "not_applicable" + + if receipt.get("dry_run") is True: + return "dry_run_not_sent" + + status = _lower(receipt.get("status")) + if receipt.get("delivered") is True and status == "delivered": + return "sent_ok" + if status == "send_failed": + return "send_failed" + if status == "blocked_missing_secrets": + return "blocked" + if status == "route_check": + return "not_applicable" + if status == "dry_run": + return "dry_run_not_sent" + return "unknown" + + +def _rollup_execution(statuses: Sequence[str]) -> str: + if not statuses: + return "not_applicable" + if any(s == "execution_alert" for s in statuses): + return "execution_alert" + if any(s == "reconciliation_required" for s in statuses): + return "reconciliation_required" + if any(s == "execution_unknown" for s in statuses): + return "execution_unknown" + if any(s == "ok_filled" for s in statuses): + return "ok_filled" + if any(s == "ok_rebalanced" for s in statuses): + return "ok_rebalanced" + if all(s == "ok_no_order" for s in statuses): + return "ok_no_order" + return "execution_unknown" + + +def _rollup_persistence(statuses: Sequence[str]) -> str: + if not statuses: + return "unknown" + if any(s == "persistence_failed" for s in statuses): + return "persistence_failed" + if any(s == "unknown" for s in statuses): + return "unknown" + if all(s == "skipped" for s in statuses): + return "skipped" + if all(s == "absent" for s in statuses): + return "absent" + if all(s == "persisted" for s in statuses): + return "persisted" + return "unknown" + + +def attach_delivery_axes(receipt: dict[str, Any]) -> dict[str, Any]: + """Dual-write ``delivery_axes`` (+ per-run axes) onto an existing receipt. + + Mutates and returns the same dict. Legacy fields are preserved. + """ + + runs = receipt.get("runs") + if not isinstance(runs, list): + runs = [] + + exec_statuses: list[str] = [] + persist_statuses: list[str] = [] + for run in runs: + if not isinstance(run, dict): + continue + execution = project_run_execution_status(run) + persistence = project_run_persistence_status(run) + delivery = project_run_evidence_delivery_status(run, in_central_runs=True) + # Additive per-run dual-write (ignore-unknown for old readers). + run["execution_status"] = execution + run["evidence_persistence_status"] = persistence + run["evidence_delivery_status"] = delivery + exec_statuses.append(execution) + persist_statuses.append(persistence) + + receipt["delivery_axes"] = { + "execution_status": _rollup_execution(exec_statuses), + "evidence_persistence_status": _rollup_persistence(persist_statuses), + "evidence_delivery_status": project_evidence_delivery_status(receipt), + "notification_delivery_status": project_notification_delivery_status( + receipt + ), + } + return receipt + + +__all__ = [ + "attach_delivery_axes", + "project_evidence_delivery_status", + "project_notification_delivery_status", + "project_run_evidence_delivery_status", + "project_run_execution_status", + "project_run_persistence_status", +] diff --git a/tests/vendor/backport_qrs/daily_digest_notify.py b/tests/vendor/backport_qrs/daily_digest_notify.py new file mode 100644 index 0000000..975f8d3 --- /dev/null +++ b/tests/vendor/backport_qrs/daily_digest_notify.py @@ -0,0 +1,1651 @@ +#!/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 + source_run_id: str = "" + approved_gate_reasons: tuple[Mapping[str, object], ...] = () + + 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. + """ + + from notification_mode import consolidation_enabled + 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, + source_run_id=str(raw.get("source_run_id") or "") if consolidation_enabled() else "", + approved_gate_reasons=tuple(raw.get("_validated_approved_gate_reasons", ())) if consolidation_enabled() else (), + 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/backport_qrs/display_names.json b/tests/vendor/backport_qrs/display_names.json new file mode 100644 index 0000000..5f2e628 --- /dev/null +++ b/tests/vendor/backport_qrs/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/backport_qrs/display_names.py b/tests/vendor/backport_qrs/display_names.py new file mode 100644 index 0000000..a045cee --- /dev/null +++ b/tests/vendor/backport_qrs/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/backport_qrs/merge_digest_candidates.py b/tests/vendor/backport_qrs/merge_digest_candidates.py new file mode 100644 index 0000000..019a0d8 --- /dev/null +++ b/tests/vendor/backport_qrs/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/backport_qrs/notification_approved_gates.py b/tests/vendor/backport_qrs/notification_approved_gates.py new file mode 100644 index 0000000..58635e1 --- /dev/null +++ b/tests/vendor/backport_qrs/notification_approved_gates.py @@ -0,0 +1,133 @@ +"""Validate and render the two approved Schwab notification gates, per run.""" +from __future__ import annotations + +import hashlib +import json +import math +from collections.abc import Mapping + +CONSECUTIVE = "consecutive_loss_limit_exceeded" +DAILY = "daily_loss_limit_exceeded" +ROUTINE = {"routine_cycle", "healthy_heartbeat", "expected_block_observed"} + + +def _number(value): + return type(value) in (int, float) and math.isfinite(value) + + +def clean_reasons(values): + if not isinstance(values, (list, tuple)) or not 1 <= len(values) <= 2: + return [] + reasons, seen = [], set() + for value in values: + if not isinstance(value, Mapping): + return [] + code, observed, limit = (value.get(k) for k in ("reason_code", "observed_value", "limit")) + if not isinstance(code, str) or code in seen: + return [] + seen.add(code) + if code == CONSECUTIVE: + if not (type(observed) is type(limit) is int and 5 == limit < observed <= 999999999): + return [] + elif code == DAILY: + equity = value.get("equity_usd") + if not all(_number(v) for v in (observed, limit, equity)) or not (observed >= limit > 0 and equity > 0): + return [] + if not math.isclose(limit, equity * (0.01 + 0.04 * 2000 / (2000 + equity)), rel_tol=1e-12): + return [] + else: + return [] + reason = dict(reason_code=code, observed_value=observed, limit=limit) + if code == DAILY: + reason["equity_usd"] = equity + reasons.append(reason) + return reasons + + +def merge_reasons(previous, row, merged): + """Union compatible observations of this run; conflicts stay disqualifying.""" + by_code = {} + invalid = previous.get("_approved_gate_conflict") or row.get("_approved_gate_conflict") + for observation in (previous, row): + block = observation.get("block_reason") + if block and (not isinstance(block, Mapping) or block.get("reason_code") not in {"strategy_risk_rejected", CONSECUTIVE}): + invalid = True + if observation.get("reason_code") not in (None, "", "execution_evidence_missing_or_attention"): + invalid = True + if "approved_gate_reasons" not in observation: + if observation.get("schema_version") != "qsl.notification_event.v1" and (block or observation.get("status") == "alert"): + invalid = True + continue + reasons = clean_reasons(observation["approved_gate_reasons"]) + if not reasons: + invalid = True + for reason in reasons: + code = reason["reason_code"] + if code in by_code and by_code[code] != reason: + invalid = True + by_code[code] = reason + if by_code: + merged["approved_gate_reasons"] = list(by_code.values()) + if invalid: + merged["_approved_gate_conflict"] = True + + +def validated_reasons(row, facts): + """Archive-approved structured evidence plus its own bound event facts only. + + Raw events alone cannot approve a gate. Receipt validation belongs to the + producer's archive projection; an unknown/urgent event always overrides it. + """ + reasons = clean_reasons(row.get("approved_gate_reasons")) + if not reasons or row.get("_approved_gate_conflict") or row.get("platform_id") != "schwab": + return [] + if row.get("schema_version") == "qsl.notification_event.v1" or row.get("actually_ran") is not True: + return [] + if not all(isinstance(row.get(k), str) and row[k].strip() for k in + ("platform_id", "opaque_account_uid", "target_id", "strategy_profile", "source_run_id")): + return [] + from daily_digest_notify import resolve_count_field + if resolve_count_field(row, "order_count") != (0, "known") or resolve_count_field(row, "cycle_count") != (1, "known"): + return [] + fills, _ = resolve_count_field(row, "fill_count") + if fills not in (None, 0): + return [] + block = row.get("block_reason") + if not isinstance(block, Mapping) or block.get("reason_code") not in {"strategy_risk_rejected", CONSECUTIVE}: + return [] + by_code = {r["reason_code"]: r for r in reasons} + if block["reason_code"] == CONSECUTIVE: + if clean_reasons([block]) != [by_code.get(CONSECUTIVE)]: + return [] + if row.get("reason_code") not in (None, "", "execution_evidence_missing_or_attention"): + return [] + for fact in facts: + if fact.get("event_kind") not in ROUTINE: + return [] + if any(fact.get(k) != row.get(k) for k in + ("platform_id", "opaque_account_uid", "target_id", "strategy_profile", "source_run_id")): + return [] + event_reasons = clean_reasons(fact.get("approved_gate_reasons")) + if "approved_gate_reasons" in fact and (not event_reasons or any(by_code.get(r["reason_code"]) != r for r in event_reasons)): + return [] + if fact.get("event_kind") == "expected_block_observed": + evidence = "approved-gate:" + hashlib.sha256(json.dumps(event_reasons, sort_keys=True, separators=(",", ":")).encode()).hexdigest() + if not event_reasons or fact.get("expected_block_evidence") != evidence: + return [] + return reasons + + +def render_reasons(reasons, locale): + parts = [] + for reason in clean_reasons(reasons): + observed, limit = str(reason["observed_value"]), str(reason["limit"]) + if reason["reason_code"] == CONSECUTIVE: + parts.append(f"连亏熔断:连续亏损 {observed}>{limit}" if locale == "zh" else f"Consecutive-loss gate: consecutive losses {observed}>{limit}") + else: + equity = str(reason["equity_usd"]) + rule = f"E*(0.01+0.04*2000/(2000+E)), E={equity} USD" + parts.append(f"日亏损上限:日亏损 {observed}≥{limit} USD({rule})" if locale == "zh" else f"Daily-loss gate: daily loss {observed}>={limit} USD ({rule})") + if not parts: + return "" + parts.append("本轮未下单" if locale == "zh" else "No order this run") + return ";".join(parts) if locale == "zh" else "; ".join(parts) diff --git a/tests/vendor/backport_qrs/notification_candidate_adapter.py b/tests/vendor/backport_qrs/notification_candidate_adapter.py new file mode 100644 index 0000000..afcd4db --- /dev/null +++ b/tests/vendor/backport_qrs/notification_candidate_adapter.py @@ -0,0 +1,257 @@ +"""QPK event facts -> existing private digest rows, without IO or alert sends.""" +from __future__ import annotations + +import json +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 +from notification_approved_gates import merge_reasons, validated_reasons + +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 event_facts_for(row: Mapping[str, Any]) -> list[Mapping[str, Any]]: + 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 raw + + +def events_for(row: Mapping[str, Any]) -> list[NotificationEvent]: + return [_event(item) for item in event_facts_for(row)] + + +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 + 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) + 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 raw_fact in (*event_facts_for(previous), *event_facts_for(row)): + # Preserve structured gate evidence outside the pinned QPK event. + fact = {**raw_fact, **_event(raw_fact).to_candidate()} + facts[json.dumps(fact, sort_keys=True, allow_nan=False)] = 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] + merge_reasons(previous, row, merged) + grouped[key] = merged + # Classification follows the final per-run evidence, just like counts. + # expected_block_observed alone is never authority to suppress an alert. + for key, row in grouped.items(): + facts = event_facts_for(row) + approved = validated_reasons(row, facts) + row["_validated_approved_gate_reasons"] = approved + urgent = False + for index, event in enumerate(events_for(row)): + event_key = event.to_candidate()["dedupe_key"] + if event_key: + event_ids.add(event_key) + routine = event.event_kind in ROUTINE_KINDS or (approved and event.event_kind == "expected_block_observed") + if not routine: + urgent = True + immediate_ids.add(event_key or ("unknown", key, index)) + if event.event_kind not in {"order_submitted", "fill"}: + row["status"] = "alert" + if event.event_kind == "blocked_run": + row["rebalance_conclusion"] = "blocked" + 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 not approved and not urgent and (row.get("status") == "alert" or blocked): + immediate_ids.add(("run", key)) + # 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/backport_qrs/notification_mode.py b/tests/vendor/backport_qrs/notification_mode.py new file mode 100644 index 0000000..73ef463 --- /dev/null +++ b/tests/vendor/backport_qrs/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/backport_qrs/notification_snapshot/__init__.py b/tests/vendor/backport_qrs/notification_snapshot/__init__.py new file mode 100644 index 0000000..3e87683 --- /dev/null +++ b/tests/vendor/backport_qrs/notification_snapshot/__init__.py @@ -0,0 +1 @@ +"""Notification-only QPK source snapshot; provenance in source.json.""" diff --git a/tests/vendor/backport_qrs/notification_snapshot/digest_routing.py b/tests/vendor/backport_qrs/notification_snapshot/digest_routing.py new file mode 100644 index 0000000..3d6929d --- /dev/null +++ b/tests/vendor/backport_qrs/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/backport_qrs/notification_snapshot/unified_contract.py b/tests/vendor/backport_qrs/notification_snapshot/unified_contract.py new file mode 100644 index 0000000..94ec7ed --- /dev/null +++ b/tests/vendor/backport_qrs/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/backport_qrs/qsl-digest-candidates.v1.schema.json b/tests/vendor/backport_qrs/qsl-digest-candidates.v1.schema.json new file mode 100644 index 0000000..c6322d2 --- /dev/null +++ b/tests/vendor/backport_qrs/qsl-digest-candidates.v1.schema.json @@ -0,0 +1,527 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/QuantStrategyLab/QuantRuntimeSettings/schemas/qsl-digest-candidates.v1.schema.json", + "title": "QSL Digest Candidates v1", + "description": "Producer-authored daily-digest candidate envelope consumed by QuantRuntimeSettings daily_digest_aggregator / daily_digest_notify. Observation fields are optional; unknown fill/order counts must be null with field_status counts_unknown (never invent 0). Not an order, fill, or adoption receipt.", + "type": "object", + "additionalProperties": false, + "properties": { + "schema_version": { + "const": "qsl.digest_candidates.v1" + }, + "runs": { + "type": "array", + "description": "Preferred candidate row array (producer contract).", + "items": { + "$ref": "#/$defs/run" + } + }, + "candidates": { + "type": "array", + "description": "Consumer alias for runs; aggregator accepts either key.", + "items": { + "$ref": "#/$defs/run" + } + }, + "producer_status": { + "type": "string", + "enum": [ + "projected", + "empty", + "skipped" + ], + "description": "Platform producer outcome; informational for operators, not rendered as fills." + }, + "producer_reason": { + "type": "string", + "maxLength": 512, + "description": "Short machine reason when status is empty/skipped or equity-without-runs." + } + }, + "$defs": { + "count_value": { + "description": "Verified non-negative count, or null when unknown.", + "anyOf": [ + { + "type": "null" + }, + { + "type": "integer", + "minimum": 0 + } + ] + }, + "field_status_token": { + "type": "string", + "enum": [ + "known", + "counts_unknown", + "unknown", + "unverified" + ] + }, + "holding": { + "type": "object", + "additionalProperties": false, + "required": [ + "symbol" + ], + "properties": { + "symbol": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "market_value": { + "type": [ + "number", + "null" + ], + "minimum": 0 + }, + "value": { + "type": [ + "number", + "null" + ], + "minimum": 0 + }, + "market_value_usd": { + "type": [ + "number", + "null" + ], + "minimum": 0 + }, + "quantity": { + "type": [ + "number", + "null" + ], + "minimum": 0 + }, + "qty": { + "type": [ + "number", + "null" + ], + "minimum": 0 + }, + "shares": { + "type": [ + "number", + "null" + ], + "minimum": 0 + }, + "currency": { + "type": "string", + "minLength": 1, + "maxLength": 16 + }, + "ccy": { + "type": "string", + "minLength": 1, + "maxLength": 16 + } + } + }, + "run": { + "type": "object", + "additionalProperties": false, + "required": [ + "platform_id", + "strategy_profile", + "actually_ran" + ], + "properties": { + "platform_id": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "description": "Short platform id recognized by central aggregator (e.g. schwab, ibkr)." + }, + "strategy_profile": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "actually_ran": { + "type": "boolean", + "description": "Must be explicit; consumer no longer defaults missing to true." + }, + "opaque_account_uid": { + "type": "string", + "maxLength": 128, + "description": "Opaque account binding hash/uid; never a raw broker login secret." + }, + "target_id": { + "type": "string", + "maxLength": 128 + }, + "account_hint": { + "type": "string", + "maxLength": 64, + "description": "Human display account label when already safe (e.g. IBKR U00000008)." + }, + "account_scope": { + "type": "string", + "maxLength": 64, + "description": "Runtime account_scope / mode token (e.g. live, paper, live-u00000008)." + }, + "execution_mode": { + "type": "string", + "maxLength": 64 + }, + "fill_count": { + "$ref": "#/$defs/count_value" + }, + "order_count": { + "$ref": "#/$defs/count_value" + }, + "cycle_count": { + "$ref": "#/$defs/count_value" + }, + "field_status": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/field_status_token" + }, + "properties": { + "fill_count": { + "$ref": "#/$defs/field_status_token" + }, + "order_count": { + "$ref": "#/$defs/field_status_token" + }, + "cycle_count": { + "$ref": "#/$defs/field_status_token" + } + } + }, + "evidence_provenance": { + "type": "string", + "maxLength": 64, + "description": "Defaults to candidates when omitted by consumer load path." + }, + "reason_code": { + "type": "string", + "maxLength": 512 + }, + "status": { + "type": "string", + "enum": [ + "ok", + "alert" + ] + }, + "business_day": { + "type": "string", + "maxLength": 32, + "description": "Producer business day (ISO date, America/New_York session). NOTIFY-04: the aggregator drops rows whose business day differs from the digest business day; rows with neither business_day nor as_of are dropped." + }, + "as_of": { + "type": "string", + "maxLength": 40, + "description": "Optional ISO 8601 observation timestamp; used only when business_day is absent (converted to the America/New_York calendar day; naive = UTC)." + }, + "strategy_label": { + "type": "string", + "maxLength": 128 + }, + "equity": { + "type": [ + "number", + "null" + ] + }, + "equity_usd": { + "type": [ + "number", + "null" + ] + }, + "equity_currency": { + "type": "string", + "maxLength": 16 + }, + "currency": { + "type": "string", + "maxLength": 16 + }, + "holdings": { + "type": "array", + "items": { + "$ref": "#/$defs/holding" + } + }, + "holdings_scope": { + "type": "string", + "enum": [ + "strategy_symbols_only", + "stocks_only" + ], + "description": "Optional coverage label for holdings: strategy_symbols_only (Schwab: only strategy symbols are listed) or stocks_only (IBKR: only STK positions). Holdings are never a full-account inventory unless this is absent and the producer says so." + }, + "positions": { + "type": "array", + "description": "Consumer alias for holdings.", + "items": { + "$ref": "#/$defs/holding" + } + }, + "signal_summary": { + "type": "string", + "maxLength": 256 + }, + "rebalance_kind": { + "type": "string", + "enum": [ + "", + "no_rebalance", + "rebalance", + "no_order", + "pending" + ] + }, + "rebalance_conclusion": { + "type": "string", + "maxLength": 1024 + }, + "tips": { + "anyOf": [ + { + "type": "string", + "maxLength": 512 + }, + { + "type": "array", + "items": { + "type": "string", + "maxLength": 512 + } + } + ] + }, + "tip": { + "type": "string", + "maxLength": 512 + }, + "note": { + "type": "string", + "maxLength": 512 + }, + "execution_status": { + "type": "string", + "enum": [ + "ok_no_order", + "ok_filled", + "ok_rebalanced", + "execution_alert", + "execution_unknown", + "reconciliation_required" + ], + "description": "B11 optional producer execution axis. When omitted, central projects from fills/rebalance/status. Does not authorize order retry." + }, + "evidence_persistence_status": { + "type": "string", + "enum": [ + "persisted", + "persistence_failed", + "skipped", + "absent", + "unknown" + ], + "description": "B11 optional platform evidence persistence axis. Archive retry must not trigger orders." + }, + "evidence_delivery_status": { + "type": "string", + "enum": [ + "pending_central", + "omitted", + "delivered_to_central" + ], + "description": "B11 optional producer-side delivery intent toward central. Central receipt projects authoritative evidence_delivery_status." + }, + "block_reason": { + "$ref": "#/$defs/block_reason" + }, + "source_run_id": { + "type": "string", + "minLength": 1 + }, + "notification_event": { + "$ref": "#/$defs/notification_event" + }, + "notification_events": { + "type": "array", + "items": { + "$ref": "#/$defs/notification_event" + } + } + } + }, + "block_reason": { + "type": "object", + "additionalProperties": false, + "description": "Notification-only observation for the latest blocked run; never proves whole-day zero orders or fills.", + "required": [ + "reason_code", + "observed_value", + "limit", + "order_submitted_this_run" + ], + "properties": { + "reason_code": { + "enum": [ + "consecutive_loss_limit_exceeded", + "strategy_risk_rejected" + ] + }, + "observed_value": { + "type": [ + "integer", + "null" + ], + "minimum": 0, + "maximum": 999999999 + }, + "limit": { + "type": [ + "integer", + "null" + ], + "minimum": 0, + "maximum": 999999999 + }, + "order_submitted_this_run": { + "type": [ + "boolean", + "null" + ] + } + } + }, + "notification_event": { + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "event_kind", + "occurred_at" + ], + "properties": { + "schema_version": { + "const": "qsl.notification_event.v1" + }, + "event_kind": { + "type": "string", + "minLength": 1 + }, + "occurred_at": { + "type": "string", + "format": "date-time" + }, + "platform_id": { + "type": [ + "string", + "null" + ] + }, + "opaque_account_uid": { + "type": [ + "string", + "null" + ] + }, + "target_id": { + "type": [ + "string", + "null" + ] + }, + "strategy_profile": { + "type": [ + "string", + "null" + ] + }, + "source_run_id": { + "type": [ + "string", + "null" + ] + }, + "incident_reason": { + "type": [ + "string", + "null" + ] + }, + "expected_block_evidence": { + "type": [ + "string", + "null" + ] + }, + "order_execution_digest": { + "type": [ + "string", + "null" + ] + }, + "dedupe_key": { + "type": [ + "string", + "null" + ] + }, + "order_count": { + "$ref": "#/$defs/count_value" + }, + "fill_count": { + "$ref": "#/$defs/count_value" + }, + "business_day": { + "type": "string", + "format": "date" + }, + "report_date": { + "type": "string", + "format": "date" + }, + "window_id": { + "type": "string", + "pattern": "^sgt-0600:[0-9]{4}-[0-9]{2}-[0-9]{2}$" + }, + "coverage_status": { + "enum": [ + "known", + "unknown" + ] + } + } + } + }, + "allOf": [ + { + "required": [ + "schema_version" + ] + }, + { + "anyOf": [ + { + "required": [ + "runs" + ] + }, + { + "required": [ + "candidates" + ] + } + ] + } + ] +} diff --git a/tests/vendor/backport_qrs/unified_daily_digest.py b/tests/vendor/backport_qrs/unified_daily_digest.py new file mode 100644 index 0000000..3468439 --- /dev/null +++ b/tests/vendor/backport_qrs/unified_daily_digest.py @@ -0,0 +1,126 @@ +"""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 + from notification_approved_gates import render_reasons + detail = render_reasons(row.approved_gate_reasons, "zh" if zh else "en") or 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/backport_qrs/upstream.json b/tests/vendor/backport_qrs/upstream.json new file mode 100644 index 0000000..9c172d1 --- /dev/null +++ b/tests/vendor/backport_qrs/upstream.json @@ -0,0 +1,63 @@ +{ + "repository": "QuantStrategyLab/QuantRuntimeSettings", + "commit": "bd3dc79f10b2c0b8048721695885c3f9333a53c6", + "test_only": true, + "files": { + "notification_approved_gates.py": { + "source": "python/scripts/notification_approved_gates.py", + "sha256": "2047e63485797079dc15d7a994344053b0076dce680d376697a56dc9add521b9" + }, + "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": "f4c3494667b44da9365ae0d6101a42cf855cdbd76ff36d85f62f19a56e1cca8f" + }, + "daily_digest_notify.py": { + "source": "python/scripts/daily_digest_notify.py", + "sha256": "11573cb1dabb4e001d975e55f60f8c4c8c95d95f3c1ced7d3ae11cf9341b7387" + }, + "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": "7d2a1f6301597ea75ef314282780cc9b5a6cbc8042f1a4f6e70a3682e7c72816" + }, + "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" + }, + "qsl-digest-candidates.v1.schema.json": { + "source": "schemas/qsl-digest-candidates.v1.schema.json", + "sha256": "05cfd8925bc0748e2601f52c28c7a41a5fdc7aa7ea6a135326e6ee26346d357a" + }, + "daily_digest_aggregator.py": { + "source": "python/scripts/daily_digest_aggregator.py", + "sha256": "f0b4672c9a987a525d7b26c7784e108d1e899c5f52708c17f96f2109780695d4" + }, + "daily_digest_delivery_axes.py": { + "source": "python/scripts/daily_digest_delivery_axes.py", + "sha256": "2db144b93428f95183908d2d1e652021c5dfd1a1851c764c0e71962acd600979" + } + } +}