Merge pull request #89 from fourblocks2020/copilot/bugfix-reconcile-warning-issue

fix(reconcile): preserve B/C warning causes per slot and unify strategy blocking with admission
This commit is contained in:
2026-09-12 23:09:39 +09:00
committed by GitHub
20 changed files with 484 additions and 108 deletions
+2 -2
View File
@@ -7,9 +7,9 @@ status: active
## Criterion
`trex.slots.req.<instance>`의 **전략용 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.<instance>`의 **전략용 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
+8 -6
View File
@@ -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>` 응답에서 제외해야 한다. 수량 대조는 **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.<instance>` 응답에서 제외해야 한다. 동일 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)을 적용하지 않는다.
+11 -1
View File
@@ -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`) 가 항상 사용.
@@ -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"]
@@ -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()
@@ -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():
@@ -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.
@@ -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
# ---------------------------------------------------------------------------
+16
View File
@@ -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
# ---------------------------------------------------------------------------
+36 -2
View File
@@ -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():
+1 -1
View File
@@ -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:
@@ -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,
@@ -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);
@@ -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:
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],
}
+26 -2
View File
@@ -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"
)
+60 -29
View File
@@ -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
# 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
]
excluded_count = len(slots) - len(included)
# 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
+57 -17
View File
@@ -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,15 +422,23 @@ 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:
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:
@@ -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] + "<redacted>"
break
return out[:200]
+24 -10
View File
@@ -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(
+9 -1
View File
@@ -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]
+1 -1
View File
@@ -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)