fix: fail closed on unknown reconcile warnings
This commit is contained in:
@@ -3,20 +3,22 @@ id: "ac-0023"
|
|||||||
status: active
|
status: active
|
||||||
---
|
---
|
||||||
|
|
||||||
# C-case quantity mismatch — strategy RPC exclusion and intent blocking
|
# B/C reconcile warning — strategy RPC exclusion and intent blocking
|
||||||
|
|
||||||
## Criterion
|
## 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
|
## Verification
|
||||||
|
|
||||||
- 성공 조건: KIS 440주, hub portfolio A 200주 + B 240주(합계 440) → A·B 모두 반환되고 `excluded_slots.reconcile_quantity_mismatch=0`.
|
- 성공 조건: 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 제외.
|
- 단일 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 허용 불가.
|
- 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 경고는 보존해야 한다.
|
- 해제: symbol 합계가 다시 일치하면 해당 symbol의 모든 **C-case 경고만** 함께 해제. 같은 symbol의 B-case 등 다른 case 경고는 보존해야 한다.
|
||||||
- 해제 대상: HOLDING 뿐 아니라 모든 상태의 slot에 걸린 C-case 경고를 포함한다.
|
- 해제 대상: HOLDING 뿐 아니라 모든 상태의 slot에 걸린 C-case 경고를 포함한다.
|
||||||
- 해제 실패: 해제 도중 예외가 발생하면 `result='clear_failed'`를 반환하고 완전 성공으로 보고하지 않아야 한다(부분 성공 = 실패).
|
- 해제 실패: 해제 도중 예외가 발생하면 `result='clear_failed'`를 반환하고 완전 성공으로 보고하지 않아야 한다(부분 성공 = 실패).
|
||||||
|
|||||||
@@ -110,13 +110,20 @@
|
|||||||
"last_seen": "ISO-8601 | null"
|
"last_seen": "ISO-8601 | null"
|
||||||
},
|
},
|
||||||
"paused": "bool",
|
"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_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
|
"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
|
"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",
|
"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
|
"message": "str | null" // sanitized — no credentials/raw exceptions
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -138,11 +145,14 @@
|
|||||||
| `warned` | 파생 | `slot_quantity != asset_quantity` (REQ-074 AC-2) |
|
| `warned` | 파생 | `slot_quantity != asset_quantity` (REQ-074 AC-2) |
|
||||||
| `watchdog` | WatchdogStore | 만료 여부 + last_seen; null=store 미연결 |
|
| `watchdog` | WatchdogStore | 만료 여부 + last_seen; null=store 미연결 |
|
||||||
| `paused` | PauseStore | durable pause overlay (REQ-032) |
|
| `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_name` | hub (PortfolioItem via SnapshotStore) | REQ-116 additive. portfolio-hub 정본. hub 미제공 시 null |
|
||||||
| `strategy_params` | hub (PortfolioItem via SnapshotStore) | REQ-116 additive. opaque dict; hub 미제공 시 {} |
|
| `strategy_params` | hub (PortfolioItem via SnapshotStore) | REQ-116 additive. opaque dict; hub 미제공 시 {} |
|
||||||
| `markets` | hub (PortfolioItem via SnapshotStore) | REQ-116 additive. list[str]; 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 참조.
|
- **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 순서다.
|
- **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=false`(기본): holding 슬롯 응답 후 `WatchdogStore.update(slot_id)` 호출 — 전략 폴링 = 생존 신호.
|
||||||
- `spy=true`: watchdog 갱신 건너뜀 — 운영자 CLI(`universe`/`slots`) 가 항상 사용.
|
- `spy=true`: watchdog 갱신 건너뜀 — 운영자 CLI(`universe`/`slots`) 가 항상 사용.
|
||||||
|
|||||||
@@ -340,6 +340,52 @@ def test_intent_service_sell_rejected_when_reconcile_warned():
|
|||||||
assert d.reason == "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():
|
def test_reconcile_warned_buy_emits_alarm_with_allowlisted_fields_after_finalize():
|
||||||
repo = InMemorySlotRepository()
|
repo = InMemorySlotRepository()
|
||||||
svc = SlotService(repo)
|
svc = SlotService(repo)
|
||||||
|
|||||||
@@ -371,6 +371,24 @@ def test_ac7_warn_store_failure_fail_closed():
|
|||||||
assert "excluded_slots" in resp.payload
|
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():
|
def test_ac23_exclusion_precedes_broker_and_strategy_state_readers():
|
||||||
repo = InMemorySlotRepository()
|
repo = InMemorySlotRepository()
|
||||||
svc = SlotService(repo)
|
svc = SlotService(repo)
|
||||||
|
|||||||
@@ -287,6 +287,11 @@ class IntentService:
|
|||||||
case_codes = {case_code} if case_code else set()
|
case_codes = {case_code} if case_code else set()
|
||||||
if not case_codes:
|
if not case_codes:
|
||||||
return True
|
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)
|
return bool(case_codes & BLOCKING_RECONCILE_CASES)
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
# ------------------------------------------------------------
|
||||||
|
|||||||
@@ -280,6 +280,8 @@ def make_slots_rpc_handler(
|
|||||||
codes = {case_code} if case_code else set()
|
codes = {case_code} if case_code else set()
|
||||||
if not codes:
|
if not codes:
|
||||||
raise RuntimeError(f"empty warning cases for slot_id={sid}")
|
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
|
warning_cases_by_slot[str(sid)] = codes
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
_log.exception("warn_store warning query failed — fail-closed")
|
_log.exception("warn_store warning query failed — fail-closed")
|
||||||
|
|||||||
@@ -660,7 +660,9 @@ def slot_to_dict(
|
|||||||
reconcile_warned = bool(warning_cases)
|
reconcile_warned = bool(warning_cases)
|
||||||
d["reconcile_warned"] = reconcile_warned
|
d["reconcile_warned"] = reconcile_warned
|
||||||
d["reconcile_warning_cases"] = sorted(warning_cases)
|
d["reconcile_warning_cases"] = sorted(warning_cases)
|
||||||
d["trade_eligible"] = not bool(warning_cases & BLOCKING_RECONCILE_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
|
return d
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user