diff --git a/.proofline/criteria/ac-0022.md b/.proofline/criteria/ac-0022.md index 54d5f67..d08ea22 100644 --- a/.proofline/criteria/ac-0022.md +++ b/.proofline/criteria/ac-0022.md @@ -7,9 +7,9 @@ status: active ## Criterion -`trex.slots.req.`의 **전략용 slots 응답에 포함되는** `holding` 슬롯(AC-0023에 따라 C-case 수량 불일치로 제외된 slot 제외)의 `qty`는 요청한 instance에 결합된 KIS account broker holdings(`hldg_qty`)를 authoritative source로 사용해야 한다. `daily_slot.qty`와 portfolio-hub 집계값은 각각 `slot_quantity`, `asset_quantity` 진단 필드로 분리 유지한다. broker 수량을 확보하지 못했거나 freshness 허용시간을 초과하면 core/hub mirror fallback 없이 `qty=null`, `qty_source="unavailable"`, `broker_quantity_available=false`, `broker_quantity_captured_at=null`을 반환해 전략이 fail-closed하도록 해야 한다. confirmed broker zero는 `qty=0`, `qty_source="broker"`로 unavailable과 구분되어야 한다. 이 read path는 주문 submit/cancel, ledger write, slot lifecycle mutation을 유발하지 않아야 한다. +`trex.slots.req.`의 **전략용 slots 응답에 포함되는** `holding` 슬롯(AC-0023에 따라 B 또는 C reconcile warning으로 제외된 slot 제외)의 `qty`는 요청한 instance에 결합된 KIS account broker holdings(`hldg_qty`)를 authoritative source로 사용해야 한다. `daily_slot.qty`와 portfolio-hub 집계값은 각각 `slot_quantity`, `asset_quantity` 진단 필드로 분리 유지한다. broker 수량을 확보하지 못했거나 freshness 허용시간을 초과하면 core/hub mirror fallback 없이 `qty=null`, `qty_source="unavailable"`, `broker_quantity_available=false`, `broker_quantity_captured_at=null`을 반환해 전략이 fail-closed하도록 해야 한다. confirmed broker zero는 `qty=0`, `qty_source="broker"`로 unavailable과 구분되어야 한다. 이 read path는 주문 submit/cancel, ledger write, slot lifecycle mutation을 유발하지 않아야 한다. -**Scope note:** AC-0023에 따라 C-case(`case_code=C`) 수량 불일치로 전략용 slots RPC에서 제외된 slot에는 이 AC의 broker-authoritative `qty` 응답 형식을 적용하지 않는다. AC-0023 제외가 AC-0022 broker-qty 형식보다 우선한다. +**Scope note:** AC-0023에 따라 B 또는 C reconcile warning(`case_code=B` 또는 `case_code=C`)으로 전략용 slots RPC에서 제외된 slot에는 이 AC의 broker-authoritative `qty` 응답 형식을 적용하지 않는다. AC-0023 제외가 AC-0022 broker-qty 형식보다 우선한다. ## Verification diff --git a/.proofline/criteria/ac-0023.md b/.proofline/criteria/ac-0023.md index e0e01d7..1ab13c4 100644 --- a/.proofline/criteria/ac-0023.md +++ b/.proofline/criteria/ac-0023.md @@ -3,23 +3,25 @@ id: "ac-0023" status: active --- -# C-case quantity mismatch — strategy RPC exclusion and intent blocking +# B/C reconcile warning — strategy RPC exclusion and intent blocking ## Criterion -KIS 계좌 잔고(`hldg_qty`)와 같은 instance가 관리하는 동일 symbol의 portfolio-hub `asset_quantity` 합계가 일치하지 않으면(`case_code=C` reconcile-warning 활성), 해당 symbol의 모든 `holding` slot을 `trex.slots.req.` 응답에서 제외해야 한다. 수량 대조는 **instance_key + symbol 단위 합계**로 수행하며, 개별 portfolio 수량을 KIS 종목 합계와 직접 비교해서는 안 된다. 응답에는 `excluded_slots.reconcile_quantity_mismatch` 필드로 제외된 slot 수를 포함한다. warn-store를 읽지 못하면 경고 유무를 모르는 상태로 slot 목록을 반환하지 않고 `degraded=true` / `reason="warn_store_unavailable"`으로 응답해야 한다(fail-closed). 신규 buy/sell intent는 durable warning을 재확인하여 `reconcile_warned` 사유로 거부한다. C-case 경고 해제는 symbol 단위 합계가 다시 일치한 경우에만 수행하며, 해당 symbol에 걸린 모든 C-case 경고를 함께 해제한다. 이 경로는 주문 submit/cancel, ledger write, slot lifecycle mutation을 유발하지 않아야 한다. +`case_code=B`(core slot↔KIS 수량 불일치) 또는 `case_code=C`(portfolio-hub↔KIS 수량 불일치) reconcile-warning이 활성인 `holding` slot은 `trex.slots.req.` 응답에서 제외해야 한다. 동일 slot의 B/C 원인은 서로 덮어쓰지 않고 동시에 보존하며, 응답에는 제외된 slot 수와 활성 case별 count를 포함한다. 신규 buy/sell intent는 같은 durable warning 원인 집합을 재확인하여 `reconcile_warned` 사유로 거부한다. warn-store를 읽지 못하거나 warning metadata가 비어 있거나 지원하지 않는 case를 포함하면 안전한 eligibility를 입증할 수 없으므로 slots RPC는 `degraded=true` / `reason="warn_store_unavailable"`, intent는 `reconcile_warning_unavailable`(조회 실패) 또는 `reconcile_warned`(empty/unsupported metadata)로 fail-closed 처리해야 한다. `sync`는 수량 일치 확인 후 B-case만 해제하고, `verify`는 instance+symbol 단위 KIS와 portfolio-hub 합계가 다시 일치한 경우 C-case만 해제하여 다른 활성 원인을 보존한다. C-case 수량 대조는 **instance_key + symbol 단위 합계**로 수행하며, 개별 portfolio 수량을 KIS 종목 합계와 직접 비교해서는 안 된다. 이 경로는 주문 submit/cancel, ledger write, slot lifecycle mutation을 유발하지 않아야 한다. ## Verification - 성공 조건: 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은 같은 응답에 포함된다. +- 불일치 조건: KIS 440주, hub A 200주 + B 235주(합계 435) → A·B 모두 제외되고 `excluded_slots.reconcile_quantity_mismatch=2`, `active_reconcile_warning_cases.C=2`. 다른 symbol 정상 slot은 같은 응답에 포함된다. +- 복합 원인: 동일 slot에 B와 C가 동시에 활성일 때 두 원인이 모두 조회·차단되며, B 또는 C 하나를 해제해도 다른 원인은 보존된다. +- B-case: B warning이 활성인 holding slot은 slots RPC에서 제외되고 신규 buy/sell intent도 거부된다. - 단일 portfolio 불일치: hub 단일 portfolio qty != KIS → 해당 symbol 전체 slot 제외. -- fail-closed (slots RPC): warn-store가 `list_c_case_warned()` 중 예외를 throw하면 `slots=[], degraded=true, reason="warn_store_unavailable"` 반환. +- fail-closed (slots RPC): warn-store 조회가 실패하거나 empty/unknown case metadata를 반환하면 `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`로 거부. +- intent race: 마지막 slots RPC 이후 경고가 설정된 경우에도 intent 처리 시점의 durable warning을 재확인해 `reconcile_warned`로 거부. unknown 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)을 적용하지 않는다. +- AC-0022 우선순위: B 또는 C reconcile warning으로 제외된 slot에는 broker-authoritative `qty` 응답 형식(AC-0022)을 적용하지 않는다. diff --git a/docs/interface-contract.md b/docs/interface-contract.md index 05b00df..7f020b2 100644 --- a/docs/interface-contract.md +++ b/docs/interface-contract.md @@ -110,13 +110,20 @@ "last_seen": "ISO-8601 | null" }, "paused": "bool", + "reconcile_warned": "bool", // durable B/C warning 또는 unknown case 존재 + "reconcile_warning_cases": ["B", "C"], // 활성 case code 목록 + "trade_eligible": "bool", // known blocking/unknown case가 없을 때만 true "strategy_name": "str | null", // REQ-116 additive (BAT-055) — portfolio-hub PortfolioItem 출처 "strategy_params": "dict", // REQ-116 additive (BAT-055) — portfolio-hub opaque dict; {} if absent "markets": ["str"] // REQ-116 additive (BAT-055) — portfolio-hub markets list; [] if absent } ], + "excluded_slots": { + "reconcile_quantity_mismatch": "int", + "active_reconcile_warning_cases": {"B": "int", "C": "int"} + }, "degraded": "bool", - "reason": "portfolio_hub_unavailable | snapshot_fetch_failed | cash_position_stale | null", + "reason": "portfolio_hub_unavailable | snapshot_fetch_failed | cash_position_stale | warn_store_unavailable | null", "message": "str | null" // sanitized — no credentials/raw exceptions } ``` @@ -138,11 +145,14 @@ | `warned` | 파생 | `slot_quantity != asset_quantity` (REQ-074 AC-2) | | `watchdog` | WatchdogStore | 만료 여부 + last_seen; null=store 미연결 | | `paused` | PauseStore | durable pause overlay (REQ-032) | +| `reconcile_warned`, `reconcile_warning_cases` | ReconcileWarningStore | 동일 slot의 durable B/C 원인을 동시에 보존·노출 | +| `trade_eligible` | 파생(safety gate) | 활성 B/C 또는 지원하지 않는 warning case가 있으면 false | | `strategy_name` | hub (PortfolioItem via SnapshotStore) | REQ-116 additive. portfolio-hub 정본. hub 미제공 시 null | | `strategy_params` | hub (PortfolioItem via SnapshotStore) | REQ-116 additive. opaque dict; hub 미제공 시 {} | | `markets` | hub (PortfolioItem via SnapshotStore) | REQ-116 additive. list[str]; hub 미제공 시 [] | - **slot-origin strategy 필드 없음** (REQ-074 AC-5, REQ-116 additive 재도입 후에도 유지): `strategy_id`, `sell_strategy_id`, `active_strategy_id` 는 응답에 포함되지 않는다. `strategy_name`/`strategy_params`/`markets` 는 PortfolioItem(hub) 출처이므로 AC-5 제외 대상과 구분된다 — REQ-116 Notes 참조. +- **Reconcile warning fail-closed**: B/C warning이 활성인 holding slot은 응답에서 제외된다. warning store 조회 실패, empty metadata, unknown case는 `slots=[]`, `degraded=true`, `reason="warn_store_unavailable"`로 처리한다. intent admission도 동일 durable source를 재확인하며 unknown case를 거래 허용 근거로 사용하지 않는다. - **Breaking change (holding `qty`) rollout**: 기존 전략 클라이언트가 `qty=slot mirror`로 가정했다면 `slot_quantity`를 읽도록 전환해야 한다. 권장 경로는 (1) 클라이언트에 `qty_source`/`broker_quantity_available` 체크 추가 → (2) fail-closed (`qty is null`) 처리 배포 → (3) `qty`를 broker 정본으로 사용하도록 cutover 순서다. - `spy=false`(기본): holding 슬롯 응답 후 `WatchdogStore.update(slot_id)` 호출 — 전략 폴링 = 생존 신호. - `spy=true`: watchdog 갱신 건너뜀 — 운영자 CLI(`universe`/`slots`) 가 항상 사용. diff --git a/tests/adapters/portfolio/test_reconcile_warning_store.py b/tests/adapters/portfolio/test_reconcile_warning_store.py new file mode 100644 index 0000000..05cc113 --- /dev/null +++ b/tests/adapters/portfolio/test_reconcile_warning_store.py @@ -0,0 +1,42 @@ +"""PostgreSQLReconcileWarningStore integration tests.""" +from __future__ import annotations + +from trex_core.adapters.portfolio.reconcile_warning_store import ( + PostgreSQLReconcileWarningStore, +) + + +def test_warning_store_preserves_multiple_cases_for_same_slot(pg_clean): + store = PostgreSQLReconcileWarningStore(pg_clean) + store.set_warned("slot-1", portfolio_id="pf-A", case_code="B", reason="b") + store.set_warned("slot-1", portfolio_id="pf-A", case_code="C", reason="c") + + assert store.is_warned("slot-1") is True + info = store.get_warning_info("slot-1") + assert info is not None + assert info["case_codes"] == ["B", "C"] + + +def test_warning_store_case_specific_clear_and_full_clear(pg_clean): + store = PostgreSQLReconcileWarningStore(pg_clean) + store.set_warned("slot-2", portfolio_id="pf-A", case_code="B") + store.set_warned("slot-2", portfolio_id="pf-A", case_code="C") + + store.clear_warned("slot-2", case_code="C") + info = store.get_warning_info("slot-2") + assert info is not None + assert info["case_codes"] == ["B"] + + store.clear_warned("slot-2") + assert store.is_warned("slot-2") is False + + +def test_warning_store_is_durable_across_reinstantiation(pg_clean): + a = PostgreSQLReconcileWarningStore(pg_clean) + a.set_warned("slot-restart", portfolio_id="pf-A", case_code="B") + a.set_warned("slot-restart", portfolio_id="pf-A", case_code="C") + + b = PostgreSQLReconcileWarningStore(pg_clean) + info = b.get_warning_info("slot-restart") + assert info is not None + assert info["case_codes"] == ["B", "C"] diff --git a/tests/application/test_reconcile_notification.py b/tests/application/test_reconcile_notification.py index 1c1de0d..b95044d 100644 --- a/tests/application/test_reconcile_notification.py +++ b/tests/application/test_reconcile_notification.py @@ -896,7 +896,7 @@ def test_b_sync_does_not_publish_resolved_when_clear_warned_fails(): from trex_core.application.reconciliation import sync_slot_to_broker class FailingWarnStore(InMemoryReconcileWarningStore): - def clear_warned(self, slot_id): + def clear_warned(self, slot_id, *, case_code=None): raise RuntimeError("DB unavailable") ns = InMemoryReconcileNotificationStore() diff --git a/tests/application/test_reconcile_warning_blocking.py b/tests/application/test_reconcile_warning_blocking.py index 73487b7..ed9f761 100644 --- a/tests/application/test_reconcile_warning_blocking.py +++ b/tests/application/test_reconcile_warning_blocking.py @@ -340,6 +340,52 @@ def test_intent_service_sell_rejected_when_reconcile_warned(): assert d.reason == "reconcile_warned" +def test_unknown_warning_case_blocks_buy_and_sell_intents(): + 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="X") + + intent_svc = IntentService( + svc, + repo, + InMemoryIntentStore(), + instance_key="default", + warn_store=warn_store, + ) + + buy = intent_svc.submit_buy(BuyIntent( + intent_id="b-unknown-case", + slot_id=slot.slot_id, + entry_price=100.0, + qty=1.0, + )) + sell = intent_svc.submit_sell(SellIntent( + intent_id="s-unknown-case", + slot_id=slot.slot_id, + reason="strategy_exit", + )) + + assert buy.status == "rejected" + assert buy.reason == "reconcile_warned" + assert sell.status == "rejected" + assert sell.reason == "reconcile_warned" + + +def test_slot_to_dict_marks_unknown_warning_case_ineligible(): + 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="X") + + row = slot_to_dict(slot, warn_store=warn_store) + + assert row["reconcile_warning_cases"] == ["X"] + assert row["trade_eligible"] is False + + def test_reconcile_warned_buy_emits_alarm_with_allowlisted_fields_after_finalize(): repo = InMemorySlotRepository() svc = SlotService(repo) @@ -892,6 +938,8 @@ def test_slot_to_dict_reconcile_warned_false_when_no_warn_store(): row = slot_to_dict(slot, warn_store=None) assert "reconcile_warned" in row assert row["reconcile_warned"] is False + assert row["trade_eligible"] is True + assert row["reconcile_warning_cases"] == [] def test_slot_to_dict_reconcile_warned_true_when_warned(): @@ -902,6 +950,8 @@ def test_slot_to_dict_reconcile_warned_true_when_warned(): 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 + assert row["trade_eligible"] is False + assert row["reconcile_warning_cases"] == ["B"] def test_slot_to_dict_paused_and_warned_independent(): diff --git a/tests/application/test_reconcile_warning_store.py b/tests/application/test_reconcile_warning_store.py index 97f329e..7224aa9 100644 --- a/tests/application/test_reconcile_warning_store.py +++ b/tests/application/test_reconcile_warning_store.py @@ -33,14 +33,27 @@ def test_clear_warned_returns_false(): assert store.is_warned("slot-1") is False -# AC-3 (REQ-102): duplicate set_warned on same slot_id => upsert (single entry). -def test_set_warned_upsert_same_slot(): +# AC-3 (REQ-123): same slot can carry multiple active warning causes. +def test_set_warned_preserves_multiple_cases_on_same_slot(): store = _store() store.set_warned("slot-1", portfolio_id="pf-A", case_code="B", reason="first") store.set_warned("slot-1", portfolio_id="pf-A", case_code="C", reason="second") - # After second set, is_warned still True — single entry (no duplicate row). assert store.is_warned("slot-1") is True assert store.list_warned() == {"slot-1"} + info = store.get_warning_info("slot-1") + assert info is not None + assert info["case_codes"] == ["B", "C"] + + +def test_clear_warned_single_case_preserves_other_case(): + store = _store() + store.set_warned("slot-1", portfolio_id="pf-A", case_code="B") + store.set_warned("slot-1", portfolio_id="pf-A", case_code="C") + store.clear_warned("slot-1", case_code="C") + info = store.get_warning_info("slot-1") + assert info is not None + assert info["case_codes"] == ["B"] + assert store.is_warned("slot-1") is True # AC-5 (REQ-102): list_warned returns set of warned slot_ids. diff --git a/tests/application/test_slots_handler_quantity_mismatch.py b/tests/application/test_slots_handler_quantity_mismatch.py index 124a495..333574b 100644 --- a/tests/application/test_slots_handler_quantity_mismatch.py +++ b/tests/application/test_slots_handler_quantity_mismatch.py @@ -128,6 +128,44 @@ def test_ac1_single_portfolio_c_case_excluded(): assert resp.payload["slots"] == [] assert resp.payload["excluded_slots"]["reconcile_quantity_mismatch"] == 1 + assert resp.payload["excluded_slots"]["active_reconcile_warning_cases"] == {"C": 1} + + +def test_holding_slot_with_b_case_is_excluded_for_strategy(): + repo = InMemorySlotRepository() + svc = SlotService(repo) + warn_store = InMemoryReconcileWarningStore() + + slot = _make_holding_slot(repo, svc, portfolio_id="pf-A", symbol=SYM_A, qty=10) + warn_store.set_warned(str(slot.slot_id), portfolio_id="pf-A", case_code="B") + + handler = _make_handler(repo, warn_store) + resp = handler(_req(filter="holding")) + + assert resp.payload["slots"] == [] + assert resp.payload["excluded_slots"]["reconcile_quantity_mismatch"] == 1 + assert resp.payload["excluded_slots"]["active_reconcile_warning_cases"] == {"B": 1} + + +def test_holding_slot_with_b_and_c_cases_keeps_both_and_reports_both(): + repo = InMemorySlotRepository() + svc = SlotService(repo) + warn_store = InMemoryReconcileWarningStore() + + slot = _make_holding_slot(repo, svc, portfolio_id="pf-A", symbol=SYM_A, qty=10) + slot_id = str(slot.slot_id) + warn_store.set_warned(slot_id, portfolio_id="pf-A", case_code="B") + warn_store.set_warned(slot_id, portfolio_id="pf-A", case_code="C") + + handler = _make_handler(repo, warn_store) + resp = handler(_req(filter="holding")) + + assert resp.payload["slots"] == [] + assert resp.payload["excluded_slots"]["reconcile_quantity_mismatch"] == 1 + assert resp.payload["excluded_slots"]["active_reconcile_warning_cases"] == {"B": 1, "C": 1} + info = warn_store.get_warning_info(slot_id) + assert info is not None + assert info["case_codes"] == ["B", "C"] # --------------------------------------------------------------------------- @@ -286,7 +324,8 @@ def test_ac5_excluded_slots_count_zero_when_no_warnings(): resp = handler(_req(filter="holding")) assert "excluded_slots" in resp.payload - assert resp.payload["excluded_slots"] == {"reconcile_quantity_mismatch": 0} + assert resp.payload["excluded_slots"]["reconcile_quantity_mismatch"] == 0 + assert resp.payload["excluded_slots"]["active_reconcile_warning_cases"] == {} # --------------------------------------------------------------------------- @@ -312,8 +351,8 @@ def test_ac6_no_warn_store_no_filtering_no_excluded_slots_key(): # --------------------------------------------------------------------------- class BrokenWarnStore(InMemoryReconcileWarningStore): - """Simulates a warn_store that fails on list_c_case_warned.""" - def list_c_case_warned(self): + """Simulates a warn_store that fails on warning reads.""" + def list_warned(self): raise RuntimeError("db connection lost") @@ -332,6 +371,24 @@ def test_ac7_warn_store_failure_fail_closed(): assert "excluded_slots" in resp.payload +def test_unknown_warning_case_fails_closed(): + repo = InMemorySlotRepository() + svc = SlotService(repo) + warn_store = InMemoryReconcileWarningStore() + slot = _make_holding_slot(repo, svc, portfolio_id="pf-A", symbol=SYM_A, qty=10) + warn_store.set_warned(str(slot.slot_id), portfolio_id="pf-A", case_code="X") + + resp = _make_handler(repo, warn_store)(_req()) + + assert resp.payload["slots"] == [] + assert resp.payload["degraded"] is True + assert resp.payload["reason"] == "warn_store_unavailable" + assert resp.payload["excluded_slots"] == { + "reconcile_quantity_mismatch": 0, + "active_reconcile_warning_cases": {}, + } + + def test_ac23_exclusion_precedes_broker_and_strategy_state_readers(): repo = InMemorySlotRepository() svc = SlotService(repo) @@ -356,7 +413,7 @@ def test_ac23_exclusion_precedes_broker_and_strategy_state_readers(): ) response = handler(_req(filter="holding")).payload assert response["slots"] == [] - assert response["excluded_slots"] == {"reconcile_quantity_mismatch": 1} + assert response["excluded_slots"]["reconcile_quantity_mismatch"] == 1 # --------------------------------------------------------------------------- diff --git a/tests/application/test_sync.py b/tests/application/test_sync.py index c09d08f..4079789 100644 --- a/tests/application/test_sync.py +++ b/tests/application/test_sync.py @@ -199,6 +199,22 @@ def test_sync_holding_qty_mismatch_updated(): assert not ws_out.is_warned(str(slot.slot_id)) +def test_sync_clears_only_b_case_when_slot_has_multiple_warning_causes(): + slot = _make_slot(SlotState.HOLDING, qty=5.0) + ws = InMemoryReconcileWarningStore() + slot_id = str(slot.slot_id) + ws.set_warned(slot_id, portfolio_id="pf-005930", case_code="B") + ws.set_warned(slot_id, portfolio_id="pf-005930", case_code="C") + + result, _repo, ws_out = _setup(slot, {"005930": 10}, warn_store=ws) + + assert result["warning_cleared"] is True + assert ws_out.is_warned(slot_id) is True + info = ws_out.get_warning_info(slot_id) + assert info is not None + assert info["case_codes"] == ["C"] + + # --------------------------------------------------------------------------- # AC-4: WAC seed from cost_basis_port when available # --------------------------------------------------------------------------- diff --git a/tests/application/test_verify.py b/tests/application/test_verify.py index 5adc88d..61f94cf 100644 --- a/tests/application/test_verify.py +++ b/tests/application/test_verify.py @@ -313,6 +313,40 @@ def test_verify_c_case_clear_preserves_b_case_warning(): 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" @@ -360,11 +394,11 @@ class _FailAfterNStore(InMemoryReconcileWarningStore): self._calls = 0 self._fail_after = fail_after - def clear_warned(self, slot_id: str) -> None: + 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) + super().clear_warned(slot_id, case_code=case_code) def test_verify_partial_clear_failure_returns_clear_failed(): diff --git a/tests/runtime/test_cli_operator.py b/tests/runtime/test_cli_operator.py index f110464..91f6d1e 100644 --- a/tests/runtime/test_cli_operator.py +++ b/tests/runtime/test_cli_operator.py @@ -418,7 +418,7 @@ def test_sync_fail_closed_skipped_error_exits_nonzero_without_writes( return SimpleNamespace(get_balance=lambda: []), None class _WarningStore: - def clear_warned(self, *_args): + def clear_warned(self, *_args, **_kwargs): raise AssertionError("warning clear must not be called") class _Connection: diff --git a/trex_core/adapters/persistence/sql/016_create_slot_reconcile_warning.sql b/trex_core/adapters/persistence/sql/016_create_slot_reconcile_warning.sql index e663962..98664c3 100644 --- a/trex_core/adapters/persistence/sql/016_create_slot_reconcile_warning.sql +++ b/trex_core/adapters/persistence/sql/016_create_slot_reconcile_warning.sql @@ -1,6 +1,6 @@ -- BAT-050 / REQ-102: reconcile-warning durable store (dcy-034). --- PK: slot_id (TEXT, UUID string). pause_store 동형 설계. --- ON CONFLICT DO UPDATE: 동일 slot_id 재삽입 시 최신 값으로 갱신. +-- Initial schema; migration 028 upgrades PK to (slot_id, case_code) +-- so B/C causes can coexist for the same slot. CREATE TABLE IF NOT EXISTS slot_reconcile_warning ( slot_id TEXT PRIMARY KEY, portfolio_id TEXT NOT NULL, diff --git a/trex_core/adapters/persistence/sql/028_preserve_reconcile_warning_cases.sql b/trex_core/adapters/persistence/sql/028_preserve_reconcile_warning_cases.sql new file mode 100644 index 0000000..40771bf --- /dev/null +++ b/trex_core/adapters/persistence/sql/028_preserve_reconcile_warning_cases.sql @@ -0,0 +1,30 @@ +-- REQ-123 / issue #83: +-- Preserve simultaneous warning causes by keying warnings per (slot_id, case_code). +-- NOTE: historical overwritten causes cannot be reconstructed from the prior +-- slot_id-only schema; this migration enables forward preservation only. +UPDATE slot_reconcile_warning +SET case_code = 'B' +WHERE case_code IS NULL OR case_code = ''; + +ALTER TABLE slot_reconcile_warning + ALTER COLUMN case_code SET NOT NULL; + +DELETE FROM slot_reconcile_warning w +USING ( + SELECT ctid, ROW_NUMBER() OVER ( + PARTITION BY slot_id, case_code + ORDER BY updated_at DESC, created_at DESC, ctid DESC + ) AS rn + FROM slot_reconcile_warning +) ranked +WHERE w.ctid = ranked.ctid + AND ranked.rn > 1; + +ALTER TABLE slot_reconcile_warning + DROP CONSTRAINT IF EXISTS slot_reconcile_warning_pkey; + +ALTER TABLE slot_reconcile_warning + ADD CONSTRAINT slot_reconcile_warning_pkey PRIMARY KEY (slot_id, case_code); + +CREATE INDEX IF NOT EXISTS idx_slot_reconcile_warning_slot_id + ON slot_reconcile_warning (slot_id); diff --git a/trex_core/adapters/portfolio/reconcile_warning_store.py b/trex_core/adapters/portfolio/reconcile_warning_store.py index 731b565..91f8c7a 100644 --- a/trex_core/adapters/portfolio/reconcile_warning_store.py +++ b/trex_core/adapters/portfolio/reconcile_warning_store.py @@ -1,8 +1,4 @@ -"""PostgreSQL-backed durable reconcile-warning store (REQ-102, BAT-050). - -pause_store.py와 동형 설계. PK: slot_id. case_code: 'B' | 'C'. -Survives daemon restart (dcy-034 NFR-1). -""" +"""PostgreSQL-backed durable reconcile-warning store (REQ-102, BAT-050).""" from __future__ import annotations from datetime import datetime, timezone @@ -12,7 +8,7 @@ from trex_core.adapters.persistence.postgresql import _require_psycopg class PostgreSQLReconcileWarningStore: - """`slot_reconcile_warning` table CRUD. slot_id PK is the gate (REQ-102).""" + """`slot_reconcile_warning` table CRUD keyed by (slot_id, case_code).""" def __init__(self, dsn: str, *, psycopg_module: Any = None) -> None: self._psycopg = _require_psycopg(psycopg_module) @@ -34,9 +30,8 @@ class PostgreSQLReconcileWarningStore: "INSERT INTO slot_reconcile_warning " "(slot_id, portfolio_id, case_code, actor, reason, created_at, updated_at) " "VALUES (%s, %s, %s, %s, %s, %s, %s) " - "ON CONFLICT (slot_id) DO UPDATE " + "ON CONFLICT (slot_id, case_code) DO UPDATE " "SET portfolio_id = EXCLUDED.portfolio_id, " - " case_code = EXCLUDED.case_code, " " actor = EXCLUDED.actor, " " reason = EXCLUDED.reason, " " updated_at = EXCLUDED.updated_at", @@ -44,13 +39,19 @@ class PostgreSQLReconcileWarningStore: ) conn.commit() - def clear_warned(self, slot_id: str) -> None: + def clear_warned(self, slot_id: str, *, case_code: str | None = None) -> None: with self._psycopg.connect(self._dsn, autocommit=False) as conn: with conn.cursor() as cur: - cur.execute( - "DELETE FROM slot_reconcile_warning WHERE slot_id = %s", - (slot_id,), - ) + if case_code is None: + cur.execute( + "DELETE FROM slot_reconcile_warning WHERE slot_id = %s", + (slot_id,), + ) + else: + cur.execute( + "DELETE FROM slot_reconcile_warning WHERE slot_id = %s AND case_code = %s", + (slot_id, case_code), + ) conn.commit() def is_warned(self, slot_id: str) -> bool: @@ -65,7 +66,7 @@ class PostgreSQLReconcileWarningStore: def list_warned(self) -> set[str]: with self._psycopg.connect(self._dsn) as conn: with conn.cursor() as cur: - cur.execute("SELECT slot_id FROM slot_reconcile_warning") + cur.execute("SELECT DISTINCT slot_id FROM slot_reconcile_warning") return {row[0] for row in cur.fetchall()} def list_c_case_warned(self) -> set[str]: @@ -73,26 +74,30 @@ class PostgreSQLReconcileWarningStore: with self._psycopg.connect(self._dsn) as conn: with conn.cursor() as cur: cur.execute( - "SELECT slot_id FROM slot_reconcile_warning WHERE case_code = %s", + "SELECT DISTINCT slot_id FROM slot_reconcile_warning WHERE case_code = %s", ("C",), ) return {row[0] for row in cur.fetchall()} def get_warning_info(self, slot_id: str) -> dict | None: - """Return {"portfolio_id", "case_code", "actor", "reason"} or None (REQ-105/106).""" + """Return warning info with full active case set, or None.""" with self._psycopg.connect(self._dsn) as conn: with conn.cursor() as cur: cur.execute( "SELECT portfolio_id, case_code, actor, reason " - "FROM slot_reconcile_warning WHERE slot_id = %s LIMIT 1", + "FROM slot_reconcile_warning WHERE slot_id = %s " + "ORDER BY CASE WHEN case_code = 'C' THEN 0 ELSE 1 END, updated_at DESC", (slot_id,), ) - row = cur.fetchone() - if row is None: + rows = cur.fetchall() + if not rows: return None + case_codes = sorted({str(row[1]) for row in rows if row[1]}) + preferred = rows[0] return { - "portfolio_id": row[0], - "case_code": row[1], - "actor": row[2], - "reason": row[3], + "portfolio_id": preferred[0], + "case_code": preferred[1], + "case_codes": case_codes, + "actor": preferred[2], + "reason": preferred[3], } diff --git a/trex_core/application/intents.py b/trex_core/application/intents.py index 1f4bf2e..7297d06 100644 --- a/trex_core/application/intents.py +++ b/trex_core/application/intents.py @@ -29,6 +29,7 @@ from typing import Any, Callable, Optional from uuid import UUID from trex_core.application.alarm_emitter import AlarmEmitter +from trex_core.application.portfolio import BLOCKING_RECONCILE_CASES from trex_core.application.ports import IntentStore from trex_core.domain.slot import ( SlotAdmissionRejectedError, @@ -270,6 +271,29 @@ class IntentService: except Exception: # noqa: BLE001 - alarm path must not affect intent decision pass + def _is_reconcile_trade_blocked(self, slot_id: str) -> bool: + if self._warn_store is None: + return False + if not self._warn_store.is_warned(slot_id): + return False + info = self._warn_store.get_warning_info(slot_id) + if info is None: + return True + raw_codes = info.get("case_codes") + if isinstance(raw_codes, list): + case_codes = {str(code) for code in raw_codes if code} + else: + case_code = str(info.get("case_code", "")) + case_codes = {case_code} if case_code else set() + if not case_codes: + return True + # The warning row is authoritative. Unknown cases are not evidence + # that trading is safe: they may come from corrupt data or a newer + # producer that this core version cannot interpret. + if not case_codes.issubset(BLOCKING_RECONCILE_CASES): + return True + return bool(case_codes & BLOCKING_RECONCILE_CASES) + # ------------------------------------------------------------ def _dedup(self, intent_id: str, intent_type: str, payload: dict) -> Optional[IntentDecision]: """Return prior decision if intent_id already seen; else None (and reserve it).""" @@ -339,7 +363,7 @@ class IntentService: # those paths call SlotService.sell() directly. if self._warn_store is not None: try: - if self._warn_store.is_warned(str(slot.slot_id)): + if self._is_reconcile_trade_blocked(str(slot.slot_id)): return IntentDecision( intent.intent_id, "rejected", None, "reconcile_warned" ) @@ -432,7 +456,7 @@ class IntentService: # SlotService.sell() / transit_slot() directly without IntentService (OQ-B). if self._warn_store is not None: try: - if self._warn_store.is_warned(str(slot.slot_id)): + if self._is_reconcile_trade_blocked(str(slot.slot_id)): return IntentDecision( intent.intent_id, "rejected", None, "reconcile_warned" ) diff --git a/trex_core/application/messaging_handlers.py b/trex_core/application/messaging_handlers.py index 9e5f291..f525d95 100644 --- a/trex_core/application/messaging_handlers.py +++ b/trex_core/application/messaging_handlers.py @@ -17,6 +17,7 @@ from trex_core.application.account_snapshot import AccountSnapshotUnavailable from trex_core.application.broker_port import BalanceItem from trex_core.application.messaging import RpcRequest, RpcResponse from trex_core.application.portfolio import ( + BLOCKING_RECONCILE_CASES, PauseStore, ReconcileWarningStore, SnapshotStore, @@ -263,28 +264,27 @@ def make_slots_rpc_handler( for s in holding_slots: watchdog_store.update(str(s.slot_id)) - # Build merged slot rows (REQ-074) - # BAT-050 / REQ-104: warn_store passed to slot_to_dict for reconcile_warned field. - slot_rows = [ - slot_to_dict( - s, - snapshot_store=snapshot_store, - watchdog_store=watchdog_store, - pause_store=pause_store, - warn_store=warn_store, - watchdog_timeout_sec=watchdog_timeout_sec, - ) - for s in slots - ] - - # AC-0023 exclusion must precede all optional data readers so excluded - # slots cannot expose state or make an otherwise valid response fail. - excluded_count = 0 + warning_cases_by_slot: dict[str, set[str]] = {} if warn_store is not None: try: - c_warned_ids = warn_store.list_c_case_warned() + warned_slot_ids = warn_store.list_warned() + for sid in warned_slot_ids: + info = warn_store.get_warning_info(sid) + if info is None: + raise RuntimeError(f"missing warning info for slot_id={sid}") + raw_codes = info.get("case_codes") + if isinstance(raw_codes, list): + codes = {str(code) for code in raw_codes if code} + else: + case_code = str(info.get("case_code", "")) + codes = {case_code} if case_code else set() + if not codes: + raise RuntimeError(f"empty warning cases for slot_id={sid}") + if not codes.issubset(BLOCKING_RECONCILE_CASES): + raise RuntimeError(f"unsupported warning cases for slot_id={sid}") + warning_cases_by_slot[str(sid)] = codes except Exception: # noqa: BLE001 - _log.exception("warn_store.list_c_case_warned() failed — fail-closed") + _log.exception("warn_store warning query failed — fail-closed") return RpcResponse(payload={ "slots": [], "available_slots": None, @@ -294,20 +294,48 @@ def make_slots_rpc_handler( "reconcile warning store unavailable; " "returning no slots (fail-closed)" ), - "excluded_slots": {"reconcile_quantity_mismatch": 0}, + "excluded_slots": { + "reconcile_quantity_mismatch": 0, + "active_reconcile_warning_cases": {}, + }, }) - if c_warned_ids: - included = [ - (slot, row) - for slot, row in zip(slots, slot_rows, strict=True) - if not ( - slot.status.value == "holding" - and str(slot.slot_id) in c_warned_ids - ) - ] - excluded_count = len(slots) - len(included) + + # Build merged slot rows (REQ-074) + slot_rows = [ + slot_to_dict( + s, + snapshot_store=snapshot_store, + watchdog_store=watchdog_store, + pause_store=pause_store, + warning_cases_by_slot=warning_cases_by_slot, + watchdog_timeout_sec=watchdog_timeout_sec, + ) + for s in slots + ] + + # Exclusion must precede all optional data readers so blocked slots cannot + # expose state or make an otherwise valid response fail. + excluded_count = 0 + excluded_case_counts: dict[str, int] = {} + if warn_store is not None: + included: list[tuple[object, dict]] = [] + for slot, row in zip(slots, slot_rows, strict=True): + blocked_holding = ( + slot.status.value == "holding" + and bool(set(row.get("reconcile_warning_cases", [])) & BLOCKING_RECONCILE_CASES) + ) + if blocked_holding: + excluded_count += 1 + for code in row.get("reconcile_warning_cases", []): + excluded_case_counts[code] = excluded_case_counts.get(code, 0) + 1 + continue + included.append((slot, row)) + if included: slots = [slot for slot, _ in included] slot_rows = [row for _, row in included] + else: + slots = [] + slot_rows = [] broker_qty_by_symbol: dict[str, int] = {} broker_qty_available = False @@ -410,7 +438,10 @@ def make_slots_rpc_handler( "message": message, } if warn_store is not None: - base_payload["excluded_slots"] = {"reconcile_quantity_mismatch": excluded_count} + base_payload["excluded_slots"] = { + "reconcile_quantity_mismatch": excluded_count, + "active_reconcile_warning_cases": excluded_case_counts, + } return RpcResponse(payload=base_payload) return handler diff --git a/trex_core/application/portfolio.py b/trex_core/application/portfolio.py index abe519b..5e9e9ea 100644 --- a/trex_core/application/portfolio.py +++ b/trex_core/application/portfolio.py @@ -21,6 +21,8 @@ from datetime import UTC, datetime from enum import Enum from typing import Protocol +BLOCKING_RECONCILE_CASES = frozenset({"B", "C"}) + # ---------------------------------------------------------------- portfolio-hub @dataclass(frozen=True) @@ -359,7 +361,7 @@ class InMemoryKillSwitchStore: class ReconcileWarningStore(Protocol): - """Durable reconcile-warning overlay — slot_id PK (REQ-102, dcy-034).""" + """Durable reconcile-warning overlay keyed by (slot_id, case_code).""" def set_warned( self, @@ -372,7 +374,7 @@ class ReconcileWarningStore(Protocol): ) -> None: ... - def clear_warned(self, slot_id: str) -> None: + def clear_warned(self, slot_id: str, *, case_code: str | None = None) -> None: ... def is_warned(self, slot_id: str) -> bool: @@ -382,10 +384,14 @@ class ReconcileWarningStore(Protocol): ... def get_warning_info(self, slot_id: str) -> dict | None: - """Return {"portfolio_id", "case_code", "actor", "reason"} or None. + """Return warning info dict or None. - Used by verify to determine whether the warning is case_code='C' - (verify's responsibility) vs 'B' (sync's responsibility, REQ-106 OQ-C). + Returned dict includes: + {"portfolio_id", "case_code", "case_codes", "actor", "reason"} + where case_code is a backward-compatible preferred case and case_codes is + the full active case set for the slot. + + Used by verify to determine whether C-case is active (REQ-106 OQ-C). Returns None when the slot has no active warning. """ ... @@ -403,8 +409,8 @@ class InMemoryReconcileWarningStore: def __init__(self) -> None: self._lock = threading.Lock() - # slot_id -> {"portfolio_id", "case_code", "actor", "reason"} - self._warned: dict[str, dict] = {} + # slot_id -> case_code -> {"portfolio_id", "actor", "reason"} + self._warned: dict[str, dict[str, dict]] = {} def set_warned( self, @@ -416,16 +422,24 @@ class InMemoryReconcileWarningStore: reason: str = "", ) -> None: with self._lock: - self._warned[slot_id] = { + per_case = self._warned.setdefault(slot_id, {}) + per_case[case_code] = { "portfolio_id": portfolio_id, - "case_code": case_code, "actor": actor, "reason": reason, } - def clear_warned(self, slot_id: str) -> None: + def clear_warned(self, slot_id: str, *, case_code: str | None = None) -> None: with self._lock: - self._warned.pop(slot_id, None) + if case_code is None: + self._warned.pop(slot_id, None) + return + per_case = self._warned.get(slot_id) + if not per_case: + return + per_case.pop(case_code, None) + if not per_case: + self._warned.pop(slot_id, None) def is_warned(self, slot_id: str) -> bool: with self._lock: @@ -437,12 +451,23 @@ class InMemoryReconcileWarningStore: def get_warning_info(self, slot_id: str) -> dict | None: with self._lock: - info = self._warned.get(slot_id) - return dict(info) if info is not None else None + per_case = self._warned.get(slot_id) + if not per_case: + return None + case_codes = sorted(per_case.keys()) + preferred_case = "C" if "C" in per_case else case_codes[0] + preferred = per_case[preferred_case] + return { + "portfolio_id": preferred.get("portfolio_id", ""), + "case_code": preferred_case, + "case_codes": case_codes, + "actor": preferred.get("actor", ""), + "reason": preferred.get("reason", ""), + } def list_c_case_warned(self) -> set[str]: with self._lock: - return {sid for sid, info in self._warned.items() if info.get("case_code") == "C"} + return {sid for sid, per_case in self._warned.items() if "C" in per_case} class ReconcileNotificationStore(Protocol): @@ -544,6 +569,7 @@ def slot_to_dict( watchdog_store=None, pause_store: PauseStore | None = None, warn_store: ReconcileWarningStore | None = None, + warning_cases_by_slot: dict[str, set[str]] | None = None, watchdog_timeout_sec: int = 120, ) -> dict: """Merged slot row for trex.slots.req (REQ-073/074). @@ -618,10 +644,25 @@ def slot_to_dict( # BAT-050 / REQ-104: reconcile_warned overlay (ReconcileWarningStore) — additive field. # Bulk list_warned() called once per RPC (caller pre-fetches; see make_slots_rpc_handler). # warn_store=None → reconcile_warned=False (backward compatible, NFR-4). - reconcile_warned = False - if warn_store is not None: - reconcile_warned = str(s.slot_id) in warn_store.list_warned() + warning_cases: set[str] = set() + if warning_cases_by_slot is not None: + warning_cases = set(warning_cases_by_slot.get(str(s.slot_id), set())) + elif warn_store is not None: + info = warn_store.get_warning_info(str(s.slot_id)) + if info is not None: + raw_cases = info.get("case_codes") + if isinstance(raw_cases, list): + warning_cases = {str(c) for c in raw_cases if c} + else: + case_code = str(info.get("case_code", "")) + if case_code: + warning_cases = {case_code} + reconcile_warned = bool(warning_cases) d["reconcile_warned"] = reconcile_warned + d["reconcile_warning_cases"] = sorted(warning_cases) + known_blocking = bool(warning_cases & BLOCKING_RECONCILE_CASES) + unknown_cases = bool(warning_cases - BLOCKING_RECONCILE_CASES) + d["trade_eligible"] = not (known_blocking or unknown_cases) return d @@ -637,4 +678,3 @@ def _sanitize(message: str) -> str: out = out[:idx] + "" break return out[:200] - diff --git a/trex_core/application/reconciliation.py b/trex_core/application/reconciliation.py index aca2c5d..bf326c1 100644 --- a/trex_core/application/reconciliation.py +++ b/trex_core/application/reconciliation.py @@ -629,7 +629,7 @@ def sync_slot_to_broker( Broker qty == 0 for slot → skipped with WARN (slot-side A-case guard). Broker-only (no slot) → NOT handled here; caller filters (FR-3). - After update, if slot.qty == broker_qty: clear_warned(slot_id). + After update, if slot.qty == broker_qty: clear_warned(slot_id, case_code="B"). """ from uuid import UUID @@ -870,14 +870,15 @@ def sync_slot_to_broker( after_avg = float(refreshed.avg_price) if (refreshed and refreshed.avg_price is not None) else None after = {"qty": after_qty, "status": after_status, "avg_price": after_avg} - # Clear warning when qty now matches broker + # Clear B-case warning when qty now matches broker warning_cleared = False if after_qty is not None and int(after_qty) == broker_qty: try: - warn_store.clear_warned(slot_id) + warn_store.clear_warned(slot_id, case_code="B") warning_cleared = True _log.info( - "sync: reconcile-warning cleared slot=%s symbol=%s broker_qty=%d actor=sync", + "sync: reconcile-warning cleared slot=%s symbol=%s case_code=B " + "broker_qty=%d actor=sync", slot_id, sym, broker_qty, ) except Exception: # noqa: BLE001 @@ -962,7 +963,7 @@ def verify_slot_reconcile( "reason": "no active reconcile-warning (already cleared or never set)", } - # Check case_code — verify is C-only (REQ-106 OQ-C). + # Check case set — verify is C-only (REQ-106 OQ-C). info = warn_store.get_warning_info(slot_id_str) if info is None: return { @@ -974,8 +975,13 @@ def verify_slot_reconcile( "reason": "warning exists but get_warning_info returned None (unexpected)", } - case_code = info.get("case_code", "") - if case_code != "C": + case_codes_raw = info.get("case_codes") + if isinstance(case_codes_raw, list): + case_codes = {str(code) for code in case_codes_raw if code} + else: + case_code = str(info.get("case_code", "")) + case_codes = {case_code} if case_code else set() + if "C" not in case_codes: return { "portfolio_id": portfolio_id, "slot_id": slot_id_str, @@ -983,7 +989,7 @@ def verify_slot_reconcile( "hub_qty": None, "broker_qty": None, "reason": ( - f"warning case_code='{case_code}' is not 'C' — " + f"warning case_codes={sorted(case_codes)} do not include 'C' — " "use 'trex-core sync' to resolve B-case (slot↔broker qty mismatch)" ), } @@ -1014,9 +1020,17 @@ def verify_slot_reconcile( 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": + if warn_info is None: continue - warn_store.clear_warned(sid_str) + warned_codes_raw = warn_info.get("case_codes") + if isinstance(warned_codes_raw, list): + warned_codes = {str(code) for code in warned_codes_raw if code} + else: + case_code = str(warn_info.get("case_code", "")) + warned_codes = {case_code} if case_code else set() + if "C" not in warned_codes: + continue + warn_store.clear_warned(sid_str, case_code="C") store.set_warning(s.portfolio_id, False) cleared_slots.append(sid_str) _log.info( diff --git a/trex_core/runtime/cli.py b/trex_core/runtime/cli.py index 128ef21..d985ad0 100644 --- a/trex_core/runtime/cli.py +++ b/trex_core/runtime/cli.py @@ -912,7 +912,15 @@ def _cmd_verify(args, cfg) -> int: c_slots: list[str] = [] for sid in warn_store.list_warned(): info = warn_store.get_warning_info(sid) - if info and info.get("case_code") == "C": + if not info: + continue + raw_codes = info.get("case_codes") + if isinstance(raw_codes, list): + case_codes = {str(code) for code in raw_codes if code} + else: + case_code = str(info.get("case_code", "")) + case_codes = {case_code} if case_code else set() + if "C" in case_codes: c_slots.append(info.get("portfolio_id", "")) target_pids = [p for p in c_slots if p] diff --git a/trex_core/runtime/daemon.py b/trex_core/runtime/daemon.py index d232973..8af08bb 100644 --- a/trex_core/runtime/daemon.py +++ b/trex_core/runtime/daemon.py @@ -198,7 +198,7 @@ class Daemon: # BAT-010/011: per-slot watchdog (snapshot RPC writer, safety-sell reader). self.watchdog = WatchdogStore() self.pause_store = PostgreSQLPauseStore(config.dsn) - # BAT-050 / REQ-102: durable reconcile-warning store (slot_id PK). + # BAT-050 / REQ-102: durable reconcile-warning store ((slot_id, case_code) PK). self.warn_store = PostgreSQLReconcileWarningStore(config.dsn) # req-0007 / ac-0024: durable reconcile notification deduplication store. self.notification_store = PostgreSQLReconcileNotificationStore(config.dsn)