feat(core-B): 운영자 sync/verify CLI + cold-start onboarding (BAT-051, REQ-105/106, PR #32)
dcy-034 마지막 조각. sync(슬롯 qty←브로커+warning해제+OQ-A 전이엣지+FR-7 평단seed)/verify(C 재대조·강제해제없음). core-A QC minor 2건 정리 동봉. QC(opus) PASS, 683 passed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VHtM5Ws1HKCKQ4JGi6GRb5
This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
"""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
|
||||
|
||||
import pytest
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user