AC-0023: fail-closed intent warn-store; C-case-only verify clear; clear_failed on partial error
Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
This commit is contained in:
co-authored by
genichin
parent
1179bdfe23
commit
49eb20bd51
@@ -598,3 +598,61 @@ def test_slot_to_dict_paused_and_warned_independent():
|
||||
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"
|
||||
|
||||
@@ -265,3 +265,140 @@ def test_verify_empty_snapshot_treats_hub_qty_as_zero():
|
||||
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_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) -> None:
|
||||
self._calls += 1
|
||||
if self._calls > self._fail_after:
|
||||
raise RuntimeError("simulated DB error")
|
||||
super().clear_warned(slot_id)
|
||||
|
||||
|
||||
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"]
|
||||
|
||||
Reference in New Issue
Block a user