From 49eb20bd51b53a2e71291b062bbcd3f529de09be Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:27:03 +0000 Subject: [PATCH] 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> --- .proofline/criteria/ac-0023.md | 8 +- .../test_reconcile_warning_blocking.py | 58 ++++++++ tests/application/test_verify.py | 137 ++++++++++++++++++ trex_core/application/intents.py | 12 +- trex_core/application/reconciliation.py | 29 +++- 5 files changed, 230 insertions(+), 14 deletions(-) diff --git a/.proofline/criteria/ac-0023.md b/.proofline/criteria/ac-0023.md index d66afb0..e0e01d7 100644 --- a/.proofline/criteria/ac-0023.md +++ b/.proofline/criteria/ac-0023.md @@ -14,8 +14,12 @@ KIS 계좌 잔고(`hldg_qty`)와 같은 instance가 관리하는 동일 symbol - 성공 조건: KIS 440주, hub portfolio A 200주 + B 240주(합계 440) → A·B 모두 반환되고 `excluded_slots.reconcile_quantity_mismatch=0`. - 불일치 조건: KIS 440주, hub A 200주 + B 235주(합계 435) → A·B 모두 제외되고 `excluded_slots.reconcile_quantity_mismatch=2`. 다른 symbol 정상 slot은 같은 응답에 포함된다. - 단일 portfolio 불일치: hub 단일 portfolio qty != KIS → 해당 symbol 전체 slot 제외. -- fail-closed: warn-store가 `list_c_case_warned()` 중 예외를 throw하면 `slots=[], degraded=true, reason="warn_store_unavailable"` 반환. +- fail-closed (slots RPC): warn-store가 `list_c_case_warned()` 중 예외를 throw하면 `slots=[], degraded=true, reason="warn_store_unavailable"` 반환. +- fail-closed (intent): intent 처리 시 `warn_store.is_warned()` 호출이 실패하면 `reconcile_warning_unavailable` 사유로 거부해야 한다 — fail-open 허용 불가. - intent race: 마지막 slots RPC 이후 경고가 설정된 경우에도 intent 처리 시점의 durable warning을 재확인해 `reconcile_warned`로 거부. -- 해제: symbol 합계가 다시 일치하면 해당 symbol의 모든 C-case 경고를 함께 해제. 시간이 지났다는 이유만으로 자동 해제하지 않음. +- 해제: symbol 합계가 다시 일치하면 해당 symbol의 모든 **C-case 경고만** 함께 해제. 같은 symbol의 B-case 등 다른 case 경고는 보존해야 한다. +- 해제 대상: HOLDING 뿐 아니라 모든 상태의 slot에 걸린 C-case 경고를 포함한다. +- 해제 실패: 해제 도중 예외가 발생하면 `result='clear_failed'`를 반환하고 완전 성공으로 보고하지 않아야 한다(부분 성공 = 실패). +- 시간이 지났다는 이유만으로 자동 해제하지 않음. - 비침습 조건: slots RPC 호출 전후로 slot 상태 전이, broker mutation, ledger write side effect가 없어야 한다. - AC-0022 우선순위: C-case로 제외된 slot에는 broker-authoritative `qty` 응답 형식(AC-0022)을 적용하지 않는다. diff --git a/tests/application/test_reconcile_warning_blocking.py b/tests/application/test_reconcile_warning_blocking.py index 169a303..4a2783a 100644 --- a/tests/application/test_reconcile_warning_blocking.py +++ b/tests/application/test_reconcile_warning_blocking.py @@ -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" diff --git a/tests/application/test_verify.py b/tests/application/test_verify.py index 214cd60..5adc88d 100644 --- a/tests/application/test_verify.py +++ b/tests/application/test_verify.py @@ -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"] diff --git a/trex_core/application/intents.py b/trex_core/application/intents.py index aeafa19..d881483 100644 --- a/trex_core/application/intents.py +++ b/trex_core/application/intents.py @@ -271,8 +271,10 @@ class IntentService: return IntentDecision( intent.intent_id, "rejected", None, "reconcile_warned" ) - except Exception: # noqa: BLE001 - fail-open - pass + except Exception: # noqa: BLE001 - fail-closed: warn-store down → reject + return IntentDecision( + intent.intent_id, "rejected", None, "reconcile_warning_unavailable" + ) # dcy-021: a buy on a HOLDING slot is a position-sizing increase. is_increase = slot.status is SlotState.HOLDING @@ -362,8 +364,10 @@ class IntentService: return IntentDecision( intent.intent_id, "rejected", None, "reconcile_warned" ) - except Exception: # noqa: BLE001 - fail-open - pass + except Exception: # noqa: BLE001 - fail-closed: warn-store down → reject + return IntentDecision( + intent.intent_id, "rejected", None, "reconcile_warning_unavailable" + ) if slot.status is not SlotState.HOLDING: return IntentDecision( diff --git a/trex_core/application/reconciliation.py b/trex_core/application/reconciliation.py index 921c412..d0c23ab 100644 --- a/trex_core/application/reconciliation.py +++ b/trex_core/application/reconciliation.py @@ -664,22 +664,35 @@ def verify_slot_reconcile( match = hub_qty == broker_qty if match: - # AC-0023: clear C-case warnings for ALL holding slots with this symbol. + # AC-0023: clear C-case warnings for ALL slots (any status) with this symbol. + # Only rows with case_code='C' are touched; B-case (or other) rows are preserved. cleared_slots: list[str] = [] try: - for s in slot_repo.list_holding_slots(instance_key): - if s.symbol == sym: - sid_str = str(s.slot_id) - warn_store.clear_warned(sid_str) - store.set_warning(s.portfolio_id, False) - cleared_slots.append(sid_str) + for s in slot_repo.list_slots(instance_key): + if s.symbol != sym: + continue + sid_str = str(s.slot_id) + warn_info = warn_store.get_warning_info(sid_str) + if warn_info is None or warn_info.get("case_code") != "C": + continue + warn_store.clear_warned(sid_str) + store.set_warning(s.portfolio_id, False) + cleared_slots.append(sid_str) _log.info( "verify: reconcile-warning cleared (C-case resolved) " "symbol=%s hub_qty=%d broker_qty=%d cleared_slots=%s", sym, hub_qty, broker_qty, cleared_slots, ) except Exception: # noqa: BLE001 - _log.exception("verify: failed to clear_warned for symbol=%s", sym) + _log.exception("verify: failed to clear C-case warned for symbol=%s", sym) + return { + "portfolio_id": portfolio_id, + "slot_id": slot_id_str, + "result": "clear_failed", + "hub_qty": hub_qty, + "broker_qty": broker_qty, + "reason": "warn_store error during C-case clear — warnings NOT fully cleared", + } return { "portfolio_id": portfolio_id, "slot_id": slot_id_str,