From 310b6f6dec34236679508da85bcd1a3dd288cd34 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 12 Sep 2026 13:28:40 +0000 Subject: [PATCH] fix: align warning-case blocking semantics across reconcile paths Co-authored-by: genichin <4282227+genichin@users.noreply.github.com> --- .../test_slots_handler_quantity_mismatch.py | 8 +++---- .../028_preserve_reconcile_warning_cases.sql | 23 +++++++++++++++++++ trex_core/application/intents.py | 23 +++++++++++++++++-- trex_core/application/messaging_handlers.py | 18 ++++++++++----- trex_core/application/portfolio.py | 4 +++- 5 files changed, 63 insertions(+), 13 deletions(-) diff --git a/tests/application/test_slots_handler_quantity_mismatch.py b/tests/application/test_slots_handler_quantity_mismatch.py index d181765..c374ced 100644 --- a/tests/application/test_slots_handler_quantity_mismatch.py +++ b/tests/application/test_slots_handler_quantity_mismatch.py @@ -128,7 +128,7 @@ 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"]["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(): @@ -144,7 +144,7 @@ def test_holding_slot_with_b_case_is_excluded_for_strategy(): assert resp.payload["slots"] == [] 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(): @@ -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["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) assert info is not None 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 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"] == {} # --------------------------------------------------------------------------- diff --git a/trex_core/adapters/persistence/sql/028_preserve_reconcile_warning_cases.sql b/trex_core/adapters/persistence/sql/028_preserve_reconcile_warning_cases.sql index 11e967d..40771bf 100644 --- a/trex_core/adapters/persistence/sql/028_preserve_reconcile_warning_cases.sql +++ b/trex_core/adapters/persistence/sql/028_preserve_reconcile_warning_cases.sql @@ -1,7 +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); diff --git a/trex_core/application/intents.py b/trex_core/application/intents.py index 1f4bf2e..26afb70 100644 --- a/trex_core/application/intents.py +++ b/trex_core/application/intents.py @@ -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,24 @@ 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 + 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 +358,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 +451,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" ) diff --git a/trex_core/application/messaging_handlers.py b/trex_core/application/messaging_handlers.py index f50c04a..527dbb1 100644 --- a/trex_core/application/messaging_handlers.py +++ b/trex_core/application/messaging_handlers.py @@ -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, @@ -270,15 +271,16 @@ def make_slots_rpc_handler( for sid in warned_slot_ids: info = warn_store.get_warning_info(sid) if info is None: - continue + 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 codes: - warning_cases_by_slot[str(sid)] = codes + if not codes: + raise RuntimeError(f"empty warning cases for slot_id={sid}") + warning_cases_by_slot[str(sid)] = codes except Exception: # noqa: BLE001 _log.exception("warn_store warning query failed — fail-closed") return RpcResponse(payload={ @@ -290,7 +292,10 @@ 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": {}, + }, }) # Build merged slot rows (REQ-074) @@ -314,7 +319,8 @@ def make_slots_rpc_handler( included: list[tuple[object, dict]] = [] for slot, row in zip(slots, slot_rows, strict=True): 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: excluded_count += 1 @@ -432,7 +438,7 @@ def make_slots_rpc_handler( if warn_store is not None: base_payload["excluded_slots"] = { "reconcile_quantity_mismatch": excluded_count, - "reconcile_warning_cases": excluded_case_counts, + "active_reconcile_warning_cases": excluded_case_counts, } return RpcResponse(payload=base_payload) diff --git a/trex_core/application/portfolio.py b/trex_core/application/portfolio.py index ad2b7c0..ba11a89 100644 --- a/trex_core/application/portfolio.py +++ b/trex_core/application/portfolio.py @@ -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) @@ -658,7 +660,7 @@ def slot_to_dict( reconcile_warned = bool(warning_cases) d["reconcile_warned"] = reconcile_warned 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