feat(core-A): reconcile-warning durable store + blocking gate (BAT-050, REQ-102/103/104/107/108)
B/C 불일치 시 슬롯을 durable 차단(ReconcileWarningStore)하고 전략 intent 경로에 pause∨reconcile_warned 게이트를 추가한다. 안전망(safety-sell/kill-switch/watchdog) 경로는 SlotService 직접 호출로 게이트를 구조적으로 우회(OQ-B 확정). SlotState enum/전이표/wire 계약 필드는 불변, reconcile_warned 필드만 additive 추가. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VHtM5Ws1HKCKQ4JGi6GRb5
This commit is contained in:
@@ -5,10 +5,13 @@ snapshot store + asset-selection service. Domain stays free of HTTP and
|
||||
RabbitMQ deps (NFR-1).
|
||||
|
||||
The snapshot is split between:
|
||||
- in-memory `SnapshotStore` universe + cash/position + warnings (process-local;
|
||||
warning is automatic/temporary, reset on restart)
|
||||
- durable `PauseStore` portfolio_id -> paused (Postgres; manual/persistent,
|
||||
survives restart and daily refresh — REQ-032)
|
||||
- in-memory `SnapshotStore` universe + cash/position + warnings (process-local;
|
||||
warning is automatic/temporary, reset on restart)
|
||||
- durable `PauseStore` portfolio_id -> paused (Postgres; manual/persistent,
|
||||
survives restart and daily refresh — REQ-032)
|
||||
- durable `ReconcileWarningStore` slot_id -> reconcile-warning (Postgres; automatic,
|
||||
set by reconcile B/C cases, cleared by sync/verify
|
||||
— REQ-102, dcy-034)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -246,6 +249,73 @@ class InMemoryKillSwitchStore:
|
||||
self._excluded.discard((portfolio_id, trading_date))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- reconcile-warning store (REQ-102)
|
||||
# BAT-050 / dcy-034: durable reconcile-warning channel — distinct from pause (manual).
|
||||
# Keyed by slot_id (TEXT UUID string). case_code: 'B' (slot↔broker qty mismatch)
|
||||
# or 'C' (ledger↔broker mismatch). Survives restart (PostgreSQL-backed in production).
|
||||
|
||||
|
||||
class ReconcileWarningStore(Protocol):
|
||||
"""Durable reconcile-warning overlay — slot_id PK (REQ-102, dcy-034)."""
|
||||
|
||||
def set_warned(
|
||||
self,
|
||||
slot_id: str,
|
||||
*,
|
||||
portfolio_id: str,
|
||||
case_code: str,
|
||||
actor: str = "reconcile",
|
||||
reason: str = "",
|
||||
) -> None:
|
||||
...
|
||||
|
||||
def clear_warned(self, slot_id: str) -> None:
|
||||
...
|
||||
|
||||
def is_warned(self, slot_id: str) -> bool:
|
||||
...
|
||||
|
||||
def list_warned(self) -> "set[str]":
|
||||
...
|
||||
|
||||
|
||||
class InMemoryReconcileWarningStore:
|
||||
"""Process-local reconcile-warning store for tests + CI (REQ-102)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
# slot_id -> {"portfolio_id", "case_code", "actor", "reason"}
|
||||
self._warned: dict[str, dict] = {}
|
||||
|
||||
def set_warned(
|
||||
self,
|
||||
slot_id: str,
|
||||
*,
|
||||
portfolio_id: str,
|
||||
case_code: str,
|
||||
actor: str = "reconcile",
|
||||
reason: str = "",
|
||||
) -> None:
|
||||
with self._lock:
|
||||
self._warned[slot_id] = {
|
||||
"portfolio_id": portfolio_id,
|
||||
"case_code": case_code,
|
||||
"actor": actor,
|
||||
"reason": reason,
|
||||
}
|
||||
|
||||
def clear_warned(self, slot_id: str) -> None:
|
||||
with self._lock:
|
||||
self._warned.pop(slot_id, None)
|
||||
|
||||
def is_warned(self, slot_id: str) -> bool:
|
||||
with self._lock:
|
||||
return slot_id in self._warned
|
||||
|
||||
def list_warned(self) -> "set[str]":
|
||||
with self._lock:
|
||||
return set(self._warned.keys())
|
||||
|
||||
|
||||
def slot_to_dict(
|
||||
s,
|
||||
@@ -253,6 +323,7 @@ def slot_to_dict(
|
||||
snapshot_store: "Optional[SnapshotStore]" = None,
|
||||
watchdog_store=None,
|
||||
pause_store: "Optional[PauseStore]" = None,
|
||||
warn_store: "Optional[ReconcileWarningStore]" = None,
|
||||
watchdog_timeout_sec: int = 120,
|
||||
) -> dict:
|
||||
"""Merged slot row for trex.slots.req (REQ-073/074).
|
||||
@@ -260,7 +331,7 @@ def slot_to_dict(
|
||||
Carries slot/core state + hub asset data + watchdog + overlay flags.
|
||||
Field-level authority: slot/core = slot_id/status/qty/avg_price/entry_price/
|
||||
forced_close_at; hub = asset_quantity/cash_balance; watchdog = watchdog{};
|
||||
overlay = paused/warned. strategy fields excluded (REQ-074 AC-5).
|
||||
overlay = paused/warned/reconcile_warned. strategy fields excluded (REQ-074 AC-5).
|
||||
"""
|
||||
d = {
|
||||
"slot_id": str(s.slot_id),
|
||||
@@ -312,6 +383,14 @@ def slot_to_dict(
|
||||
paused = s.portfolio_id in pause_store.list_paused()
|
||||
d["paused"] = paused
|
||||
|
||||
# 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()
|
||||
d["reconcile_warned"] = reconcile_warned
|
||||
|
||||
return d
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user