fix: align warning-case blocking semantics across reconcile paths

Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-09-12 13:28:40 +00:00
committed by GitHub
co-authored by genichin
parent 45a5e4157d
commit 310b6f6dec
5 changed files with 63 additions and 13 deletions
@@ -128,7 +128,7 @@ def test_ac1_single_portfolio_c_case_excluded():
assert resp.payload["slots"] == [] assert resp.payload["slots"] == []
assert resp.payload["excluded_slots"]["reconcile_quantity_mismatch"] == 1 assert resp.payload["excluded_slots"]["reconcile_quantity_mismatch"] == 1
assert resp.payload["excluded_slots"]["reconcile_warning_cases"] == {"C": 1} assert resp.payload["excluded_slots"]["active_reconcile_warning_cases"] == {"C": 1}
def test_holding_slot_with_b_case_is_excluded_for_strategy(): def test_holding_slot_with_b_case_is_excluded_for_strategy():
@@ -144,7 +144,7 @@ def test_holding_slot_with_b_case_is_excluded_for_strategy():
assert resp.payload["slots"] == [] assert resp.payload["slots"] == []
assert resp.payload["excluded_slots"]["reconcile_quantity_mismatch"] == 1 assert resp.payload["excluded_slots"]["reconcile_quantity_mismatch"] == 1
assert resp.payload["excluded_slots"]["reconcile_warning_cases"] == {"B": 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(): def test_holding_slot_with_b_and_c_cases_keeps_both_and_reports_both():
@@ -162,7 +162,7 @@ def test_holding_slot_with_b_and_c_cases_keeps_both_and_reports_both():
assert resp.payload["slots"] == [] assert resp.payload["slots"] == []
assert resp.payload["excluded_slots"]["reconcile_quantity_mismatch"] == 1 assert resp.payload["excluded_slots"]["reconcile_quantity_mismatch"] == 1
assert resp.payload["excluded_slots"]["reconcile_warning_cases"] == {"B": 1, "C": 1} assert resp.payload["excluded_slots"]["active_reconcile_warning_cases"] == {"B": 1, "C": 1}
info = warn_store.get_warning_info(slot_id) info = warn_store.get_warning_info(slot_id)
assert info is not None assert info is not None
assert info["case_codes"] == ["B", "C"] assert info["case_codes"] == ["B", "C"]
@@ -325,7 +325,7 @@ def test_ac5_excluded_slots_count_zero_when_no_warnings():
assert "excluded_slots" in resp.payload 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"]["reconcile_warning_cases"] == {} assert resp.payload["excluded_slots"]["active_reconcile_warning_cases"] == {}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -1,7 +1,30 @@
-- REQ-123 / issue #83: -- REQ-123 / issue #83:
-- Preserve simultaneous warning causes by keying warnings per (slot_id, case_code). -- 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 ALTER TABLE slot_reconcile_warning
DROP CONSTRAINT IF EXISTS slot_reconcile_warning_pkey; DROP CONSTRAINT IF EXISTS slot_reconcile_warning_pkey;
ALTER TABLE slot_reconcile_warning ALTER TABLE slot_reconcile_warning
ADD CONSTRAINT slot_reconcile_warning_pkey PRIMARY KEY (slot_id, case_code); 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);
+21 -2
View File
@@ -29,6 +29,7 @@ from typing import Any, Callable, Optional
from uuid import UUID from uuid import UUID
from trex_core.application.alarm_emitter import AlarmEmitter 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.application.ports import IntentStore
from trex_core.domain.slot import ( from trex_core.domain.slot import (
SlotAdmissionRejectedError, SlotAdmissionRejectedError,
@@ -270,6 +271,24 @@ class IntentService:
except Exception: # noqa: BLE001 - alarm path must not affect intent decision except Exception: # noqa: BLE001 - alarm path must not affect intent decision
pass 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
return bool(case_codes & BLOCKING_RECONCILE_CASES)
# ------------------------------------------------------------ # ------------------------------------------------------------
def _dedup(self, intent_id: str, intent_type: str, payload: dict) -> Optional[IntentDecision]: 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).""" """Return prior decision if intent_id already seen; else None (and reserve it)."""
@@ -339,7 +358,7 @@ class IntentService:
# those paths call SlotService.sell() directly. # those paths call SlotService.sell() directly.
if self._warn_store is not None: if self._warn_store is not None:
try: try:
if self._warn_store.is_warned(str(slot.slot_id)): if self._is_reconcile_trade_blocked(str(slot.slot_id)):
return IntentDecision( return IntentDecision(
intent.intent_id, "rejected", None, "reconcile_warned" intent.intent_id, "rejected", None, "reconcile_warned"
) )
@@ -432,7 +451,7 @@ class IntentService:
# SlotService.sell() / transit_slot() directly without IntentService (OQ-B). # SlotService.sell() / transit_slot() directly without IntentService (OQ-B).
if self._warn_store is not None: if self._warn_store is not None:
try: try:
if self._warn_store.is_warned(str(slot.slot_id)): if self._is_reconcile_trade_blocked(str(slot.slot_id)):
return IntentDecision( return IntentDecision(
intent.intent_id, "rejected", None, "reconcile_warned" intent.intent_id, "rejected", None, "reconcile_warned"
) )
+11 -5
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.broker_port import BalanceItem
from trex_core.application.messaging import RpcRequest, RpcResponse from trex_core.application.messaging import RpcRequest, RpcResponse
from trex_core.application.portfolio import ( from trex_core.application.portfolio import (
BLOCKING_RECONCILE_CASES,
PauseStore, PauseStore,
ReconcileWarningStore, ReconcileWarningStore,
SnapshotStore, SnapshotStore,
@@ -270,14 +271,15 @@ def make_slots_rpc_handler(
for sid in warned_slot_ids: for sid in warned_slot_ids:
info = warn_store.get_warning_info(sid) info = warn_store.get_warning_info(sid)
if info is None: if info is None:
continue raise RuntimeError(f"missing warning info for slot_id={sid}")
raw_codes = info.get("case_codes") raw_codes = info.get("case_codes")
if isinstance(raw_codes, list): if isinstance(raw_codes, list):
codes = {str(code) for code in raw_codes if code} codes = {str(code) for code in raw_codes if code}
else: else:
case_code = str(info.get("case_code", "")) case_code = str(info.get("case_code", ""))
codes = {case_code} if case_code else set() codes = {case_code} if case_code else set()
if codes: if not codes:
raise RuntimeError(f"empty 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")
@@ -290,7 +292,10 @@ def make_slots_rpc_handler(
"reconcile warning store unavailable; " "reconcile warning store unavailable; "
"returning no slots (fail-closed)" "returning no slots (fail-closed)"
), ),
"excluded_slots": {"reconcile_quantity_mismatch": 0}, "excluded_slots": {
"reconcile_quantity_mismatch": 0,
"active_reconcile_warning_cases": {},
},
}) })
# Build merged slot rows (REQ-074) # Build merged slot rows (REQ-074)
@@ -314,7 +319,8 @@ def make_slots_rpc_handler(
included: list[tuple[object, dict]] = [] included: list[tuple[object, dict]] = []
for slot, row in zip(slots, slot_rows, strict=True): for slot, row in zip(slots, slot_rows, strict=True):
blocked_holding = ( blocked_holding = (
slot.status.value == "holding" and bool(row.get("reconcile_warned")) slot.status.value == "holding"
and bool(set(row.get("reconcile_warning_cases", [])) & BLOCKING_RECONCILE_CASES)
) )
if blocked_holding: if blocked_holding:
excluded_count += 1 excluded_count += 1
@@ -432,7 +438,7 @@ def make_slots_rpc_handler(
if warn_store is not None: if warn_store is not None:
base_payload["excluded_slots"] = { base_payload["excluded_slots"] = {
"reconcile_quantity_mismatch": excluded_count, "reconcile_quantity_mismatch": excluded_count,
"reconcile_warning_cases": excluded_case_counts, "active_reconcile_warning_cases": excluded_case_counts,
} }
return RpcResponse(payload=base_payload) return RpcResponse(payload=base_payload)
+3 -1
View File
@@ -21,6 +21,8 @@ from datetime import UTC, datetime
from enum import Enum from enum import Enum
from typing import Protocol from typing import Protocol
BLOCKING_RECONCILE_CASES = frozenset({"B", "C"})
# ---------------------------------------------------------------- portfolio-hub # ---------------------------------------------------------------- portfolio-hub
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -658,7 +660,7 @@ 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 reconcile_warned d["trade_eligible"] = not bool(warning_cases & BLOCKING_RECONCILE_CASES)
return d return d