diff --git a/notifications/unified_adapter.py b/notifications/unified_adapter.py index f74a5f5b..c23a974f 100644 --- a/notifications/unified_adapter.py +++ b/notifications/unified_adapter.py @@ -164,6 +164,14 @@ def comparable(v): **({"block_reason": block_reason} if block_reason else {})} if account_snapshot is not None: previous_snapshot = (prior or {}).get("account_snapshot", account_snapshot) + empty_stock = {"holdings_scope": "stocks_only", "holdings": [], "holdings_complete": True} + for enriched, legacy in ((account_snapshot, previous_snapshot), (previous_snapshot, account_snapshot)): + # The old archive projection dropped this explicit empty list. + if (isinstance(enriched, dict) + and all(enriched.get(key) == value for key, value in empty_stock.items()) + and legacy == {key: value for key, value in enriched.items() if key not in empty_stock}): + previous_snapshot = account_snapshot = enriched + break record["account_snapshot"] = account_snapshot if previous_snapshot == account_snapshot else None events[fact["dedupe_key"]] = record changed = events != old["events"] @@ -910,6 +918,9 @@ def _archived_account_snapshot(report, selector, started, now): {"symbol": row["symbol"], "quantity": float(row["quantity"]), "asset_type": "stock"} for row in positions ]) + elif positions == []: + # The account-facts projector admits an empty list only with explicit stock coverage. + snapshot.update(holdings_scope=POSITIONS_SCOPE, holdings=[], holdings_complete=True) return snapshot if len(snapshot) > 1 else None diff --git a/tests/test_notification_qrs_roundtrip.py b/tests/test_notification_qrs_roundtrip.py index 849d4fda..e952b8e4 100644 --- a/tests/test_notification_qrs_roundtrip.py +++ b/tests/test_notification_qrs_roundtrip.py @@ -50,7 +50,7 @@ def archive_report(run, orders=()): return source -def export_archive(monkeypatch, reports, *, seed_unknown=False, target=TARGET): +def export_archive(monkeypatch, reports, *, seed_unknown=False, target=TARGET, replay=None): state, candidates = StateStore(), CandidateStore() monkeypatch.setattr(adapter, "_state_store", lambda env=None: state) monkeypatch.setattr(adapter, "GCSCandidateStore", lambda *args, **kwargs: candidates) @@ -72,6 +72,8 @@ def read(args): monkeypatch.setenv(adapter.FLAG, "true") for key, value in env.items(): monkeypatch.setenv(key, value) + if replay: + adapter.persist_archived_reports([replay], env, now=NOW + timedelta(hours=2, minutes=21)) if reports[0]["summary"]["execution_status"] == "completed": assert adapter.publish_monitor(kind="healthy_heartbeat", report=reports[0], now=NOW + timedelta(hours=2, minutes=21), send=lambda: pytest.fail("healthy monitor must not send")) @@ -139,6 +141,34 @@ def test_archive_exports_only_in_run_snapshots_for_the_bound_account(qrs, monkey assert "account_daily_facts" not in rows[0] +@pytest.mark.parametrize("complete", [True, False]) +def test_archive_retains_empty_stock_inventory_only_with_explicit_coverage(qrs, monkeypatch, tmp_path, complete): + source, target = archive_snapshot_report("U0000001") + source["summary"]["account_facts"].update(broker_reported_positions=[], + broker_reported_positions_complete=complete) + payload = export_archive(monkeypatch, [source], seed_unknown=True, target=target) + rows, _ = intake(qrs, tmp_path, payload) + snapshot, = rows[0]["account_daily_facts"]["snapshots"] + assert snapshot["equity"] == 125.5 + if complete: + assert snapshot["holdings"] == [] and snapshot["holdings_scope"] == "stocks_only" + assert snapshot["holdings_complete"] is True + else: + assert "holdings" not in snapshot and "holdings_complete" not in snapshot + + +def test_empty_stock_snapshot_can_enrich_legacy_archive_observation(qrs, monkeypatch, tmp_path): + source, target = archive_snapshot_report("U0000001") + source["summary"]["account_facts"]["broker_reported_positions"] = [] + enriched = deepcopy(source) + enriched["summary"]["account_facts"]["broker_reported_positions_complete"] = True + latest = export_archive(monkeypatch, [source], seed_unknown=True, target=target, replay=enriched) + rows, _ = intake(qrs, tmp_path, latest) + assert len(rows) == 1 + assert rows[0]["order_count"] == 0 + assert rows[0]["account_daily_facts"]["snapshots"][0]["holdings"] == [] + + def test_archive_snapshot_reaches_the_scheduled_account_renderer(monkeypatch): qrs_checkout = os.environ.get("QRS_DAILY_REPORT_CHECKOUT") if not qrs_checkout: