"""verify_slot_reconcile tests (REQ-106, BAT-051, dcy-034). TDD coverage: - AC-1: C-case warned + ledger matches broker → cleared - AC-2: C-case warned + ledger != broker → rejected + reason - AC-3: slot qty is NOT changed (read-centric invariant) - AC-4: slot with no warning → not_warned (idempotent, exit 0) - AC-5: --json output format (tested in CLI layer) - AC-6: no --portfolio-id → all C-warned slots (CLI layer) - OQ-C: B-warned slot → not_c_case + 0 exit (test function result.result) - ReconcileWarningStore.get_warning_info: returns correct dict """ from __future__ import annotations from uuid import uuid4 from trex_core.application.broker_port import BalanceItem from trex_core.application.portfolio import ( InMemoryReconcileWarningStore, PortfolioItem, SnapshotStore, ) from trex_core.application.reconciliation import verify_slot_reconcile from trex_core.domain.slot import SlotState from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository from trex_core.domain.slot.models import Slot INSTANCE = "test-instance" def _make_slot(status: SlotState = SlotState.HOLDING, qty: float = 10.0, symbol: str = "005930", pid: str = "pf-005930") -> Slot: return Slot( slot_id=uuid4(), status=status, symbol=symbol, qty=qty, portfolio_id=pid, instance_key=INSTANCE, ) class FakeBroker: def __init__(self, balances: dict[str, int]): self._b = balances def name(self): return "fake" def submit_order(self, req): ... def poll_fill(self, *args, **kw): ... def cancel_order(self, *args, **kw): ... def get_current_price(self, sym): return None def get_balance(self) -> list[BalanceItem]: return [BalanceItem(symbol=sym, hldg_qty=qty) for sym, qty in self._b.items() if qty > 0] def _setup_snapshot(portfolio_id: str, asset_qty: int, symbol: str = "005930") -> SnapshotStore: store = SnapshotStore() store.swap([PortfolioItem( portfolio_id=portfolio_id, symbol=symbol, cash_balance=0.0, asset_quantity=float(asset_qty), )]) return store def _run_verify(slot: Slot, hub_qty: int, broker_qty: int, case_code: str = "C", warn_store=None) -> dict: repo = InMemorySlotRepository() repo.add_slot(slot) pid = slot.portfolio_id or "pf-005930" ws = warn_store if warn_store is not None else InMemoryReconcileWarningStore() ws.set_warned(str(slot.slot_id), portfolio_id=pid, case_code=case_code) store = _setup_snapshot(pid, hub_qty, symbol=slot.symbol or "005930") broker = FakeBroker({slot.symbol or "005930": broker_qty}) result = verify_slot_reconcile( portfolio_id=pid, store=store, broker=broker, slot_repo=repo, instance_key=INSTANCE, warn_store=ws, ) return result, repo, ws # --------------------------------------------------------------------------- # AC-1: C-case warned, ledger matches broker → cleared # --------------------------------------------------------------------------- def test_verify_c_case_match_clears_warning(): """AC-1: hub_qty == broker_qty → 'cleared', warning removed.""" slot = _make_slot(qty=10.0) result, repo, ws = _run_verify(slot, hub_qty=10, broker_qty=10, case_code="C") assert result["result"] == "cleared" assert not ws.is_warned(str(slot.slot_id)) assert result["hub_qty"] == 10 assert result["broker_qty"] == 10 assert result["reason"] is None # --------------------------------------------------------------------------- # AC-2: C-case warned, ledger != broker → rejected + reason (no forced clear) # --------------------------------------------------------------------------- def test_verify_c_case_mismatch_rejected(): """AC-2: hub_qty != broker_qty → 'rejected', warning NOT cleared, reason shown.""" slot = _make_slot(qty=10.0) result, repo, ws = _run_verify(slot, hub_qty=5, broker_qty=10, case_code="C") assert result["result"] == "rejected" assert ws.is_warned(str(slot.slot_id)) # still warned assert result["hub_qty"] == 5 assert result["broker_qty"] == 10 assert result["reason"] is not None assert "hub_qty" in result["reason"] or "5" in result["reason"] # --------------------------------------------------------------------------- # AC-3: slot qty is NOT changed (read-centric invariant) # --------------------------------------------------------------------------- def test_verify_does_not_change_slot_qty_on_clear(): """AC-3: verify does not modify slot qty when clearing (read-centric).""" slot = _make_slot(qty=10.0) result, repo, _ = _run_verify(slot, hub_qty=10, broker_qty=10, case_code="C") refreshed = repo.fetch_slot(slot.slot_id) assert refreshed.qty == 10.0 # unchanged assert result["result"] == "cleared" def test_verify_does_not_change_slot_qty_on_reject(): """AC-3: verify does not modify slot qty when rejecting.""" slot = _make_slot(qty=10.0) result, repo, _ = _run_verify(slot, hub_qty=5, broker_qty=10, case_code="C") refreshed = repo.fetch_slot(slot.slot_id) assert refreshed.qty == 10.0 # unchanged assert result["result"] == "rejected" # --------------------------------------------------------------------------- # AC-4: no warning → not_warned (idempotent, exit 0) # --------------------------------------------------------------------------- def test_verify_not_warned_returns_not_warned(): """AC-4: slot with no active reconcile-warning → 'not_warned' (idempotent).""" slot = _make_slot(qty=10.0) repo = InMemorySlotRepository() repo.add_slot(slot) ws = InMemoryReconcileWarningStore() # no warning set store = _setup_snapshot("pf-005930", 10) broker = FakeBroker({"005930": 10}) result = verify_slot_reconcile( portfolio_id="pf-005930", store=store, broker=broker, slot_repo=repo, instance_key=INSTANCE, warn_store=ws, ) assert result["result"] == "not_warned" assert result["slot_id"] == str(slot.slot_id) # --------------------------------------------------------------------------- # OQ-C: B-warned slot → not_c_case, 0 exit # --------------------------------------------------------------------------- def test_verify_b_warned_slot_returns_not_c_case(): """OQ-C: verify on B-warned slot returns 'not_c_case' (use sync for B).""" slot = _make_slot(qty=10.0) result, _, ws = _run_verify(slot, hub_qty=10, broker_qty=10, case_code="B") assert result["result"] == "not_c_case" # warning still present (not_c_case does not clear) assert ws.is_warned(str(slot.slot_id)) # reason explains the situation assert "sync" in result["reason"].lower() or "B" in result["reason"] # --------------------------------------------------------------------------- # slot_not_found # --------------------------------------------------------------------------- def test_verify_slot_not_found(): """No slot for portfolio_id → 'slot_not_found'.""" repo = InMemorySlotRepository() ws = InMemoryReconcileWarningStore() store = _setup_snapshot("pf-999", 10) broker = FakeBroker({"005930": 10}) result = verify_slot_reconcile( portfolio_id="pf-999", store=store, broker=broker, slot_repo=repo, instance_key=INSTANCE, warn_store=ws, ) assert result["result"] == "slot_not_found" assert result["slot_id"] is None # --------------------------------------------------------------------------- # ReconcileWarningStore.get_warning_info (InMemory) # --------------------------------------------------------------------------- def test_get_warning_info_returns_dict(): """get_warning_info returns dict with case_code/portfolio_id/actor/reason.""" ws = InMemoryReconcileWarningStore() ws.set_warned("slot-1", portfolio_id="pf-001", case_code="C", actor="reconcile", reason="hub_qty=5 broker_qty=10") info = ws.get_warning_info("slot-1") assert info is not None assert info["case_code"] == "C" assert info["portfolio_id"] == "pf-001" assert info["actor"] == "reconcile" assert "hub_qty" in info["reason"] def test_get_warning_info_returns_none_for_missing(): """get_warning_info returns None for unknown slot_id.""" ws = InMemoryReconcileWarningStore() assert ws.get_warning_info("nonexistent") is None def test_get_warning_info_after_clear_returns_none(): """get_warning_info returns None after clear_warned.""" ws = InMemoryReconcileWarningStore() ws.set_warned("s1", portfolio_id="p1", case_code="B") ws.clear_warned("s1") assert ws.get_warning_info("s1") is None # --------------------------------------------------------------------------- # verify with mismatch between snapshot and broker (empty snapshot) # --------------------------------------------------------------------------- def test_verify_empty_snapshot_treats_hub_qty_as_zero(): """When snapshot has no entry for portfolio_id, hub_qty defaults to 0. If broker has holdings, this will be a mismatch → rejected.""" slot = _make_slot(qty=10.0) repo = InMemorySlotRepository() repo.add_slot(slot) ws = InMemoryReconcileWarningStore() ws.set_warned(str(slot.slot_id), portfolio_id="pf-005930", case_code="C") store = SnapshotStore() # empty — no swap broker = FakeBroker({"005930": 10}) result = verify_slot_reconcile( portfolio_id="pf-005930", store=store, broker=broker, slot_repo=repo, instance_key=INSTANCE, warn_store=ws, ) # hub_qty=0, broker_qty=10 → mismatch → rejected assert result["result"] == "rejected" assert result["hub_qty"] == 0 assert result["broker_qty"] == 10 # --------------------------------------------------------------------------- # AC-0023 comment 3853379970: B-case warning preserved; non-holding C-case cleared # --------------------------------------------------------------------------- def test_verify_c_case_clear_preserves_b_case_warning(): """B-case warning for same symbol must NOT be cleared by a C-case verify clear.""" sym = "005930" # Slot A: HOLDING, C-case warning (should be cleared) slot_c = _make_slot(status=SlotState.HOLDING, symbol=sym, pid="pf-c") # Slot B: HOLDING, B-case warning (must be preserved) slot_b = _make_slot(status=SlotState.HOLDING, symbol=sym, pid="pf-b") repo = InMemorySlotRepository() repo.add_slot(slot_c) repo.add_slot(slot_b) ws = InMemoryReconcileWarningStore() ws.set_warned(str(slot_c.slot_id), portfolio_id="pf-c", case_code="C") ws.set_warned(str(slot_b.slot_id), portfolio_id="pf-b", case_code="B") # Aggregate hub qty across both portfolios = 10 store = SnapshotStore() store.swap([ PortfolioItem(portfolio_id="pf-c", symbol=sym, cash_balance=0.0, asset_quantity=5.0), PortfolioItem(portfolio_id="pf-b", symbol=sym, cash_balance=0.0, asset_quantity=5.0), ]) broker = FakeBroker({sym: 10}) result = verify_slot_reconcile( portfolio_id="pf-c", store=store, broker=broker, slot_repo=repo, instance_key=INSTANCE, warn_store=ws, ) assert result["result"] == "cleared" # C-case slot cleared assert not ws.is_warned(str(slot_c.slot_id)) # B-case slot untouched assert ws.is_warned(str(slot_b.slot_id)) b_info = ws.get_warning_info(str(slot_b.slot_id)) assert b_info is not None and b_info["case_code"] == "B" def test_verify_c_case_clear_on_slot_with_b_and_c_keeps_b(): sym = "005930" slot = _make_slot(status=SlotState.HOLDING, symbol=sym, pid="pf-c") repo = InMemorySlotRepository() repo.add_slot(slot) ws = InMemoryReconcileWarningStore() slot_id = str(slot.slot_id) ws.set_warned(slot_id, portfolio_id="pf-c", case_code="B") ws.set_warned(slot_id, portfolio_id="pf-c", case_code="C") store = SnapshotStore() store.swap([ PortfolioItem(portfolio_id="pf-c", symbol=sym, cash_balance=0.0, asset_quantity=10.0), ]) broker = FakeBroker({sym: 10}) result = verify_slot_reconcile( portfolio_id="pf-c", store=store, broker=broker, slot_repo=repo, instance_key=INSTANCE, warn_store=ws, ) assert result["result"] == "cleared" assert ws.is_warned(slot_id) is True info = ws.get_warning_info(slot_id) assert info is not None assert info["case_codes"] == ["B"] def test_verify_c_case_clear_non_holding_slot(): """A non-HOLDING slot with C-case warning for the same symbol must also be cleared.""" sym = "005930" slot_holding = _make_slot(status=SlotState.HOLDING, symbol=sym, pid="pf-hold") # Simulate a slot that is INACTIVE (e.g. after sell filled) but still has a C-case warning slot_inactive = _make_slot(status=SlotState.INACTIVE, symbol=sym, pid="pf-inact") repo = InMemorySlotRepository() repo.add_slot(slot_holding) repo.add_slot(slot_inactive) ws = InMemoryReconcileWarningStore() ws.set_warned(str(slot_holding.slot_id), portfolio_id="pf-hold", case_code="C") ws.set_warned(str(slot_inactive.slot_id), portfolio_id="pf-inact", case_code="C") store = SnapshotStore() store.swap([ PortfolioItem(portfolio_id="pf-hold", symbol=sym, cash_balance=0.0, asset_quantity=10.0), ]) broker = FakeBroker({sym: 10}) result = verify_slot_reconcile( portfolio_id="pf-hold", store=store, broker=broker, slot_repo=repo, instance_key=INSTANCE, warn_store=ws, ) assert result["result"] == "cleared" assert not ws.is_warned(str(slot_holding.slot_id)) assert not ws.is_warned(str(slot_inactive.slot_id)) # --------------------------------------------------------------------------- # AC-0023 comment 3853379979: partial clear failure → clear_failed, not cleared # --------------------------------------------------------------------------- class _FailAfterNStore(InMemoryReconcileWarningStore): """Raises on the Nth+ call to clear_warned (1-indexed).""" def __init__(self, fail_after: int = 1) -> None: super().__init__() self._calls = 0 self._fail_after = fail_after def clear_warned(self, slot_id: str, *, case_code: str | None = None) -> None: self._calls += 1 if self._calls > self._fail_after: raise RuntimeError("simulated DB error") super().clear_warned(slot_id, case_code=case_code) def test_verify_partial_clear_failure_returns_clear_failed(): """If clear_warned raises mid-loop, result must be 'clear_failed', not 'cleared'.""" sym = "005930" slot1 = _make_slot(status=SlotState.HOLDING, symbol=sym, pid="pf-1") slot2 = _make_slot(status=SlotState.HOLDING, symbol=sym, pid="pf-2") repo = InMemorySlotRepository() repo.add_slot(slot1) repo.add_slot(slot2) # Fails on 2nd clear_warned call ws = _FailAfterNStore(fail_after=1) ws.set_warned(str(slot1.slot_id), portfolio_id="pf-1", case_code="C") ws.set_warned(str(slot2.slot_id), portfolio_id="pf-2", case_code="C") store = SnapshotStore() store.swap([ PortfolioItem(portfolio_id="pf-1", symbol=sym, cash_balance=0.0, asset_quantity=10.0), PortfolioItem(portfolio_id="pf-2", symbol=sym, cash_balance=0.0, asset_quantity=0.0), ]) broker = FakeBroker({sym: 10}) result = verify_slot_reconcile( portfolio_id="pf-1", store=store, broker=broker, slot_repo=repo, instance_key=INSTANCE, warn_store=ws, ) assert result["result"] == "clear_failed" assert result["hub_qty"] == 10 assert result["broker_qty"] == 10 assert result["reason"] is not None and "warn_store" in result["reason"]