From b34353248c53fd5e29c09fb1ede53bb686bdb8e0 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sat, 24 Oct 2026 03:33:57 +0800 Subject: [PATCH 1/2] refactor(notifications): import pinned QPK digest modules Delete the vendored notification snapshot (notifications/digest_snapshot/) and notifications/display_names.py. All five modules are byte-identical to the modules at the pinned quant-platform-kit revision, so production code, scripts and tests now import them from quant_platform_kit directly. Drop the snapshot provenance hash test; the QPK pin is the single reviewed source. Update docs/unified-daily-notification.md. QPK/strategy pins, trading, deployment and CI configuration are unchanged. Verification: baseline 1064 passed; after 1063 passed (one provenance test removed), 0 failed, 0 skipped; targeted notification tests 122 passed; ruff check . passes. Co-Authored-By: Claude Code --- application/rebalance_service.py | 6 +- docs/unified-daily-notification.md | 9 +- main.py | 4 +- notifications/digest_snapshot/__init__.py | 1 - .../digest_snapshot/digest_archive.py | 127 -------- .../digest_snapshot/digest_candidates.py | 121 -------- .../digest_snapshot/digest_routing.py | 92 ------ notifications/digest_snapshot/provenance.json | 17 -- .../digest_snapshot/unified_contract.py | 284 ------------------ notifications/display_names.json | 241 --------------- notifications/display_names.py | 54 ---- notifications/telegram.py | 2 +- notifications/unified.py | 8 +- scripts/emit_unified_digest_candidates.py | 2 +- tests/test_display_names.py | 11 +- tests/test_notification_consolidation.py | 2 +- tests/test_notification_storage_and_wiring.py | 8 - tests/test_sender_receipt_identity.py | 2 +- tests/test_unified_digest_candidates.py | 10 +- tests/test_unified_digest_routing.py | 2 +- 20 files changed, 27 insertions(+), 976 deletions(-) delete mode 100644 notifications/digest_snapshot/__init__.py delete mode 100644 notifications/digest_snapshot/digest_archive.py delete mode 100644 notifications/digest_snapshot/digest_candidates.py delete mode 100644 notifications/digest_snapshot/digest_routing.py delete mode 100644 notifications/digest_snapshot/provenance.json delete mode 100644 notifications/digest_snapshot/unified_contract.py delete mode 100644 notifications/display_names.json delete mode 100644 notifications/display_names.py diff --git a/application/rebalance_service.py b/application/rebalance_service.py index 0e52328..e371ae4 100644 --- a/application/rebalance_service.py +++ b/application/rebalance_service.py @@ -302,7 +302,7 @@ def send_and_capture(text: str) -> bool | None: def _should_publish_cycle_notification(result: Mapping[str, Any]) -> bool: - from notifications.digest_snapshot.digest_routing import daily_digest_enabled + from quant_platform_kit.notifications.digest_routing import daily_digest_enabled if daily_digest_enabled() and (result.get("ok") is False or any(result.get(key) for key in ( "error", "errors", "strategy_run_persistence_error", "strategy_plugin_error", "strategy_plugin_alert_error", "pending_reconciliation", "orders_pending_count", @@ -313,7 +313,7 @@ def _should_publish_cycle_notification(result: Mapping[str, Any]) -> bool: repeat_funding = result.get("notification_suppressed_reason") == "repeat_funding_blocked" if suppressed and not repeat_funding: return False - from notifications.digest_snapshot.digest_routing import defer_routine_cycle_to_digest + from quant_platform_kit.notifications.digest_routing import defer_routine_cycle_to_digest notification_evidence = dict(result) if not notification_evidence.get("execution_status") and result.get("ok") is True: notification_evidence["execution_status"] = "completed" @@ -419,7 +419,7 @@ def publish_strategy_plugin_alerts( log_message: Callable[..., Any] = print, env_reader: Callable[[str, str | None], str | None] = os.getenv, ): - from notifications.digest_snapshot.digest_routing import immediate_plugin_signals + from quant_platform_kit.notifications.digest_routing import immediate_plugin_signals return dispatch_strategy_plugin_alerts( immediate_plugin_signals(signals), notification_settings=settings, diff --git a/docs/unified-daily-notification.md b/docs/unified-daily-notification.md index f9ef19b..f99692c 100644 --- a/docs/unified-daily-notification.md +++ b/docs/unified-daily-notification.md @@ -8,10 +8,11 @@ routine plugin observations do not produce individual pushes. QPK source reference: `4cc5e55251269923771f90cd661bb62a5cfdf0cd` (notification modules only). -The notification-only QPK source snapshot in `notifications/digest_snapshot/` avoids an -unrelated trading dependency upgrade. Keep these three modules byte-identical -to the reviewed QPK notification modules. Existing QPK/strategy pins, order -execution, funding, risk, account enablement and durable report writes are unchanged. +The notification modules are imported directly from the QPK dependency pinned in +`pyproject.toml` and `uv.lock`; no vendored QPK notification snapshot is kept in +this repository. The pinned QPK revision is the single reviewed source for these +modules. Existing QPK/strategy pins, order execution, funding, risk, account +enablement and durable report writes are unchanged. Before enabling routing, validate the exact active target's private archived report, candidate store and QRS receiver. Existing cloud monitor wiring uses diff --git a/main.py b/main.py index 1da84ed..adbe762 100644 --- a/main.py +++ b/main.py @@ -300,10 +300,10 @@ def _strategy_result_summary(result: dict[str, Any], *, dry_run: bool) -> dict[s summary["orders_skipped"] = skipped_orders if execution_notes: summary["execution_notes"] = execution_notes - from notifications.digest_snapshot.digest_routing import daily_digest_enabled + from quant_platform_kit.notifications.digest_routing import daily_digest_enabled from notifications.unified import enabled if enabled() or daily_digest_enabled(): - from notifications.digest_snapshot.digest_candidates import cycle_evidence + from quant_platform_kit.notifications.digest_candidates import cycle_evidence evidence = dict(result) if not evidence.get("execution_status") and result.get("ok") is True: evidence["execution_status"] = "completed" diff --git a/notifications/digest_snapshot/__init__.py b/notifications/digest_snapshot/__init__.py deleted file mode 100644 index 63deddf..0000000 --- a/notifications/digest_snapshot/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Notification-only QPK source snapshot; trading dependency pins stay unchanged.""" diff --git a/notifications/digest_snapshot/digest_archive.py b/notifications/digest_snapshot/digest_archive.py deleted file mode 100644 index 436fd2a..0000000 --- a/notifications/digest_snapshot/digest_archive.py +++ /dev/null @@ -1,127 +0,0 @@ -"""Publish notification candidates from existing archives; disabled by default. - -Runs only inside an existing cloud monitor, with its existing identity. It never -invokes a platform service or broker, reads secrets, or enables a runtime target. -""" -from __future__ import annotations - -import json -import os -import re -import subprocess -from datetime import datetime, timedelta, timezone -from zoneinfo import ZoneInfo - -from .digest_candidates import project_reports - -_SAFE = re.compile(r"[a-z0-9][a-z0-9_-]{0,40}\Z") - - -def _read(args): - result = subprocess.run(["gcloud", "storage", *args], capture_output=True, text=True, check=False) - if result.returncode: - raise ValueError("candidate_archive_read_failed") - return json.loads(result.stdout) - - -def emit(env, *, now=None, read=_read, upload=None): - if env.get("QSL_ROUTINE_NOTIFICATION_MODE", "immediate") != "daily_digest": - return {"status": "disabled", "uploaded": False} - bindings = env.get("DIGEST_TARGET_BINDINGS_JSON", "").strip() - if bindings: - values = json.loads(bindings) - if not isinstance(values, list) or not values or len(values) > 100 or not all(isinstance(v, dict) for v in values): - raise ValueError("candidate_bindings_invalid") - results = [] - for value in values: - results.append(emit({**env, **value, "DIGEST_TARGET_BINDINGS_JSON": ""}, now=now, read=read, upload=upload)) - return {"status": "projected", "uploaded": any(v["uploaded"] for v in results), "targets": len(results)} - if env.get("RUNTIME_TARGET_ENABLED") == "false": - return {"status": "target_disabled", "uploaded": False} - if env.get("RUNTIME_TARGET_ENABLED") != "true": - raise ValueError("candidate_target_enablement_unknown") - target = json.loads(env.get("RUNTIME_TARGET_JSON", "")) - platform = target.get("platform_id", "") - slot = env.get("DIGEST_STORE_TARGET", "") - if not _SAFE.fullmatch(platform) or not _SAFE.fullmatch(slot): - raise ValueError("candidate_store_identity_missing") - uid, tid = env.get("DIGEST_OPAQUE_ACCOUNT_UID", ""), env.get("DIGEST_TARGET_ID", "") - if not uid or not tid: - raise ValueError("candidate_identity_missing") - prefix = env.get("EXECUTION_REPORT_GCS_URI", "").rstrip("/") - destination = env.get("DIGEST_CANDIDATES_GCS_URI", "").rstrip("/") - if any(not re.fullmatch(r"gs://[a-z0-9][a-z0-9._-]+/[^\s?#*]+", value) for value in (prefix, destination)): - raise ValueError("candidate_store_configuration_missing") - current = now or datetime.now(timezone.utc) - if current.astimezone(ZoneInfo("Asia/Singapore")).hour < 6: - return {"status": "before_cutoff", "uploaded": False} - day = (current.astimezone(ZoneInfo("Asia/Singapore")) - timedelta(hours=6)).date() - timedelta(days=1) - # Monitor runs after the fixed 06:00 SGT cut-off. Reject early runs rather - # than overwriting the previous day's complete candidate with partial facts. - end = datetime(day.year, day.month, day.day, 6, tzinfo=ZoneInfo("Asia/Singapore")) + timedelta(days=1) - start = end - timedelta(days=1) - months = {start.astimezone(timezone.utc).strftime("%Y-%m"), end.astimezone(timezone.utc).strftime("%Y-%m")} - entries = [] - for month in sorted(months): - entries.extend(read(["ls", "--json", f"{prefix}/{platform}/**/{month}/*.json"])) - if len(entries) > 10000: - raise ValueError("candidate_archive_budget_exceeded") - reports, uris = [], set() - for entry in entries: - uri = str(entry.get("url", "")).split("#", 1)[0] - if uri in uris: - continue - if not uri.startswith(f"{prefix}/{platform}/"): - raise ValueError("candidate_source_locator_mismatch") - uris.add(uri) - raw_stamp = (entry.get("metadata") or {}).get("updated") or (entry.get("metadata") or {}).get("timeCreated") - stamp = datetime.fromisoformat(str(raw_stamp).replace("Z", "+00:00")) - if stamp.tzinfo is None: - raise ValueError("candidate_archive_time_missing") - if stamp < start or stamp > current: - continue - source = read(["cat", uri]) - source_target = source.get("runtime_target") or {} - keys = ("platform_id", "strategy_profile", "service_name", "account_scope", "account_selector", "deployment_selector", "dry_run_only") - if not isinstance(source_target, dict) or any(source_target.get(key) != target.get(key) for key in keys): - continue - source_stamp = datetime.fromisoformat(str(source.get("started_at", "")).replace("Z", "+00:00")) - if source_stamp.tzinfo is None: - raise ValueError("candidate_source_time_invalid") - if start <= source_stamp < end: - reports.append(source) - if not reports: - schedule_raw = env.get("DIGEST_NOTIFICATION_SCHEDULE_JSON", "").strip() - if not schedule_raw: - raise ValueError("candidate_source_absent") - from quant_platform_kit.common.runtime_heartbeat_policy import filter_due_targets - schedule = json.loads(schedule_raw) - due, evaluated = filter_due_targets([schedule], since=start, now=end - timedelta(microseconds=1), - publication_grace=timedelta(0), warning_logger=lambda message: None) - if not evaluated or due: - raise ValueError("candidate_due_source_absent") - payload = {"schema_version": "qsl.digest_candidates.v1", "runs": [{ - "platform_id": platform, "strategy_profile": target["strategy_profile"], - "opaque_account_uid": uid, "target_id": tid, "business_day": day.isoformat(), - "actually_ran": False, "status": "ok", "scheduled_idle": True, - "evidence_provenance": "candidates", - }]} - else: - payload = project_reports(reports, expected_target=target, opaque_account_uid=uid, target_id=tid, now=current) - if upload is None: - from google.cloud import storage - def upload(uri, body): - storage.Blob.from_string(uri, client=storage.Client()).upload_from_string( - body, content_type="application/json", retry=None, timeout=30) - # Never retry an unknown write result. Public output contains no identities. - upload(f"{destination}/{platform}/{day.isoformat()}/{slot}.json", json.dumps(payload, allow_nan=False)) - return {"status": "projected", "uploaded": True, "runs": len(reports)} - - -def main(): - try: - print(json.dumps(emit(os.environ), sort_keys=True)) - return 0 - except Exception: - print(json.dumps({"status": "blocked", "uploaded": False, "reason": "candidate_projection_failed"})) - return 1 diff --git a/notifications/digest_snapshot/digest_candidates.py b/notifications/digest_snapshot/digest_candidates.py deleted file mode 100644 index ee086ff..0000000 --- a/notifications/digest_snapshot/digest_candidates.py +++ /dev/null @@ -1,121 +0,0 @@ -"""Notification-only facts projected from existing, privately archived reports. - -No broker calls. Missing or contradictory evidence stays unknown. Callers must -supply the exact protected runtime target; public labels never establish it. -""" -from __future__ import annotations - -from collections.abc import Mapping, Sequence -from datetime import datetime, timedelta -from zoneinfo import ZoneInfo - - -def cycle_evidence(execution: Mapping, orders: Sequence[Mapping], *, dry_run: bool = False) -> dict: - """Keep counts derived from actual order responses, never from log lines.""" - from .digest_routing import defer_routine_cycle_to_digest - - count = len(orders) - valid = all(isinstance(order, Mapping) and bool(order.get("status")) for order in orders) - confirmed = valid and all(str(order["status"]).lower() in {"filled", "completed", "executed"} for order in orders) - healthy = defer_routine_cycle_to_digest(execution, submitted_order_count=count, - submitted_orders=orders, mode="daily_digest", include_orders=True) - fill_ids = [] - fills_known = confirmed and healthy - for order in orders: - records = order.get("fills") if isinstance(order, Mapping) else None - if not isinstance(records, (list, tuple)) or not records: - fills_known = False - break - for record in records: - identity = next((record.get(k) for k in ("execution_id", "execId", "tradeId") if record.get(k) is not None), None) if isinstance(record, Mapping) else None - if identity is None: - fills_known = False - break - fill_ids.append(str(identity)) - fills_known = fills_known and len(fill_ids) == len(set(fill_ids)) - return { - "schema_version": "qsl.notification_cycle.v1", - "order_count": count if valid and not dry_run else None, - "fill_count": len(fill_ids) if fills_known and not dry_run else None, - "routine": healthy and not dry_run, - "dry_run": dry_run, - } - - -def project_reports(reports: Sequence[Mapping], *, expected_target: Mapping, - opaque_account_uid: str, target_id: str, now: datetime) -> dict: - """Aggregate each unique persisted cycle for the same target and source day.""" - if any(not isinstance(v, str) or not v.strip() for v in (opaque_account_uid, target_id)) or now.tzinfo is None: - raise ValueError("candidate_identity_or_time_missing") - required = {"platform_id", "strategy_profile", "service_name", "account_scope", "account_selector", "deployment_selector", "dry_run_only"} - if not required.issubset(expected_target) or expected_target.get("dry_run_only") is not False: - raise ValueError("candidate_target_invalid") - if (any(not isinstance(expected_target[k], str) or not expected_target[k].strip() - for k in required - {"account_selector", "dry_run_only"}) - or not isinstance(expected_target["account_selector"], list) or not expected_target["account_selector"] - or any(not isinstance(v, str) or not v.strip() for v in expected_target["account_selector"])): - raise ValueError("candidate_target_invalid") - rows, seen = {}, set() - for report in reports: - target = report.get("runtime_target") or {} - if not isinstance(target, Mapping) or any(target.get(k) != expected_target[k] for k in required): - raise ValueError("candidate_source_identity_mismatch") - if (report.get("schema_version") != "runtime_report.v1" or report.get("dry_run") is not False - or report.get("platform") != expected_target["platform_id"] - or report.get("strategy_profile") != expected_target["strategy_profile"] - or report.get("service_name") != expected_target["service_name"]): - raise ValueError("candidate_source_invalid") - stamp = datetime.fromisoformat(str(report.get("started_at", "")).replace("Z", "+00:00")) - if stamp.tzinfo is None or not timedelta(0) <= now - stamp <= timedelta(hours=36): - raise ValueError("candidate_source_time_invalid") - if report.get("status") not in {"ok", "completed", "success", "error", "aborted", "failed"}: - raise ValueError("candidate_source_incomplete") - run_id = report.get("run_id") - if not isinstance(run_id, str) or not run_id: - raise ValueError("candidate_run_id_missing") - if run_id in seen: - raise ValueError("candidate_duplicate_run") - seen.add(run_id) - day = (stamp.astimezone(ZoneInfo("Asia/Singapore")) - timedelta(hours=6)).date().isoformat() - facts = (report.get("summary") or {}).get("notification_digest") or {} - known = (facts.get("schema_version") == "qsl.notification_cycle.v1" and facts.get("dry_run") is False - and type(facts.get("order_count")) is int and facts["order_count"] >= 0 - and (facts.get("fill_count") is None or (type(facts["fill_count"]) is int and facts["fill_count"] >= 0))) - error = report.get("status") in {"error", "failed", "aborted"} or bool((report.get("error_summary") or {}).get("errors")) - alert = error or not known or facts.get("routine") is not True - row = rows.setdefault(day, { - "platform_id": expected_target["platform_id"], "strategy_profile": expected_target["strategy_profile"], - "opaque_account_uid": opaque_account_uid, "target_id": target_id, "business_day": day, - "actually_ran": True, "as_of": stamp.isoformat(), "status": "ok", "fill_count": 0, - "order_count": 0, "cycle_count": 0, "field_status": {}, "evidence_provenance": "candidates", - "persistence_status": "persisted", "delivery_status": "pending_central", - }) - row["cycle_count"] += 1 - row["as_of"] = max(row["as_of"], stamp.isoformat()) - for key in ("fill_count", "order_count"): - row[key] = row[key] + facts[key] if known and type(facts.get(key)) is int and row[key] is not None else None - row["field_status"][key] = "known" if row[key] is not None else "counts_unknown" - if not alert and row["status"] == "ok": - row["execution_status"] = "executed" if row["order_count"] else "no_action" - if alert: - row.update(status="alert", reason_code="source_cycle_alert" if error else "execution_evidence_missing_or_attention", - execution_status="execution_alert" if error else "execution_unknown") - return {"schema_version": "qsl.digest_candidates.v1", "producer_status": "projected", "runs": list(rows.values())} - - -def summary_evidence(summary: Mapping, *, dry_run: bool = False) -> dict: - """IBKR named order collections are broker facts, unlike formatted logs.""" - if not isinstance(summary.get("orders_submitted"), (list, tuple)): - return {"schema_version": "qsl.notification_cycle.v1", "order_count": None, - "fill_count": None, "routine": False, "dry_run": dry_run} - orders = list(summary.get("orders_submitted") or ()) + list(summary.get("option_orders_submitted") or ()) - filled = list(summary.get("orders_filled") or ()) + list(summary.get("option_orders_filled") or ()) - evidence = dict(summary) - if (orders and len(orders) == len(filled) and not summary.get("orders_pending") - and not summary.get("option_orders_pending") and not summary.get("orders_partially_filled") - and not summary.get("option_orders_partially_filled")): - # These collections are populated by the existing broker reconciliation. - facts = [{**order, "status": "filled"} for order in filled] if all(isinstance(order, Mapping) for order in filled) else orders - else: - facts = orders - return cycle_evidence(evidence, facts, dry_run=dry_run) diff --git a/notifications/digest_snapshot/digest_routing.py b/notifications/digest_snapshot/digest_routing.py deleted file mode 100644 index 3d6929d..0000000 --- a/notifications/digest_snapshot/digest_routing.py +++ /dev/null @@ -1,92 +0,0 @@ -"""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/notifications/digest_snapshot/provenance.json b/notifications/digest_snapshot/provenance.json deleted file mode 100644 index 2e7e080..0000000 --- a/notifications/digest_snapshot/provenance.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "repository": "QuantStrategyLab/QuantPlatformKit", - "source_sha": "07e24c0c804a9b47316b6ec3c75bb63820d8f421", - "branch": "main", - "method": "notification-only source snapshot; trading dependency pins unchanged", - "sources": { - "unified_contract.py": { - "source_path": "src/quant_platform_kit/notifications/unified_contract.py", - "sha256": "7ba48d1d7c74e66be8cd3ff983c2117002776e82cda6675a084cfe0dc51aecc0" - }, - "digest_routing.py": { - "source_path": "src/quant_platform_kit/notifications/digest_routing.py", - "sha256": "60c3f258c4d1a03bc49c7d03b392c02c316541726dfacb0b1d4fae6dbd2e5017" - } - }, - "merge_note": "snapshot pinned to merged QPK main; trading dependency pins unchanged" -} diff --git a/notifications/digest_snapshot/unified_contract.py b/notifications/digest_snapshot/unified_contract.py deleted file mode 100644 index 94ec7ed..0000000 --- a/notifications/digest_snapshot/unified_contract.py +++ /dev/null @@ -1,284 +0,0 @@ -"""Opt-in notification facts and routing; no broker, transport, or storage IO. - -Consumers inject their existing candidate producer and durable marker storage. -This module neither changes receipt acceptance nor replaces the central daily -recipient/report-date guard. Unknown evidence is never converted to zero. -""" -from __future__ import annotations - -import hashlib -import json -from collections.abc import Callable, Mapping -from dataclasses import asdict, dataclass -from datetime import date, datetime, time, timedelta -from typing import Literal, Protocol -from zoneinfo import ZoneInfo - -from .digest_routing import daily_digest_enabled - -Route = Literal["immediate", "digest_candidate", "legacy"] -EVENT_KINDS = frozenset({ - "routine_cycle", "healthy_heartbeat", "order_submitted", "fill", - "receipt_missing", "receipt_stale", "receipt_unacceptable", "auth_failure", - "order_status_unknown", "risk_escalation", "real_error", "blocked_run", - "expected_block_observed", -}) -ROUTINE_EVENT_KINDS = frozenset({"routine_cycle", "healthy_heartbeat"}) -ORDER_EVENT_KINDS = frozenset({"order_submitted", "fill"}) -SGT = ZoneInfo("Asia/Singapore") - - -def unified_notification_enabled(env: Mapping[str, object], key: str) -> bool: - """Read a release flag: only boolean True or the string 'true' enables it.""" - value = env.get(key) - return value is True or isinstance(value, str) and value.strip().lower() == "true" - - -def effective_notification_mode(env: Mapping[str, object], flag_key: str) -> str: - """Map the release boolean onto the existing single routing mode.""" - if unified_notification_enabled(env, flag_key): - return "daily_digest" - return str(env.get("QSL_ROUTINE_NOTIFICATION_MODE") or "immediate") - - -def select_notification_route(*, event_kind: str, env: Mapping[str, object], flag_key: str) -> Route: - """OFF delegates untouched to legacy. Unknown/blocked/urgent stays immediate. - - The new contract defaults routine orders to immediate. Legacy routing's - historical order default is deliberately not changed by this API. - """ - if not unified_notification_enabled(env, flag_key): - return "legacy" - if daily_digest_enabled(effective_notification_mode(env, flag_key)): - if event_kind in ROUTINE_EVENT_KINDS: - return "digest_candidate" - if event_kind in ORDER_EVENT_KINDS and unified_notification_enabled(env, "QSL_DIGEST_ROUTINE_ORDERS"): - return "digest_candidate" - return "immediate" - - -@dataclass(frozen=True) -class BusinessWindow: - start: datetime - end: datetime - business_day: date - report_date: date - window_id: str - - -def sgt_business_window(occurred_at: datetime) -> BusinessWindow: - """Containing half-open [D 06:00, D+1 06:00) window; not a send guard.""" - if occurred_at.tzinfo is None or occurred_at.utcoffset() is None: - raise ValueError("notification_time_timezone_missing") - day = (occurred_at.astimezone(SGT) - timedelta(hours=6)).date() - start = datetime.combine(day, time(6), SGT) - end = start + timedelta(days=1) - return BusinessWindow(start, end, day, end.date(), f"sgt-0600:{day.isoformat()}") - - -def _digest(parts: list[object]) -> str: - return hashlib.sha256(json.dumps(parts, ensure_ascii=False, separators=(",", ":")).encode()).hexdigest() - - -def order_identity_digest(order_or_execution_id: str) -> str: - """Irreversible, domain-separated identity; never put raw broker IDs in keys.""" - if not isinstance(order_or_execution_id, str) or not order_or_execution_id.strip(): - raise ValueError("notification_order_identity_missing") - return _digest(["qsl.notification_order.v1", order_or_execution_id]) - - -@dataclass(frozen=True) -class NotificationEvent: - event_kind: str - occurred_at: datetime - platform_id: str | None = None - opaque_account_uid: str | None = None - target_id: str | None = None - strategy_profile: str | None = None - source_run_id: str | None = None - order_execution_digest: str | None = None - incident_reason: str | None = None - order_count: int | None = None - fill_count: int | None = None - expected_block_evidence: str | None = None - - def __post_init__(self) -> None: - sgt_business_window(self.occurred_at) - for count in (self.order_count, self.fill_count): - if count is not None and (type(count) is not int or count < 0): - raise ValueError("notification_count_invalid") - digest = self.order_execution_digest - if digest is not None and (len(digest) != 64 or any(c not in "0123456789abcdef" for c in digest)): - raise ValueError("notification_order_digest_invalid") - if self.event_kind == "expected_block_observed" and not _identity_present(self.expected_block_evidence): - raise ValueError("notification_expected_block_evidence_missing") - - @property - def coverage_status(self) -> str: - return "known" if routine_dedupe_key(self) is not None else "unknown" - - def to_candidate(self) -> dict[str, object]: - """Private producer fact, to be projected with existing digest_candidates. - - This event envelope is not a replacement for qsl.digest_candidates.v1; - the existing archive projector remains responsible for window counts. - """ - result = asdict(self) - window = sgt_business_window(self.occurred_at) - result.update( - schema_version="qsl.notification_event.v1", occurred_at=self.occurred_at.isoformat(), - business_day=window.business_day.isoformat(), report_date=window.report_date.isoformat(), - window_id=window.window_id, dedupe_key=routine_dedupe_key(self), - coverage_status=self.coverage_status, - ) - if self.coverage_status == "unknown": - result.update(order_count=None, fill_count=None) - return result - - -def _identity_present(value: object) -> bool: - return isinstance(value, str) and bool(value.strip()) - - -def routine_dedupe_key(event: NotificationEvent) -> str | None: - parts = [event.platform_id, event.opaque_account_uid, event.target_id, - event.strategy_profile, event.source_run_id, event.event_kind] - if event.event_kind in ORDER_EVENT_KINDS: - parts.append(event.order_execution_digest) - return _digest(["qsl.notification_routine.v1", *parts]) if all(map(_identity_present, parts)) else None - - -class CandidatePersistenceError(RuntimeError): - pass - - -@dataclass(frozen=True) -class RoutingResult: - route: Route - candidate_persisted: bool - result: object = None - - -def route_notification_event( - event: NotificationEvent, *, env: Mapping[str, object], flag_key: str, - persist_candidate: Callable[[dict[str, object]], bool], - legacy: Callable[[], object], immediate: Callable[[], object], -) -> RoutingResult: - """Persist before deferral. Alert delivery survives candidate-write failure. - - The immediate callback owns durable incident reservation and receipt ACK. - Callers must inspect candidate_persisted; deferral is never delivery. - """ - route = select_notification_route(event_kind=event.event_kind, env=env, flag_key=flag_key) - if route == "legacy": - return RoutingResult(route, False, legacy()) - if route == "digest_candidate": - if event.coverage_status != "known": - raise CandidatePersistenceError("notification_candidate_identity_unknown") - if persist_candidate(event.to_candidate()) is not True: - raise CandidatePersistenceError("notification_candidate_write_unconfirmed") - return RoutingResult(route, True) - try: - persisted = persist_candidate(event.to_candidate()) is True - except Exception: - # A routine evidence-store failure cannot suppress a real alert. - persisted = False - return RoutingResult(route, persisted, immediate()) - - -class IncidentMarkerStore(Protocol): - """Durable JSON state, with atomic compare-and-set across workers. - - Missing key -> (None, None); read failures MUST raise, not act like missing. - A version is an opaque storage generation. No non-atomic has/write adapter - is provided: existing alert_marker has no atomic claim or readable receipt. - """ - def read(self, key: str) -> tuple[object, Mapping[str, object] | None]: ... - - def compare_and_set(self, key: str, version: object, value: Mapping[str, object]) -> bool: ... - - -@dataclass(frozen=True) -class IncidentDecision: - action: Literal["send", "duplicate", "recovered", "coverage_unknown"] - incident_key: str | None = None - transition_key: str | None = None - - -class IncidentTracker: - """Reserve before sending; pending/uncertain is never blindly resent. - - The caller supplies ordered observations and explicit recovery evidence. - Merely seeing a healthy label elsewhere is not proof of recovery. Severity - is a caller-owned nonnegative rank; a rise is a new escalation. Storage - errors propagate; a process-local set is never a production fallback. - """ - def __init__(self, store: IncidentMarkerStore): - self.store = store - - def _read_marker(self, key: str) -> tuple[object, dict[str, object] | None]: - version, value = self.store.read(key) - if version is None and value is None: - return version, None - required = {"schema_version", "active", "state", "severity", "generation", - "transition_key", "delivery_status", "receipt_id"} - if (version is None or not isinstance(value, Mapping) or not required.issubset(value) - or value["schema_version"] != "qsl.notification_incident.v1" - or type(value["active"]) is not bool - or not _identity_present(value["state"]) - or any(type(value[k]) is not int or value[k] < 0 for k in ("severity", "generation")) - or value["delivery_status"] not in {"not_attempted", "pending", "uncertain", "delivered"} - or (value["generation"] > 0 and not _identity_present(value["transition_key"]))): - raise RuntimeError("notification_marker_history_incomplete") - return version, dict(value) - - def observe(self, event: NotificationEvent, *, state: str, severity: int = 1, - recovered: bool = False) -> IncidentDecision: - parts = [event.platform_id, event.target_id, event.opaque_account_uid, - event.strategy_profile, event.incident_reason] - if not all(map(_identity_present, parts)): - return IncidentDecision("coverage_unknown") - if not _identity_present(state) or type(severity) is not int or severity < 0: - raise ValueError("notification_incident_state_invalid") - key = _digest(["qsl.notification_incident.v1", *parts]) - for _ in range(8): - version, previous = self._read_marker(key) - previous = previous or { - "schema_version": "qsl.notification_incident.v1", "active": False, - "state": None, "severity": 0, "generation": 0, - "transition_key": None, "delivery_status": "not_attempted", "receipt_id": None, - } - active = previous.get("active") is True - generation = int(previous.get("generation", 0)) - transition = previous.get("transition_key") - if recovered: - value = {**previous, "active": False, "state": state, "severity": severity} - action = "recovered" - elif active and severity <= int(previous["severity"]): - # A downgrade updates observation state without sending. Later - # severity-up remains visible, even before full recovery. - value = {**previous, "state": state, "severity": severity} - action = "duplicate" - else: - generation += 1 - transition = _digest([key, generation, previous.get("state"), state, severity]) - value = {"schema_version": "qsl.notification_incident.v1", - "active": True, "state": state, "severity": severity, - "generation": generation, "transition_key": transition, - "delivery_status": "pending", "receipt_id": None} - action = "send" - if value == previous or self.store.compare_and_set(key, version, value): - return IncidentDecision(action, key, transition) - raise RuntimeError("notification_marker_concurrent_update") - - def record_delivery(self, decision: IncidentDecision, *, receipt_id: str | None = None) -> None: - """Only an explicit receipt is delivered; absent receipt stays uncertain.""" - if decision.action != "send" or not decision.incident_key: - raise ValueError("notification_delivery_without_reservation") - version, previous = self._read_marker(decision.incident_key) - if not previous or previous.get("transition_key") != decision.transition_key: - raise RuntimeError("notification_marker_transition_changed") - value = {**previous, "delivery_status": "delivered" if _identity_present(receipt_id) else "uncertain", - "receipt_id": receipt_id if _identity_present(receipt_id) else None} - if not self.store.compare_and_set(decision.incident_key, version, value): - raise RuntimeError("notification_marker_concurrent_update") diff --git a/notifications/display_names.json b/notifications/display_names.json deleted file mode 100644 index 5f2e628..0000000 --- a/notifications/display_names.json +++ /dev/null @@ -1,241 +0,0 @@ -{ - "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/notifications/display_names.py b/notifications/display_names.py deleted file mode 100644 index a045cee..0000000 --- a/notifications/display_names.py +++ /dev/null @@ -1,54 +0,0 @@ -"""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/notifications/telegram.py b/notifications/telegram.py index c8f30df..277339f 100644 --- a/notifications/telegram.py +++ b/notifications/telegram.py @@ -2,7 +2,7 @@ from __future__ import annotations -from .display_names import display_name +from quant_platform_kit.common.display_names import display_name from quant_platform_kit.common.operational_notification_localization import resolve_operational_notification_locale diff --git a/notifications/unified.py b/notifications/unified.py index 9daa40e..b7b17dd 100644 --- a/notifications/unified.py +++ b/notifications/unified.py @@ -12,10 +12,10 @@ import json import os -from .digest_snapshot import digest_archive -from .digest_snapshot.digest_candidates import project_reports -from .digest_snapshot.digest_routing import defer_routine_cycle_to_digest -from .digest_snapshot.unified_contract import ( +from quant_platform_kit.notifications import digest_archive +from quant_platform_kit.notifications.digest_candidates import project_reports +from quant_platform_kit.notifications.digest_routing import defer_routine_cycle_to_digest +from quant_platform_kit.notifications.unified_contract import ( CandidatePersistenceError, IncidentTracker, NotificationEvent, effective_notification_mode, order_identity_digest, route_notification_event, sgt_business_window, unified_notification_enabled, diff --git a/scripts/emit_unified_digest_candidates.py b/scripts/emit_unified_digest_candidates.py index 70d229f..4ed8bbc 100644 --- a/scripts/emit_unified_digest_candidates.py +++ b/scripts/emit_unified_digest_candidates.py @@ -25,7 +25,7 @@ def main(): try: _check_binding_modes(os.environ) if not enabled(): - from notifications.digest_snapshot.digest_archive import main as legacy_main + from quant_platform_kit.notifications.digest_archive import main as legacy_main return legacy_main() print(json.dumps(emit_candidates(os.environ), sort_keys=True)) return 0 diff --git a/tests/test_display_names.py b/tests/test_display_names.py index 5265518..b3e0304 100644 --- a/tests/test_display_names.py +++ b/tests/test_display_names.py @@ -1,16 +1,11 @@ -import importlib.util import json -from pathlib import Path from unittest.mock import patch +from quant_platform_kit.common.display_names import display_name + def load_resolver(): - root = Path(__file__).resolve().parents[1] - path = root / "notifications/display_names.py" - spec = importlib.util.spec_from_file_location("display_names_under_test", path) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module.display_name + return display_name def test_display_mapping_and_unknown_names_do_not_rewrite_inputs(): diff --git a/tests/test_notification_consolidation.py b/tests/test_notification_consolidation.py index a4b1091..2d64493 100644 --- a/tests/test_notification_consolidation.py +++ b/tests/test_notification_consolidation.py @@ -7,7 +7,7 @@ import pytest from notifications import unified -from notifications.digest_snapshot.digest_candidates import cycle_evidence +from quant_platform_kit.notifications.digest_candidates import cycle_evidence NOW = datetime(2026, 10, 17, 0, tzinfo=timezone.utc) TARGET = dict(platform_id="firstrade", strategy_profile="synthetic", service_name="synthetic-service", diff --git a/tests/test_notification_storage_and_wiring.py b/tests/test_notification_storage_and_wiring.py index 635b6ba..6461c3e 100644 --- a/tests/test_notification_storage_and_wiring.py +++ b/tests/test_notification_storage_and_wiring.py @@ -23,14 +23,6 @@ def test_receipt_acceptance_quiet_and_due_rules_are_byte_identical_to_main(): assert actual == expected -def test_qpk_snapshot_matches_recorded_source_hashes(): - root = Path("notifications/digest_snapshot") - provenance = json.loads((root / "provenance.json").read_text()) - assert provenance["source_sha"] == "07e24c0c804a9b47316b6ec3c75bb63820d8f421" - for name, metadata in provenance["sources"].items(): - assert hashlib.sha256((root / name).read_bytes()).hexdigest() == metadata["sha256"] - - def test_real_gcs_adapter_uses_generations_and_never_retries_unknown_writes(): from google.api_core.exceptions import NotFound, PreconditionFailed class Blob: diff --git a/tests/test_sender_receipt_identity.py b/tests/test_sender_receipt_identity.py index f0588d9..78f06ca 100644 --- a/tests/test_sender_receipt_identity.py +++ b/tests/test_sender_receipt_identity.py @@ -6,7 +6,7 @@ import pytest from notifications import unified -from notifications.digest_snapshot.unified_contract import order_identity_digest +from quant_platform_kit.notifications.unified_contract import order_identity_digest from notifications.telegram import build_sender from test_notification_consolidation import ENV, NOW, TARGET, Store, source diff --git a/tests/test_unified_digest_candidates.py b/tests/test_unified_digest_candidates.py index 21e2623..e9eba18 100644 --- a/tests/test_unified_digest_candidates.py +++ b/tests/test_unified_digest_candidates.py @@ -1,7 +1,7 @@ from copy import deepcopy from datetime import datetime, timezone import pytest -from notifications.digest_snapshot.digest_candidates import cycle_evidence, project_reports +from quant_platform_kit.notifications.digest_candidates import cycle_evidence, project_reports TARGET = dict(platform_id="synthetic", strategy_profile="core", service_name="test-service", account_scope="test-scope", account_selector=["test-selector"], deployment_selector="test-slot", dry_run_only=False) @@ -50,7 +50,7 @@ def test_bad_source_stays_visible_or_rejects(mutation): def test_archive_default_off_and_disabled_target_never_read_or_upload(): - from notifications.digest_snapshot.digest_archive import emit + from quant_platform_kit.notifications.digest_archive import emit def forbidden(*args): raise AssertionError("unexpected I/O") assert emit({}, read=forbidden, upload=forbidden)["status"] == "disabled" assert emit({"QSL_ROUTINE_NOTIFICATION_MODE": "daily_digest", "RUNTIME_TARGET_ENABLED": "false"}, @@ -59,7 +59,7 @@ def forbidden(*args): raise AssertionError("unexpected I/O") def test_archive_aggregates_window_and_uploads_once_without_private_output(): import json - from notifications.digest_snapshot.digest_archive import emit + from quant_platform_kit.notifications.digest_archive import emit env = dict(QSL_ROUTINE_NOTIFICATION_MODE="daily_digest", RUNTIME_TARGET_ENABLED="true", RUNTIME_TARGET_JSON=json.dumps(TARGET), DIGEST_STORE_TARGET="synthetic-slot", DIGEST_OPAQUE_ACCOUNT_UID="synthetic-uid", DIGEST_TARGET_ID="synthetic-target", @@ -89,7 +89,7 @@ def failed_upload(*args): def test_configured_closed_session_proves_idle_without_inventing_runs(monkeypatch): import json import quant_platform_kit.common.runtime_heartbeat_policy as policy - from notifications.digest_snapshot.digest_archive import emit + from quant_platform_kit.notifications.digest_archive import emit env = dict(QSL_ROUTINE_NOTIFICATION_MODE="daily_digest", RUNTIME_TARGET_ENABLED="true", RUNTIME_TARGET_JSON=json.dumps(TARGET), DIGEST_STORE_TARGET="synthetic-slot", DIGEST_OPAQUE_ACCOUNT_UID="synthetic-uid", DIGEST_TARGET_ID="synthetic-target", @@ -109,7 +109,7 @@ def test_configured_closed_session_proves_idle_without_inventing_runs(monkeypatc def test_multi_target_bindings_use_each_explicit_enablement_without_a_global_target(): import json - from notifications.digest_snapshot.digest_archive import emit + from quant_platform_kit.notifications.digest_archive import emit result = emit({"QSL_ROUTINE_NOTIFICATION_MODE": "daily_digest", "DIGEST_TARGET_BINDINGS_JSON": json.dumps([{"RUNTIME_TARGET_ENABLED": "false"}])}, read=lambda args: pytest.fail("disabled target must not be read"), diff --git a/tests/test_unified_digest_routing.py b/tests/test_unified_digest_routing.py index 09b42b6..231ce9c 100644 --- a/tests/test_unified_digest_routing.py +++ b/tests/test_unified_digest_routing.py @@ -4,7 +4,7 @@ import pytest -from notifications.digest_snapshot.digest_routing import defer_routine_cycle_to_digest +from quant_platform_kit.notifications.digest_routing import defer_routine_cycle_to_digest def test_default_off_and_explicit_opt_in(monkeypatch): From 63407b30bf5f01ab5144efab729c0be44c72aac3 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sat, 24 Oct 2026 03:36:09 +0800 Subject: [PATCH 2/2] docs(notifications): align display-name and QPK source references Record that display name resolution comes from quant_platform_kit.common.display_names installed with QPK pin 5181f2d6, that the local notifications/display_names.py/json copies were removed, and that the notification modules come from the same QPK pin. Documentation only; no code or configuration change. Co-Authored-By: Claude Code --- docs/display-names.md | 2 +- docs/naming-trading-diff.zh-CN.md | 4 ++-- docs/unified-daily-notification.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/display-names.md b/docs/display-names.md index c5a6d74..c4bd158 100644 --- a/docs/display-names.md +++ b/docs/display-names.md @@ -2,7 +2,7 @@ QRS `platform-config.json` owns public strategy/platform labels. `display-names.config.json` owns display-only account aliases, lanes, legacy labels and aliases. `python/scripts/build_display_names.py` combines them into one fixed mapping; it never changes trading catalog metadata or runtime configuration hashes. -Run `python3 python/scripts/build_display_names.py --repos-root /path/to/source/checkouts` from QRS, then `--check` with the same arguments. Review and commit each repository separately. Consumer `display_names.py/json` files are generated snapshots, not independently maintained maps. They avoid upgrading pinned trading dependencies just for labels. No runtime fetch is performed. Refresh the snapshots together when labels change. +Run `python3 python/scripts/build_display_names.py --repos-root /path/to/source/checkouts` from QRS, then `--check` with the same arguments. Review and commit each repository separately. Display name resolution is provided by `quant_platform_kit.common.display_names`, installed with QPK pin `5181f2d6`; this repository keeps no local `display_names.py/json` copy. No runtime fetch is performed; label updates reach consumers through the QPK pin. Python: optional `QSL_DISPLAY_NAMES_JSON` JSON object. Console: optional `VITE_QSL_DISPLAY_NAMES_JSON` at build time. Example (synthetic): `{"strategies":{"custom_profile":{"zh":"ETF-轮动-202610","en":"ETF-Rotation-202610"}},"accounts":{"binance":{"crypto_combo":"live"}}}`. Invalid JSON falls back to defaults; unknown labels retain the supplied fallback. Set private aliases only in the existing protected configuration, never in public source or PR bodies. diff --git a/docs/naming-trading-diff.zh-CN.md b/docs/naming-trading-diff.zh-CN.md index 57eb934..0afb943 100644 --- a/docs/naming-trading-diff.zh-CN.md +++ b/docs/naming-trading-diff.zh-CN.md @@ -2,7 +2,7 @@ 基线:`c3f4a2122aa535594703279f9a4a1b4222d57586`。作者自审与 AST 检查,不声称独立人工审查。 -只改通知显示名解析、固定显示配置副本、测试和说明。账户 key、策略 profile、RuntimeTarget、fingerprint、release/continuity binding、Secret 名称、存储 key、通知去重键均不改。交易依赖及锁文件未改;SOXL 0.679 / 总仓位 0.97 / 现金 0.03 未改。不部署交易服务。 +只改通知显示名解析、固定显示配置副本、测试和说明。当前显示名解析由 QPK 提供(`quant_platform_kit.common.display_names`,随 QPK pin `5181f2d6` 安装),仓库内 `notifications/display_names.py/json` 副本已删除;下表保留基线时的文件记录。账户 key、策略 profile、RuntimeTarget、fingerprint、release/continuity binding、Secret 名称、存储 key、通知去重键均不改。交易依赖及锁文件未改;SOXL 0.679 / 总仓位 0.97 / 现金 0.03 未改。不部署交易服务。 现有源码中的券商、订单、风险、状态和报告持久化调用表达式及其参数比较结果: @@ -11,4 +11,4 @@ | `notifications/display_names.py` | 0 | 是 | | `notifications/telegram.py` | 0 | 是 | -新增解析器只读同目录的生成 JSON 及可选显示环境配置,不联网、不调用券商、不写持久状态。显示值不得作为机器标识再输入交易调用。固定副本来自 QRS 的生成器,避免只为改名升级交易依赖。PR 保持 draft;即使 CI 通过,也未批准实际运行版本采用。 +新增解析器只读同目录的生成 JSON 及可选显示环境配置,不联网、不调用券商、不写持久状态。显示值不得作为机器标识再输入交易调用。该 PR 的固定副本来自 QRS 的生成器,避免只为改名升级交易依赖。PR 保持 draft;即使 CI 通过,也未批准实际运行版本采用。 diff --git a/docs/unified-daily-notification.md b/docs/unified-daily-notification.md index f99692c..eecfe0b 100644 --- a/docs/unified-daily-notification.md +++ b/docs/unified-daily-notification.md @@ -6,7 +6,7 @@ immediate confirmed order notices. Failed, pending, uncertain, risk, persistence and authentication outcomes retain immediate alerts. Healthy idle cycles and routine plugin observations do not produce individual pushes. -QPK source reference: `4cc5e55251269923771f90cd661bb62a5cfdf0cd` (notification modules only). +QPK source reference: `5181f2d6a44b935f7b82c46bc9e77439297e18d5` (notification modules; same as the QPK pin). The notification modules are imported directly from the QPK dependency pinned in `pyproject.toml` and `uv.lock`; no vendored QPK notification snapshot is kept in