Files
trex-core/tests/application/test_verify.py
T

439 lines
16 KiB
Python

"""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"]