fix: preserve multi-cause reconcile warnings and align slot blocking
Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
This commit is contained in:
co-authored by
genichin
parent
e47bebacaf
commit
45a5e4157d
@@ -0,0 +1,42 @@
|
||||
"""PostgreSQLReconcileWarningStore integration tests."""
|
||||
from __future__ import annotations
|
||||
|
||||
from trex_core.adapters.portfolio.reconcile_warning_store import (
|
||||
PostgreSQLReconcileWarningStore,
|
||||
)
|
||||
|
||||
|
||||
def test_warning_store_preserves_multiple_cases_for_same_slot(pg_clean):
|
||||
store = PostgreSQLReconcileWarningStore(pg_clean)
|
||||
store.set_warned("slot-1", portfolio_id="pf-A", case_code="B", reason="b")
|
||||
store.set_warned("slot-1", portfolio_id="pf-A", case_code="C", reason="c")
|
||||
|
||||
assert store.is_warned("slot-1") is True
|
||||
info = store.get_warning_info("slot-1")
|
||||
assert info is not None
|
||||
assert info["case_codes"] == ["B", "C"]
|
||||
|
||||
|
||||
def test_warning_store_case_specific_clear_and_full_clear(pg_clean):
|
||||
store = PostgreSQLReconcileWarningStore(pg_clean)
|
||||
store.set_warned("slot-2", portfolio_id="pf-A", case_code="B")
|
||||
store.set_warned("slot-2", portfolio_id="pf-A", case_code="C")
|
||||
|
||||
store.clear_warned("slot-2", case_code="C")
|
||||
info = store.get_warning_info("slot-2")
|
||||
assert info is not None
|
||||
assert info["case_codes"] == ["B"]
|
||||
|
||||
store.clear_warned("slot-2")
|
||||
assert store.is_warned("slot-2") is False
|
||||
|
||||
|
||||
def test_warning_store_is_durable_across_reinstantiation(pg_clean):
|
||||
a = PostgreSQLReconcileWarningStore(pg_clean)
|
||||
a.set_warned("slot-restart", portfolio_id="pf-A", case_code="B")
|
||||
a.set_warned("slot-restart", portfolio_id="pf-A", case_code="C")
|
||||
|
||||
b = PostgreSQLReconcileWarningStore(pg_clean)
|
||||
info = b.get_warning_info("slot-restart")
|
||||
assert info is not None
|
||||
assert info["case_codes"] == ["B", "C"]
|
||||
@@ -896,7 +896,7 @@ def test_b_sync_does_not_publish_resolved_when_clear_warned_fails():
|
||||
from trex_core.application.reconciliation import sync_slot_to_broker
|
||||
|
||||
class FailingWarnStore(InMemoryReconcileWarningStore):
|
||||
def clear_warned(self, slot_id):
|
||||
def clear_warned(self, slot_id, *, case_code=None):
|
||||
raise RuntimeError("DB unavailable")
|
||||
|
||||
ns = InMemoryReconcileNotificationStore()
|
||||
|
||||
@@ -892,6 +892,8 @@ def test_slot_to_dict_reconcile_warned_false_when_no_warn_store():
|
||||
row = slot_to_dict(slot, warn_store=None)
|
||||
assert "reconcile_warned" in row
|
||||
assert row["reconcile_warned"] is False
|
||||
assert row["trade_eligible"] is True
|
||||
assert row["reconcile_warning_cases"] == []
|
||||
|
||||
|
||||
def test_slot_to_dict_reconcile_warned_true_when_warned():
|
||||
@@ -902,6 +904,8 @@ def test_slot_to_dict_reconcile_warned_true_when_warned():
|
||||
warn_store.set_warned(str(slot.slot_id), portfolio_id="pf-A", case_code="B")
|
||||
row = slot_to_dict(slot, warn_store=warn_store)
|
||||
assert row["reconcile_warned"] is True
|
||||
assert row["trade_eligible"] is False
|
||||
assert row["reconcile_warning_cases"] == ["B"]
|
||||
|
||||
|
||||
def test_slot_to_dict_paused_and_warned_independent():
|
||||
|
||||
@@ -33,14 +33,27 @@ def test_clear_warned_returns_false():
|
||||
assert store.is_warned("slot-1") is False
|
||||
|
||||
|
||||
# AC-3 (REQ-102): duplicate set_warned on same slot_id => upsert (single entry).
|
||||
def test_set_warned_upsert_same_slot():
|
||||
# AC-3 (REQ-123): same slot can carry multiple active warning causes.
|
||||
def test_set_warned_preserves_multiple_cases_on_same_slot():
|
||||
store = _store()
|
||||
store.set_warned("slot-1", portfolio_id="pf-A", case_code="B", reason="first")
|
||||
store.set_warned("slot-1", portfolio_id="pf-A", case_code="C", reason="second")
|
||||
# After second set, is_warned still True — single entry (no duplicate row).
|
||||
assert store.is_warned("slot-1") is True
|
||||
assert store.list_warned() == {"slot-1"}
|
||||
info = store.get_warning_info("slot-1")
|
||||
assert info is not None
|
||||
assert info["case_codes"] == ["B", "C"]
|
||||
|
||||
|
||||
def test_clear_warned_single_case_preserves_other_case():
|
||||
store = _store()
|
||||
store.set_warned("slot-1", portfolio_id="pf-A", case_code="B")
|
||||
store.set_warned("slot-1", portfolio_id="pf-A", case_code="C")
|
||||
store.clear_warned("slot-1", case_code="C")
|
||||
info = store.get_warning_info("slot-1")
|
||||
assert info is not None
|
||||
assert info["case_codes"] == ["B"]
|
||||
assert store.is_warned("slot-1") is True
|
||||
|
||||
|
||||
# AC-5 (REQ-102): list_warned returns set of warned slot_ids.
|
||||
|
||||
@@ -128,6 +128,44 @@ 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}
|
||||
|
||||
|
||||
def test_holding_slot_with_b_case_is_excluded_for_strategy():
|
||||
repo = InMemorySlotRepository()
|
||||
svc = SlotService(repo)
|
||||
warn_store = InMemoryReconcileWarningStore()
|
||||
|
||||
slot = _make_holding_slot(repo, svc, portfolio_id="pf-A", symbol=SYM_A, qty=10)
|
||||
warn_store.set_warned(str(slot.slot_id), portfolio_id="pf-A", case_code="B")
|
||||
|
||||
handler = _make_handler(repo, warn_store)
|
||||
resp = handler(_req(filter="holding"))
|
||||
|
||||
assert resp.payload["slots"] == []
|
||||
assert resp.payload["excluded_slots"]["reconcile_quantity_mismatch"] == 1
|
||||
assert resp.payload["excluded_slots"]["reconcile_warning_cases"] == {"B": 1}
|
||||
|
||||
|
||||
def test_holding_slot_with_b_and_c_cases_keeps_both_and_reports_both():
|
||||
repo = InMemorySlotRepository()
|
||||
svc = SlotService(repo)
|
||||
warn_store = InMemoryReconcileWarningStore()
|
||||
|
||||
slot = _make_holding_slot(repo, svc, portfolio_id="pf-A", symbol=SYM_A, qty=10)
|
||||
slot_id = str(slot.slot_id)
|
||||
warn_store.set_warned(slot_id, portfolio_id="pf-A", case_code="B")
|
||||
warn_store.set_warned(slot_id, portfolio_id="pf-A", case_code="C")
|
||||
|
||||
handler = _make_handler(repo, warn_store)
|
||||
resp = handler(_req(filter="holding"))
|
||||
|
||||
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}
|
||||
info = warn_store.get_warning_info(slot_id)
|
||||
assert info is not None
|
||||
assert info["case_codes"] == ["B", "C"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -286,7 +324,8 @@ def test_ac5_excluded_slots_count_zero_when_no_warnings():
|
||||
resp = handler(_req(filter="holding"))
|
||||
|
||||
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"] == {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -312,8 +351,8 @@ def test_ac6_no_warn_store_no_filtering_no_excluded_slots_key():
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class BrokenWarnStore(InMemoryReconcileWarningStore):
|
||||
"""Simulates a warn_store that fails on list_c_case_warned."""
|
||||
def list_c_case_warned(self):
|
||||
"""Simulates a warn_store that fails on warning reads."""
|
||||
def list_warned(self):
|
||||
raise RuntimeError("db connection lost")
|
||||
|
||||
|
||||
@@ -356,7 +395,7 @@ def test_ac23_exclusion_precedes_broker_and_strategy_state_readers():
|
||||
)
|
||||
response = handler(_req(filter="holding")).payload
|
||||
assert response["slots"] == []
|
||||
assert response["excluded_slots"] == {"reconcile_quantity_mismatch": 1}
|
||||
assert response["excluded_slots"]["reconcile_quantity_mismatch"] == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -199,6 +199,22 @@ def test_sync_holding_qty_mismatch_updated():
|
||||
assert not ws_out.is_warned(str(slot.slot_id))
|
||||
|
||||
|
||||
def test_sync_clears_only_b_case_when_slot_has_multiple_warning_causes():
|
||||
slot = _make_slot(SlotState.HOLDING, qty=5.0)
|
||||
ws = InMemoryReconcileWarningStore()
|
||||
slot_id = str(slot.slot_id)
|
||||
ws.set_warned(slot_id, portfolio_id="pf-005930", case_code="B")
|
||||
ws.set_warned(slot_id, portfolio_id="pf-005930", case_code="C")
|
||||
|
||||
result, _repo, ws_out = _setup(slot, {"005930": 10}, warn_store=ws)
|
||||
|
||||
assert result["warning_cleared"] is True
|
||||
assert ws_out.is_warned(slot_id) is True
|
||||
info = ws_out.get_warning_info(slot_id)
|
||||
assert info is not None
|
||||
assert info["case_codes"] == ["C"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-4: WAC seed from cost_basis_port when available
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -313,6 +313,40 @@ def test_verify_c_case_clear_preserves_b_case_warning():
|
||||
assert b_info is not None and b_info["case_code"] == "B"
|
||||
|
||||
|
||||
def test_verify_c_case_clear_on_slot_with_b_and_c_keeps_b():
|
||||
sym = "005930"
|
||||
slot = _make_slot(status=SlotState.HOLDING, symbol=sym, pid="pf-c")
|
||||
|
||||
repo = InMemorySlotRepository()
|
||||
repo.add_slot(slot)
|
||||
|
||||
ws = InMemoryReconcileWarningStore()
|
||||
slot_id = str(slot.slot_id)
|
||||
ws.set_warned(slot_id, portfolio_id="pf-c", case_code="B")
|
||||
ws.set_warned(slot_id, portfolio_id="pf-c", case_code="C")
|
||||
|
||||
store = SnapshotStore()
|
||||
store.swap([
|
||||
PortfolioItem(portfolio_id="pf-c", symbol=sym, cash_balance=0.0, asset_quantity=10.0),
|
||||
])
|
||||
broker = FakeBroker({sym: 10})
|
||||
|
||||
result = verify_slot_reconcile(
|
||||
portfolio_id="pf-c",
|
||||
store=store,
|
||||
broker=broker,
|
||||
slot_repo=repo,
|
||||
instance_key=INSTANCE,
|
||||
warn_store=ws,
|
||||
)
|
||||
|
||||
assert result["result"] == "cleared"
|
||||
assert ws.is_warned(slot_id) is True
|
||||
info = ws.get_warning_info(slot_id)
|
||||
assert info is not None
|
||||
assert info["case_codes"] == ["B"]
|
||||
|
||||
|
||||
def test_verify_c_case_clear_non_holding_slot():
|
||||
"""A non-HOLDING slot with C-case warning for the same symbol must also be cleared."""
|
||||
sym = "005930"
|
||||
@@ -360,11 +394,11 @@ class _FailAfterNStore(InMemoryReconcileWarningStore):
|
||||
self._calls = 0
|
||||
self._fail_after = fail_after
|
||||
|
||||
def clear_warned(self, slot_id: str) -> None:
|
||||
def clear_warned(self, slot_id: str, *, case_code: str | None = None) -> None:
|
||||
self._calls += 1
|
||||
if self._calls > self._fail_after:
|
||||
raise RuntimeError("simulated DB error")
|
||||
super().clear_warned(slot_id)
|
||||
super().clear_warned(slot_id, case_code=case_code)
|
||||
|
||||
|
||||
def test_verify_partial_clear_failure_returns_clear_failed():
|
||||
|
||||
@@ -418,7 +418,7 @@ def test_sync_fail_closed_skipped_error_exits_nonzero_without_writes(
|
||||
return SimpleNamespace(get_balance=lambda: []), None
|
||||
|
||||
class _WarningStore:
|
||||
def clear_warned(self, *_args):
|
||||
def clear_warned(self, *_args, **_kwargs):
|
||||
raise AssertionError("warning clear must not be called")
|
||||
|
||||
class _Connection:
|
||||
|
||||
Reference in New Issue
Block a user