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

659 lines
25 KiB
Python

"""Blocking reconcile B/C cases + gate tests (REQ-103, REQ-104, REQ-107, REQ-108, BAT-050)."""
from __future__ import annotations
import logging
from trex_core.application.broker_port import BalanceItem
from trex_core.application.intents import BuyIntent, IntentService, SellIntent
from trex_core.application.messaging import RpcRequest
from trex_core.application.messaging_handlers import make_slots_rpc_handler
from trex_core.application.portfolio import (
InMemoryPauseStore,
InMemoryReconcileWarningStore,
PortfolioItem,
SnapshotStore,
slot_to_dict,
)
from trex_core.application.reconciliation import (
reconcile_core_holdings,
reconcile_positions,
)
from trex_core.domain.slot import SlotService, SlotState
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
class FakeBroker:
def __init__(self, balances):
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_balance(self): return list(self._b)
class InMemoryIntentStore:
"""Minimal in-memory intent store for tests."""
def __init__(self):
self._db: dict = {}
def try_insert(self, intent_id, intent_type, payload):
if intent_id in self._db:
return False
self._db[intent_id] = {"status": None, "slot_id": None, "reason": None}
return True
def get(self, intent_id):
rec = self._db.get(intent_id)
if rec is None:
return None
class R:
def __init__(self, d): self.__dict__.update(d)
r = R(rec)
r.intent_id = intent_id
return r
def finalize(self, intent_id, status, slot_id, reason):
self._db[intent_id] = {"status": status, "slot_id": slot_id, "reason": reason}
def _make_holding_slot(repo, svc, instance_key="default", portfolio_id="pf-A", symbol="005930", qty=10):
"""Helper: creates universe, puts slot in HOLDING state with given qty."""
repo.reconcile_universe(instance_key, {portfolio_id: symbol})
slot = repo.find_slot_by_portfolio(instance_key, portfolio_id)
pending = svc.transit_slot(
slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
reason="buy", actor="test", symbol=symbol,
)
holding = svc.transit_slot(
pending.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
reason="filled", actor="test", avg_price=100.0, qty=float(qty),
)
return holding
def _store_with(items):
store = SnapshotStore()
store.swap(items)
return store
# ===========================================================================
# REQ-103 AC-1: B case (slot qty != broker qty) => warn_store.set_warned(case_code='B')
# ===========================================================================
def test_reconcile_core_holdings_b_case_sets_warned():
repo = InMemorySlotRepository()
svc = SlotService(repo)
warn_store = InMemoryReconcileWarningStore()
slot = _make_holding_slot(repo, svc, qty=10)
# Broker has different qty (5 != 10) => B case
broker = FakeBroker([BalanceItem(symbol="005930", hldg_qty=5)])
decisions = reconcile_core_holdings(
slot_repo=repo, slot_service=svc, broker=broker,
instance_key="default", warn_store=warn_store,
)
slot_id_str = str(slot.slot_id)
assert decisions[slot_id_str] == "qty_warning"
assert warn_store.is_warned(slot_id_str) is True
# REQ-103 AC-1: B case stores case_code='B'
def test_reconcile_core_holdings_b_case_code():
repo = InMemorySlotRepository()
svc = SlotService(repo)
warn_store = InMemoryReconcileWarningStore()
slot = _make_holding_slot(repo, svc, qty=10)
broker = FakeBroker([BalanceItem(symbol="005930", hldg_qty=3)])
reconcile_core_holdings(
slot_repo=repo, slot_service=svc, broker=broker,
instance_key="default", warn_store=warn_store,
)
assert warn_store.list_warned() == {str(slot.slot_id)}
# REQ-103 AC-3: matching qty => warn_store NOT set
def test_reconcile_core_holdings_matching_qty_no_warn(caplog):
repo = InMemorySlotRepository()
svc = SlotService(repo)
warn_store = InMemoryReconcileWarningStore()
_make_holding_slot(repo, svc, qty=10)
broker = FakeBroker([BalanceItem(symbol="005930", hldg_qty=10)]) # match
decisions = reconcile_core_holdings(
slot_repo=repo, slot_service=svc, broker=broker,
instance_key="default", warn_store=warn_store,
)
assert warn_store.list_warned() == set() # S-4: no false positive
assert all(v == "ok" for v in decisions.values() if v != "unmanaged_position")
# REQ-103 AC-4: A case (broker 0) => ERROR transition, NOT warn_store
def test_reconcile_core_holdings_a_case_error_not_warned():
repo = InMemorySlotRepository()
svc = SlotService(repo)
warn_store = InMemoryReconcileWarningStore()
slot = _make_holding_slot(repo, svc, qty=10)
broker = FakeBroker([]) # broker flat (qty=0)
decisions = reconcile_core_holdings(
slot_repo=repo, slot_service=svc, broker=broker,
instance_key="default", warn_store=warn_store,
)
slot_id_str = str(slot.slot_id)
assert decisions[slot_id_str] == "error"
assert warn_store.is_warned(slot_id_str) is False # A uses error, not warn_store
# REQ-103 AC-5: warn_store=None => backward compat, no exception
def test_reconcile_core_holdings_no_warn_store_backward_compat():
repo = InMemorySlotRepository()
svc = SlotService(repo)
_make_holding_slot(repo, svc, qty=10)
broker = FakeBroker([BalanceItem(symbol="005930", hldg_qty=5)]) # mismatch
decisions = reconcile_core_holdings(
slot_repo=repo, slot_service=svc, broker=broker,
instance_key="default",
warn_store=None, # no store
)
# Still returns qty_warning, no exception
assert any(v == "qty_warning" for v in decisions.values())
# ===========================================================================
# REQ-103 AC-2: C case (hub qty != broker qty) => warn_store.set_warned(case_code='C')
# ===========================================================================
def test_reconcile_positions_c_case_sets_warned():
repo = InMemorySlotRepository()
svc = SlotService(repo)
warn_store = InMemoryReconcileWarningStore()
slot = _make_holding_slot(repo, svc, qty=10, portfolio_id="pf-A")
store = _store_with([
PortfolioItem(portfolio_id="pf-A", symbol="005930", cash_balance=0, asset_quantity=10),
])
broker = FakeBroker([BalanceItem(symbol="005930", hldg_qty=7)]) # hub=10, broker=7 => C
decisions = reconcile_positions(
store=store, broker=broker,
slot_repo=repo, instance_key="default",
warn_store=warn_store,
)
assert decisions["pf-A"] == "warning"
assert warn_store.is_warned(str(slot.slot_id)) is True
# REQ-103 AC-3: C case matching => no warn
def test_reconcile_positions_c_case_matching_no_warn():
repo = InMemorySlotRepository()
svc = SlotService(repo)
warn_store = InMemoryReconcileWarningStore()
_make_holding_slot(repo, svc, qty=10, portfolio_id="pf-A")
store = _store_with([
PortfolioItem(portfolio_id="pf-A", symbol="005930", cash_balance=0, asset_quantity=10),
])
broker = FakeBroker([BalanceItem(symbol="005930", hldg_qty=10)]) # match
decisions = reconcile_positions(
store=store, broker=broker,
slot_repo=repo, instance_key="default",
warn_store=warn_store,
)
assert decisions["pf-A"] == "ok"
assert warn_store.list_warned() == set() # S-4: no false positive
# REQ-103 AC-5: C case with warn_store=None => backward compat
def test_reconcile_positions_no_warn_store_backward_compat():
store = _store_with([
PortfolioItem(portfolio_id="pf-A", symbol="005930", cash_balance=0, asset_quantity=10),
])
broker = FakeBroker([BalanceItem(symbol="005930", hldg_qty=7)])
decisions = reconcile_positions(store=store, broker=broker, warn_store=None)
# in-memory warning still set on store
assert decisions["pf-A"] == "warning"
assert store.warnings() == {"pf-A"}
# ===========================================================================
# REQ-104 AC-1: reconcile-warned slot => buy intent rejected
# ===========================================================================
def test_intent_service_buy_rejected_when_reconcile_warned():
repo = InMemorySlotRepository()
svc = SlotService(repo)
warn_store = InMemoryReconcileWarningStore()
intent_store = InMemoryIntentStore()
repo.reconcile_universe("default", {"pf-A": "005930"})
slot = repo.find_slot_by_portfolio("default", "pf-A")
warn_store.set_warned(str(slot.slot_id), portfolio_id="pf-A", case_code="B")
intent_svc = IntentService(
svc, repo, intent_store,
instance_key="default",
warn_store=warn_store,
)
d = intent_svc.submit_buy(BuyIntent(
intent_id="b-001",
slot_id=slot.slot_id,
entry_price=100.0,
qty=10.0,
))
assert d.status == "rejected"
assert d.reason == "reconcile_warned"
# REQ-104 AC-2: reconcile-warned HOLDING slot => sell intent rejected (external path)
def test_intent_service_sell_rejected_when_reconcile_warned():
repo = InMemorySlotRepository()
svc = SlotService(repo)
warn_store = InMemoryReconcileWarningStore()
intent_store = InMemoryIntentStore()
slot = _make_holding_slot(repo, svc, qty=10)
warn_store.set_warned(str(slot.slot_id), portfolio_id="pf-A", case_code="B")
intent_svc = IntentService(
svc, repo, intent_store,
instance_key="default",
warn_store=warn_store,
)
d = intent_svc.submit_sell(SellIntent(
intent_id="s-001",
slot_id=slot.slot_id,
reason="strategy_exit",
))
assert d.status == "rejected"
assert d.reason == "reconcile_warned"
# REQ-104 AC-3: no warn, no pause => buy/sell admitted normally
def test_intent_service_no_warn_admitted_normally():
repo = InMemorySlotRepository()
svc = SlotService(repo)
warn_store = InMemoryReconcileWarningStore()
intent_store = InMemoryIntentStore()
repo.reconcile_universe("default", {"pf-A": "005930"})
slot = repo.find_slot_by_portfolio("default", "pf-A")
intent_svc = IntentService(
svc, repo, intent_store,
instance_key="default",
warn_store=warn_store,
)
d = intent_svc.submit_buy(BuyIntent(
intent_id="b-002",
slot_id=slot.slot_id,
entry_price=100.0,
qty=10.0,
))
assert d.status == "admitted"
# REQ-104: warn_store=None => gate skipped, backward compat
def test_intent_service_no_warn_store_backward_compat():
repo = InMemorySlotRepository()
svc = SlotService(repo)
intent_store = InMemoryIntentStore()
repo.reconcile_universe("default", {"pf-A": "005930"})
slot = repo.find_slot_by_portfolio("default", "pf-A")
intent_svc = IntentService(
svc, repo, intent_store,
instance_key="default",
warn_store=None, # no gate
)
d = intent_svc.submit_buy(BuyIntent(
intent_id="b-003",
slot_id=slot.slot_id,
entry_price=100.0,
qty=10.0,
))
assert d.status == "admitted"
# ===========================================================================
# REQ-104: safety-sell / kill-switch / watchdog bypass (OQ-B 확정)
# Internal path: SlotService.sell() / transit_slot() directly — NOT IntentService
# ===========================================================================
def test_safety_sell_bypasses_reconcile_warning_gate():
"""Safety-sell calls SlotService directly — reconcile_warned slot can still be sold."""
repo = InMemorySlotRepository()
svc = SlotService(repo)
warn_store = InMemoryReconcileWarningStore()
slot = _make_holding_slot(repo, svc, qty=10)
warn_store.set_warned(str(slot.slot_id), portfolio_id="pf-A", case_code="B")
# Safety-sell path: SlotService.sell() directly (no IntentService gate)
# Verify that transit_slot HOLDING -> PENDING_SELL succeeds regardless of warn_store.
updated = svc.transit_slot(
slot.slot_id,
SlotState.HOLDING,
SlotState.PENDING_SELL,
reason="safety_sell",
actor="trex-safety-sell",
)
assert updated.status == SlotState.PENDING_SELL
# reconcile_warned flag unchanged — sell went through
assert warn_store.is_warned(str(slot.slot_id)) is True
# ===========================================================================
# REQ-104 AC-4/5/6/7: slots RPC reconcile_warned field
# ===========================================================================
def _wire_with_warn(warned_slot_ids=None):
repo = InMemorySlotRepository()
svc = SlotService(repo)
repo.reconcile_universe("default", {"pf-A": "SYM-A", "pf-B": "SYM-B"})
store = SnapshotStore()
store.swap([
PortfolioItem(portfolio_id="pf-A", symbol="SYM-A", cash_balance=0, asset_quantity=0),
PortfolioItem(portfolio_id="pf-B", symbol="SYM-B", cash_balance=0, asset_quantity=0),
])
pause = InMemoryPauseStore()
warn_store = InMemoryReconcileWarningStore()
if warned_slot_ids:
for sid in warned_slot_ids:
warn_store.set_warned(sid, portfolio_id="pf-A", case_code="B")
handler = make_slots_rpc_handler(
repo,
snapshot_store=store,
pause_store=pause,
warn_store=warn_store,
)
return repo, svc, handler, pause, warn_store
def _req(filter=None):
return RpcRequest(payload={"filter": filter or "all"}, correlation_id="t")
# REQ-104 AC-5: reconcile_warned=True for warned slot
def test_slots_rpc_reconcile_warned_true():
repo, svc, handler, _, warn_store = _wire_with_warn()
slot_a = repo.find_slot_by_portfolio("default", "pf-A")
warn_store.set_warned(str(slot_a.slot_id), portfolio_id="pf-A", case_code="B")
resp = handler(_req())
rows = {r["portfolio_id"]: r for r in resp.payload["slots"]}
assert rows["pf-A"]["reconcile_warned"] is True
assert rows["pf-B"]["reconcile_warned"] is False
# REQ-104 AC-4: pause only => paused=True, reconcile_warned=False
def test_slots_rpc_paused_only():
repo, svc, handler, pause, _ = _wire_with_warn()
pause.pause("pf-A")
resp = handler(_req())
rows = {r["portfolio_id"]: r for r in resp.payload["slots"]}
assert rows["pf-A"]["paused"] is True
assert rows["pf-A"]["reconcile_warned"] is False
# REQ-104 AC-6: both pause + reconcile_warned
def test_slots_rpc_both_pause_and_reconcile_warned():
repo, svc, handler, pause, warn_store = _wire_with_warn()
slot_a = repo.find_slot_by_portfolio("default", "pf-A")
pause.pause("pf-A")
warn_store.set_warned(str(slot_a.slot_id), portfolio_id="pf-A", case_code="C")
resp = handler(_req())
rows = {r["portfolio_id"]: r for r in resp.payload["slots"]}
assert rows["pf-A"]["paused"] is True
assert rows["pf-A"]["reconcile_warned"] is True
# REQ-104 AC-7: warn_store=None => reconcile_warned=False (backward compat, NFR-4)
def test_slots_rpc_no_warn_store_reconcile_warned_false():
repo = InMemorySlotRepository()
repo.reconcile_universe("default", {"pf-A": "SYM"})
handler = make_slots_rpc_handler(repo, warn_store=None)
resp = handler(_req())
for row in resp.payload["slots"]:
assert row["reconcile_warned"] is False
# REQ-104 AC-8: existing 'warned' field unchanged
def test_slots_rpc_existing_warned_field_unchanged():
"""warned field (slot qty vs hub asset_quantity divergence) must not be affected."""
repo = InMemorySlotRepository()
svc = SlotService(repo)
repo.reconcile_universe("default", {"pf-A": "SYM"})
store = SnapshotStore()
store.swap([
PortfolioItem(portfolio_id="pf-A", symbol="SYM", cash_balance=0, asset_quantity=5),
])
slot_a = repo.find_slot_by_portfolio("default", "pf-A")
pending = svc.transit_slot(slot_a.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
reason="buy", actor="test", symbol="SYM")
svc.transit_slot(pending.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
reason="filled", actor="test", avg_price=100.0, qty=10.0)
warn_store = InMemoryReconcileWarningStore()
handler = make_slots_rpc_handler(repo, snapshot_store=store, warn_store=warn_store)
resp = handler(RpcRequest(payload={"filter": "holding"}, correlation_id="t"))
row = resp.payload["slots"][0]
# slot.qty=10 != hub asset_quantity=5 => warned=True (existing field)
assert row["warned"] is True
# reconcile_warned is separate
assert row["reconcile_warned"] is False
# ===========================================================================
# REQ-107: logging fields
# ===========================================================================
def test_reconcile_core_holdings_b_case_logging(caplog):
repo = InMemorySlotRepository()
svc = SlotService(repo)
warn_store = InMemoryReconcileWarningStore()
_make_holding_slot(repo, svc, qty=10)
broker = FakeBroker([BalanceItem(symbol="005930", hldg_qty=5)])
with caplog.at_level(logging.WARNING, logger="trex_core.application.reconciliation"):
reconcile_core_holdings(
slot_repo=repo, slot_service=svc, broker=broker,
instance_key="default", warn_store=warn_store,
)
log_text = caplog.text
assert "reconcile_warning set" in log_text
assert "case_code=B" in log_text
assert "broker_qty=5" in log_text
def test_reconcile_positions_c_case_logging(caplog):
repo = InMemorySlotRepository()
svc = SlotService(repo)
warn_store = InMemoryReconcileWarningStore()
_make_holding_slot(repo, svc, qty=10, portfolio_id="pf-A")
store = _store_with([
PortfolioItem(portfolio_id="pf-A", symbol="005930", cash_balance=0, asset_quantity=10),
])
broker = FakeBroker([BalanceItem(symbol="005930", hldg_qty=7)])
with caplog.at_level(logging.WARNING, logger="trex_core.application.reconciliation"):
reconcile_positions(
store=store, broker=broker,
slot_repo=repo, instance_key="default",
warn_store=warn_store,
)
log_text = caplog.text
assert "reconcile_warning set" in log_text
assert "case_code=C" in log_text
# ===========================================================================
# REQ-108: SlotState enum + ALLOWED_TRANSITIONS invariant (NFR-4)
# ===========================================================================
def test_slot_state_enum_unchanged():
"""SlotState enum has exactly the same 5 values as before BAT-050."""
from trex_core.domain.slot.state import SlotState
expected = {"inactive", "pending_buy", "holding", "pending_sell", "error"}
assert {s.value for s in SlotState} == expected
def test_allowed_transitions_count():
"""ALLOWED_TRANSITIONS count: 7 base + 4 to_error + 2 from_error
+ 1 sync-onboarding (BAT-051 REQ-105 OQ-A: INACTIVE→HOLDING) = 14.
BAT-050 did not add transitions; BAT-051 added exactly 1 (INACTIVE→HOLDING)
for cold-start onboarding (actor=sync, wire contract unchanged).
SlotState enum remains 5 values.
"""
from trex_core.domain.slot.state import ALLOWED_TRANSITIONS, SlotState
# 14 = 7 base + 4 to_error + 2 from_error + 1 sync-onboarding (BAT-051)
assert len(ALLOWED_TRANSITIONS) == 14
# BAT-051 REQ-105 OQ-A: INACTIVE→HOLDING is now allowed (sync-only actor)
assert (SlotState.INACTIVE, SlotState.HOLDING) in ALLOWED_TRANSITIONS
# SlotState enum still 5 values — wire contract unchanged
assert len(list(SlotState)) == 5
# NFR-2: short-term instance normal scenario (slot qty == broker qty) => no warn
def test_short_term_instance_normal_no_warn():
"""M1/M2 daily slot matching scenario — no false positive blocking (S-4, NFR-2)."""
repo = InMemorySlotRepository()
svc = SlotService(repo)
warn_store = InMemoryReconcileWarningStore()
_make_holding_slot(repo, svc, qty=10, portfolio_id="pf-A")
store = _store_with([
PortfolioItem(portfolio_id="pf-A", symbol="005930", cash_balance=0, asset_quantity=10),
])
broker = FakeBroker([BalanceItem(symbol="005930", hldg_qty=10)]) # perfect match
reconcile_positions(
store=store, broker=broker,
slot_repo=repo, instance_key="default",
warn_store=warn_store,
)
reconcile_core_holdings(
slot_repo=repo, slot_service=svc, broker=broker,
instance_key="default", warn_store=warn_store,
)
# No new warn_store entries
assert warn_store.list_warned() == set()
# NFR-3: reconcile does NOT auto-modify slot qty
def test_reconcile_no_auto_fix_slot_qty():
"""Reconcile must only detect and block — no auto-modification of slot qty."""
repo = InMemorySlotRepository()
svc = SlotService(repo)
warn_store = InMemoryReconcileWarningStore()
slot = _make_holding_slot(repo, svc, qty=10)
broker = FakeBroker([BalanceItem(symbol="005930", hldg_qty=5)])
reconcile_core_holdings(
slot_repo=repo, slot_service=svc, broker=broker,
instance_key="default", warn_store=warn_store,
)
# Slot qty must remain 10 — not auto-corrected to 5
updated_slot = repo.fetch_slot(slot.slot_id)
assert float(updated_slot.qty) == 10.0
# ===========================================================================
# slot_to_dict unit tests for reconcile_warned field
# ===========================================================================
def test_slot_to_dict_reconcile_warned_false_when_no_warn_store():
"""When warn_store is None, reconcile_warned=False (NFR-4 additive)."""
repo = InMemorySlotRepository()
svc = SlotService(repo)
slot = _make_holding_slot(repo, svc, qty=5)
row = slot_to_dict(slot, warn_store=None)
assert "reconcile_warned" in row
assert row["reconcile_warned"] is False
def test_slot_to_dict_reconcile_warned_true_when_warned():
repo = InMemorySlotRepository()
svc = SlotService(repo)
slot = _make_holding_slot(repo, svc, qty=5)
warn_store = InMemoryReconcileWarningStore()
warn_store.set_warned(str(slot.slot_id), portfolio_id="pf-A", case_code="B")
row = slot_to_dict(slot, warn_store=warn_store)
assert row["reconcile_warned"] is True
def test_slot_to_dict_paused_and_warned_independent():
"""paused and reconcile_warned are independent fields."""
repo = InMemorySlotRepository()
svc = SlotService(repo)
slot = _make_holding_slot(repo, svc, qty=5)
pause_store = InMemoryPauseStore()
pause_store.pause("pf-A")
warn_store = InMemoryReconcileWarningStore()
warn_store.set_warned(str(slot.slot_id), portfolio_id="pf-A", case_code="C")
row = slot_to_dict(slot, pause_store=pause_store, warn_store=warn_store)
assert row["paused"] is True
assert row["reconcile_warned"] is True
# ===========================================================================
# AC-0023 review: warn_store.is_warned() raises → intent fail-closed
# ===========================================================================
class _RaisingWarnStore:
"""Stub warn_store whose is_warned always raises (simulates DB unavailable)."""
def is_warned(self, slot_id: str) -> bool:
raise RuntimeError("DB unavailable")
def list_c_case_warned(self) -> set:
raise RuntimeError("DB unavailable")
def test_buy_intent_fail_closed_when_warn_store_raises():
"""If warn_store.is_warned() raises, buy intent must be rejected (fail-closed)."""
repo = InMemorySlotRepository()
svc = SlotService(repo)
intent_store = InMemoryIntentStore()
repo.reconcile_universe("default", {"pf-A": "005930"})
slot = repo.find_slot_by_portfolio("default", "pf-A")
intent_svc = IntentService(
svc, repo, intent_store,
instance_key="default",
warn_store=_RaisingWarnStore(),
)
d = intent_svc.submit_buy(BuyIntent(
intent_id="b-fc-001",
slot_id=slot.slot_id,
entry_price=100.0,
qty=10.0,
))
assert d.status == "rejected"
assert d.reason == "reconcile_warning_unavailable"
def test_sell_intent_fail_closed_when_warn_store_raises():
"""If warn_store.is_warned() raises, sell intent must be rejected (fail-closed)."""
repo = InMemorySlotRepository()
svc = SlotService(repo)
intent_store = InMemoryIntentStore()
slot = _make_holding_slot(repo, svc, qty=10)
intent_svc = IntentService(
svc, repo, intent_store,
instance_key="default",
warn_store=_RaisingWarnStore(),
)
d = intent_svc.submit_sell(SellIntent(
intent_id="s-fc-001",
slot_id=slot.slot_id,
reason="strategy_exit",
))
assert d.status == "rejected"
assert d.reason == "reconcile_warning_unavailable"