fix: align warning-case blocking semantics across reconcile paths
Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
This commit is contained in:
co-authored by
genichin
parent
45a5e4157d
commit
310b6f6dec
@@ -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);
|
||||||
|
|||||||
@@ -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"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,15 +271,16 @@ 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:
|
||||||
warning_cases_by_slot[str(sid)] = codes
|
raise RuntimeError(f"empty warning cases for slot_id={sid}")
|
||||||
|
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")
|
||||||
return RpcResponse(payload={
|
return RpcResponse(payload={
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user