AC-0023: filter C-case quantity mismatch slots from strategy RPC, fail-closed on warn_store failure, symbol-aggregate verify clear
Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
This commit is contained in:
co-authored by
genichin
parent
d9205fe151
commit
1179bdfe23
@@ -68,6 +68,16 @@ class PostgreSQLReconcileWarningStore:
|
||||
cur.execute("SELECT slot_id FROM slot_reconcile_warning")
|
||||
return {row[0] for row in cur.fetchall()}
|
||||
|
||||
def list_c_case_warned(self) -> set[str]:
|
||||
"""Return slot_id strings with an active case_code='C' warning (AC-0023)."""
|
||||
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",
|
||||
("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)."""
|
||||
with self._psycopg.connect(self._dsn) as conn:
|
||||
|
||||
@@ -347,13 +347,53 @@ def make_slots_rpc_handler(
|
||||
if strategy_filter is not None:
|
||||
slot_rows = [r for r in slot_rows if r.get("strategy_name") == strategy_filter]
|
||||
|
||||
return RpcResponse(payload={
|
||||
# AC-0023: exclude holding slots with active case_code='C' reconcile warning.
|
||||
# Fail-closed: if warn_store exists but throws, return degraded=True / no slots.
|
||||
excluded_count = 0
|
||||
if warn_store is not None:
|
||||
try:
|
||||
c_warned_ids = warn_store.list_c_case_warned()
|
||||
except Exception: # noqa: BLE001
|
||||
_log.exception("warn_store.list_c_case_warned() failed — fail-closed")
|
||||
return RpcResponse(payload={
|
||||
"slots": [],
|
||||
"available_slots": available_slots,
|
||||
"degraded": True,
|
||||
"reason": "warn_store_unavailable",
|
||||
"message": (
|
||||
"reconcile warning store unavailable; "
|
||||
"returning no slots (fail-closed)"
|
||||
),
|
||||
"excluded_slots": {"reconcile_quantity_mismatch": 0},
|
||||
})
|
||||
if c_warned_ids:
|
||||
# Rebuild slot lookup from the original `slots` list (before strategy
|
||||
# filter reduced slot_rows). slot_rows[i] corresponds to slots[i]
|
||||
# only before filtering; use slot_id stored in each row.
|
||||
slot_by_id = {str(s.slot_id): s for s in slots}
|
||||
filtered_rows: list[dict] = []
|
||||
for row in slot_rows:
|
||||
slot_obj = slot_by_id.get(str(row.get("slot_id", "")))
|
||||
if (
|
||||
slot_obj is not None
|
||||
and str(slot_obj.slot_id) in c_warned_ids
|
||||
and slot_obj.status.value == "holding"
|
||||
):
|
||||
excluded_count += 1
|
||||
else:
|
||||
filtered_rows.append(row)
|
||||
slot_rows = filtered_rows
|
||||
|
||||
base_payload: dict = {
|
||||
"slots": slot_rows,
|
||||
"available_slots": available_slots,
|
||||
"degraded": degraded,
|
||||
"reason": reason,
|
||||
"message": message,
|
||||
})
|
||||
}
|
||||
if warn_store is not None:
|
||||
base_payload["excluded_slots"] = {"reconcile_quantity_mismatch": excluded_count}
|
||||
return RpcResponse(payload=base_payload)
|
||||
|
||||
return handler
|
||||
|
||||
|
||||
@@ -391,6 +391,13 @@ class ReconcileWarningStore(Protocol):
|
||||
"""
|
||||
...
|
||||
|
||||
def list_c_case_warned(self) -> "set[str]":
|
||||
"""Return set of slot_id strings that have an active case_code='C' warning.
|
||||
|
||||
AC-0023: used by strategy slots RPC to exclude C-case mismatched holding slots.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class InMemoryReconcileWarningStore:
|
||||
"""Process-local reconcile-warning store for tests + CI (REQ-102)."""
|
||||
@@ -434,6 +441,10 @@ class InMemoryReconcileWarningStore:
|
||||
info = self._warned.get(slot_id)
|
||||
return dict(info) if info is not None else None
|
||||
|
||||
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"}
|
||||
|
||||
|
||||
def slot_to_dict(
|
||||
s,
|
||||
|
||||
@@ -648,31 +648,38 @@ def verify_slot_reconcile(
|
||||
}
|
||||
|
||||
# Re-run the C-case check: hub qty vs broker qty for this symbol.
|
||||
# AC-0023: use symbol-level aggregate across all portfolios (not single portfolio).
|
||||
sym = slot.symbol or ""
|
||||
broker_qty_map: dict[str, int] = {
|
||||
item.symbol: int(item.hldg_qty) for item in broker.get_balance()
|
||||
}
|
||||
broker_qty = broker_qty_map.get(sym, 0)
|
||||
|
||||
# Hub qty: sum asset_quantity for this portfolio_id from snapshot.
|
||||
# Hub qty: sum asset_quantity for ALL portfolios with this symbol in the snapshot.
|
||||
hub_qty: int = 0
|
||||
snap = store.get(portfolio_id)
|
||||
if snap is not None:
|
||||
hub_qty = int(snap.item.asset_quantity)
|
||||
for a in store.list_assets():
|
||||
if a.item.symbol == sym:
|
||||
hub_qty += int(a.item.asset_quantity)
|
||||
|
||||
match = hub_qty == broker_qty
|
||||
|
||||
if match:
|
||||
# AC-0023: clear C-case warnings for ALL holding slots with this symbol.
|
||||
cleared_slots: list[str] = []
|
||||
try:
|
||||
warn_store.clear_warned(slot_id_str)
|
||||
store.set_warning(portfolio_id, False)
|
||||
for s in slot_repo.list_holding_slots(instance_key):
|
||||
if s.symbol == sym:
|
||||
sid_str = str(s.slot_id)
|
||||
warn_store.clear_warned(sid_str)
|
||||
store.set_warning(s.portfolio_id, False)
|
||||
cleared_slots.append(sid_str)
|
||||
_log.info(
|
||||
"verify: reconcile-warning cleared (C-case resolved) "
|
||||
"slot=%s portfolio_id=%s symbol=%s hub_qty=%d broker_qty=%d",
|
||||
slot_id_str, portfolio_id, sym, hub_qty, broker_qty,
|
||||
"symbol=%s hub_qty=%d broker_qty=%d cleared_slots=%s",
|
||||
sym, hub_qty, broker_qty, cleared_slots,
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
_log.exception("verify: failed to clear_warned for slot=%s", slot_id_str)
|
||||
_log.exception("verify: failed to clear_warned for symbol=%s", sym)
|
||||
return {
|
||||
"portfolio_id": portfolio_id,
|
||||
"slot_id": slot_id_str,
|
||||
|
||||
Reference in New Issue
Block a user