Files
trex-core/tests/application/test_reconcile_warning_blocking.py
T

977 lines
37 KiB
Python

"""Blocking reconcile B/C cases + gate tests (REQ-103, REQ-104, REQ-107, REQ-108, BAT-050)."""
from __future__ import annotations
import logging
import pytest
from trex_core.application.broker_port import BalanceItem
from trex_core.application.intents import BuyIntent, IntentService, SellIntent
from trex_core.application.messaging import RpcRequest
from trex_core.application.messaging_handlers import make_slots_rpc_handler
from trex_core.application.portfolio import (
InMemoryPauseStore,
InMemoryReconcileWarningStore,
PortfolioItem,
SnapshotStore,
slot_to_dict,
)
from trex_core.application.reconciliation import (
reconcile_core_holdings,
reconcile_positions,
)
from trex_core.domain.slot import SlotService, SlotState
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
class FakeBroker:
def __init__(self, balances):
self._b = balances
def name(self): return "fake"
def submit_order(self, req): ...
def poll_fill(self, *args, **kw): ...
def cancel_order(self, *args, **kw): ...
def get_balance(self): return list(self._b)
class InMemoryIntentStore:
"""Minimal in-memory intent store for tests."""
def __init__(self):
self._db: dict = {}
def try_insert(self, intent_id, intent_type, payload):
if intent_id in self._db:
return False
self._db[intent_id] = {"status": None, "slot_id": None, "reason": None}
return True
def get(self, intent_id):
rec = self._db.get(intent_id)
if rec is None:
return None
class R:
def __init__(self, d): self.__dict__.update(d)
r = R(rec)
r.intent_id = intent_id
return r
def finalize(self, intent_id, status, slot_id, reason):
self._db[intent_id] = {"status": status, "slot_id": slot_id, "reason": reason}
class RecordingIntentStore(InMemoryIntentStore):
def __init__(self, trace=None, fail_finalize=False):
super().__init__()
self._trace = trace
self._fail_finalize = fail_finalize
def finalize(self, intent_id, status, slot_id, reason):
if self._trace is not None:
self._trace.append("finalize")
if self._fail_finalize:
raise RuntimeError("finalize failed")
super().finalize(intent_id, status, slot_id, reason)
class RecordingAlarmEmitter:
def __init__(self, *, trace=None, return_value=True, raise_error=False):
self.calls = []
self._trace = trace
self._return_value = return_value
self._raise_error = raise_error
def publish_alarm(self, code, title, *, fields=None, severity="critical"):
if self._trace is not None:
self._trace.append("publish")
self.calls.append((code, title, severity, fields or {}))
if self._raise_error:
raise RuntimeError("transport down")
return self._return_value
class DuplicateInFlightIntentStore:
def try_insert(self, intent_id, intent_type, payload):
return False
def get(self, intent_id):
return None
def finalize(self, intent_id, status, slot_id, reason):
raise AssertionError("finalize must not be called for duplicate_in_flight")
class PostFinalizeContextFailingSlotRepository(InMemorySlotRepository):
"""Raise fetch_slot only after explicitly armed by finalize."""
def __init__(self):
super().__init__()
self._raise_on_fetch = False
def fetch_slot(self, slot_id):
if self._raise_on_fetch:
raise RuntimeError("context lookup failed")
return super().fetch_slot(slot_id)
class FinalizeArmingIntentStore(InMemoryIntentStore):
"""Arm repo fetch failure immediately after finalize persistence."""
def __init__(self, repo):
super().__init__()
self._repo = repo
def finalize(self, intent_id, status, slot_id, reason):
super().finalize(intent_id, status, slot_id, reason)
self._repo._raise_on_fetch = True
def _make_holding_slot(repo, svc, instance_key="default", portfolio_id="pf-A", symbol="005930", qty=10):
"""Helper: creates universe, puts slot in HOLDING state with given qty."""
repo.reconcile_universe(instance_key, {portfolio_id: symbol})
slot = repo.find_slot_by_portfolio(instance_key, portfolio_id)
pending = svc.transit_slot(
slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
reason="buy", actor="test", symbol=symbol,
)
holding = svc.transit_slot(
pending.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
reason="filled", actor="test", avg_price=100.0, qty=float(qty),
)
return holding
def _store_with(items):
store = SnapshotStore()
store.swap(items)
return store
# ===========================================================================
# REQ-103 AC-1: B case (slot qty != broker qty) => warn_store.set_warned(case_code='B')
# ===========================================================================
def test_reconcile_core_holdings_b_case_sets_warned():
repo = InMemorySlotRepository()
svc = SlotService(repo)
warn_store = InMemoryReconcileWarningStore()
slot = _make_holding_slot(repo, svc, qty=10)
# Broker has different qty (5 != 10) => B case
broker = FakeBroker([BalanceItem(symbol="005930", hldg_qty=5)])
decisions = reconcile_core_holdings(
slot_repo=repo, slot_service=svc, broker=broker,
instance_key="default", warn_store=warn_store,
)
slot_id_str = str(slot.slot_id)
assert decisions[slot_id_str] == "qty_warning"
assert warn_store.is_warned(slot_id_str) is True
# REQ-103 AC-1: B case stores case_code='B'
def test_reconcile_core_holdings_b_case_code():
repo = InMemorySlotRepository()
svc = SlotService(repo)
warn_store = InMemoryReconcileWarningStore()
slot = _make_holding_slot(repo, svc, qty=10)
broker = FakeBroker([BalanceItem(symbol="005930", hldg_qty=3)])
reconcile_core_holdings(
slot_repo=repo, slot_service=svc, broker=broker,
instance_key="default", warn_store=warn_store,
)
assert warn_store.list_warned() == {str(slot.slot_id)}
# REQ-103 AC-3: matching qty => warn_store NOT set
def test_reconcile_core_holdings_matching_qty_no_warn(caplog):
repo = InMemorySlotRepository()
svc = SlotService(repo)
warn_store = InMemoryReconcileWarningStore()
_make_holding_slot(repo, svc, qty=10)
broker = FakeBroker([BalanceItem(symbol="005930", hldg_qty=10)]) # match
decisions = reconcile_core_holdings(
slot_repo=repo, slot_service=svc, broker=broker,
instance_key="default", warn_store=warn_store,
)
assert warn_store.list_warned() == set() # S-4: no false positive
assert all(v == "ok" for v in decisions.values() if v != "unmanaged_position")
# REQ-103 AC-4: A case (broker 0) => ERROR transition, NOT warn_store
def test_reconcile_core_holdings_a_case_error_not_warned():
repo = InMemorySlotRepository()
svc = SlotService(repo)
warn_store = InMemoryReconcileWarningStore()
slot = _make_holding_slot(repo, svc, qty=10)
broker = FakeBroker([]) # broker flat (qty=0)
decisions = reconcile_core_holdings(
slot_repo=repo, slot_service=svc, broker=broker,
instance_key="default", warn_store=warn_store,
)
slot_id_str = str(slot.slot_id)
assert decisions[slot_id_str] == "error"
assert warn_store.is_warned(slot_id_str) is False # A uses error, not warn_store
# REQ-103 AC-5: warn_store=None => backward compat, no exception
def test_reconcile_core_holdings_no_warn_store_backward_compat():
repo = InMemorySlotRepository()
svc = SlotService(repo)
_make_holding_slot(repo, svc, qty=10)
broker = FakeBroker([BalanceItem(symbol="005930", hldg_qty=5)]) # mismatch
decisions = reconcile_core_holdings(
slot_repo=repo, slot_service=svc, broker=broker,
instance_key="default",
warn_store=None, # no store
)
# Still returns qty_warning, no exception
assert any(v == "qty_warning" for v in decisions.values())
# ===========================================================================
# REQ-103 AC-2: C case (hub qty != broker qty) => warn_store.set_warned(case_code='C')
# ===========================================================================
def test_reconcile_positions_c_case_sets_warned():
repo = InMemorySlotRepository()
svc = SlotService(repo)
warn_store = InMemoryReconcileWarningStore()
slot = _make_holding_slot(repo, svc, qty=10, portfolio_id="pf-A")
store = _store_with([
PortfolioItem(portfolio_id="pf-A", symbol="005930", cash_balance=0, asset_quantity=10),
])
broker = FakeBroker([BalanceItem(symbol="005930", hldg_qty=7)]) # hub=10, broker=7 => C
decisions = reconcile_positions(
store=store, broker=broker,
slot_repo=repo, instance_key="default",
warn_store=warn_store,
)
assert decisions["pf-A"] == "warning"
assert warn_store.is_warned(str(slot.slot_id)) is True
# REQ-103 AC-3: C case matching => no warn
def test_reconcile_positions_c_case_matching_no_warn():
repo = InMemorySlotRepository()
svc = SlotService(repo)
warn_store = InMemoryReconcileWarningStore()
_make_holding_slot(repo, svc, qty=10, portfolio_id="pf-A")
store = _store_with([
PortfolioItem(portfolio_id="pf-A", symbol="005930", cash_balance=0, asset_quantity=10),
])
broker = FakeBroker([BalanceItem(symbol="005930", hldg_qty=10)]) # match
decisions = reconcile_positions(
store=store, broker=broker,
slot_repo=repo, instance_key="default",
warn_store=warn_store,
)
assert decisions["pf-A"] == "ok"
assert warn_store.list_warned() == set() # S-4: no false positive
# REQ-103 AC-5: C case with warn_store=None => backward compat
def test_reconcile_positions_no_warn_store_backward_compat():
store = _store_with([
PortfolioItem(portfolio_id="pf-A", symbol="005930", cash_balance=0, asset_quantity=10),
])
broker = FakeBroker([BalanceItem(symbol="005930", hldg_qty=7)])
decisions = reconcile_positions(store=store, broker=broker, warn_store=None)
# in-memory warning still set on store
assert decisions["pf-A"] == "warning"
assert store.warnings() == {"pf-A"}
# ===========================================================================
# REQ-104 AC-1: reconcile-warned slot => buy intent rejected
# ===========================================================================
def test_intent_service_buy_rejected_when_reconcile_warned():
repo = InMemorySlotRepository()
svc = SlotService(repo)
warn_store = InMemoryReconcileWarningStore()
intent_store = InMemoryIntentStore()
repo.reconcile_universe("default", {"pf-A": "005930"})
slot = repo.find_slot_by_portfolio("default", "pf-A")
warn_store.set_warned(str(slot.slot_id), portfolio_id="pf-A", case_code="B")
intent_svc = IntentService(
svc, repo, intent_store,
instance_key="default",
warn_store=warn_store,
)
d = intent_svc.submit_buy(BuyIntent(
intent_id="b-001",
slot_id=slot.slot_id,
entry_price=100.0,
qty=10.0,
))
assert d.status == "rejected"
assert d.reason == "reconcile_warned"
# REQ-104 AC-2: reconcile-warned HOLDING slot => sell intent rejected (external path)
def test_intent_service_sell_rejected_when_reconcile_warned():
repo = InMemorySlotRepository()
svc = SlotService(repo)
warn_store = InMemoryReconcileWarningStore()
intent_store = InMemoryIntentStore()
slot = _make_holding_slot(repo, svc, qty=10)
warn_store.set_warned(str(slot.slot_id), portfolio_id="pf-A", case_code="B")
intent_svc = IntentService(
svc, repo, intent_store,
instance_key="default",
warn_store=warn_store,
)
d = intent_svc.submit_sell(SellIntent(
intent_id="s-001",
slot_id=slot.slot_id,
reason="strategy_exit",
))
assert d.status == "rejected"
assert d.reason == "reconcile_warned"
def test_reconcile_warned_buy_emits_alarm_with_allowlisted_fields_after_finalize():
repo = InMemorySlotRepository()
svc = SlotService(repo)
warn_store = InMemoryReconcileWarningStore()
trace = []
intent_store = RecordingIntentStore(trace=trace)
alarms = RecordingAlarmEmitter(trace=trace)
repo.reconcile_universe("default", {"pf-A": "005930"})
slot = repo.find_slot_by_portfolio("default", "pf-A")
warn_store.set_warned(str(slot.slot_id), portfolio_id="pf-A", case_code="B")
intent_svc = IntentService(
svc, repo, intent_store,
instance_key="default",
warn_store=warn_store,
alarm_emitter=alarms,
)
d = intent_svc.submit_buy(BuyIntent(
intent_id="b-alert-001",
slot_id=slot.slot_id,
entry_price=100.0,
qty=10.0,
strategy_id="stg-buy",
))
assert d.status == "rejected"
assert d.reason == "reconcile_warned"
assert trace == ["finalize", "publish"]
assert len(alarms.calls) == 1
code, title, severity, fields = alarms.calls[0]
assert code == "strategy_intent_rejected"
assert severity == "warn"
assert title
assert fields["instance_key"] == "default"
assert fields["intent_id"] == "b-alert-001"
assert fields["intent_type"] == "buy"
assert fields["reason"] == "reconcile_warned"
assert fields["broker_submitted"] is False
assert fields["slot_id"] == str(slot.slot_id)
assert fields["portfolio_id"] == "pf-A"
assert fields["symbol"] == "005930"
assert fields["strategy_id"] == "stg-buy"
for blocked in ("qty", "entry_price", "limit_price", "venue", "extra", "sell_strategy_id"):
assert blocked not in fields
text = repr((title, fields))
for forbidden in ("account", "token", "password", "secret", "dsn", "connection", "raw_payload"):
assert forbidden not in text.lower()
def test_reconcile_warned_sell_emits_alarm_with_normalized_strategy_id():
repo = InMemorySlotRepository()
svc = SlotService(repo)
warn_store = InMemoryReconcileWarningStore()
intent_store = InMemoryIntentStore()
alarms = RecordingAlarmEmitter()
slot = _make_holding_slot(repo, svc, qty=10, portfolio_id="pf-A", symbol="005930")
warn_store.set_warned(str(slot.slot_id), portfolio_id="pf-A", case_code="C")
intent_svc = IntentService(
svc, repo, intent_store,
instance_key="default",
warn_store=warn_store,
alarm_emitter=alarms,
)
d = intent_svc.submit_sell(SellIntent(
intent_id="s-alert-001",
slot_id=slot.slot_id,
reason="strategy_exit",
sell_strategy_id="stg-sell",
))
assert d.status == "rejected"
assert d.reason == "reconcile_warned"
assert len(alarms.calls) == 1
_, _, severity, fields = alarms.calls[0]
assert severity == "warn"
assert fields["intent_type"] == "sell"
assert fields["strategy_id"] == "stg-sell"
assert fields["slot_id"] == str(slot.slot_id)
def test_reconcile_warned_alarm_dedup_by_intent_id_across_service_recreation():
repo = InMemorySlotRepository()
svc = SlotService(repo)
warn_store = InMemoryReconcileWarningStore()
intent_store = InMemoryIntentStore()
alarms = RecordingAlarmEmitter()
repo.reconcile_universe("default", {"pf-A": "005930"})
slot = repo.find_slot_by_portfolio("default", "pf-A")
warn_store.set_warned(str(slot.slot_id), portfolio_id="pf-A", case_code="B")
s1 = IntentService(
svc, repo, intent_store, instance_key="default", warn_store=warn_store, alarm_emitter=alarms
)
s2 = IntentService(
svc, repo, intent_store, instance_key="default", warn_store=warn_store, alarm_emitter=alarms
)
first = s1.submit_buy(BuyIntent(intent_id="b-dedup", slot_id=slot.slot_id))
second = s1.submit_buy(BuyIntent(intent_id="b-dedup", slot_id=slot.slot_id))
third = s2.submit_buy(BuyIntent(intent_id="b-dedup", slot_id=slot.slot_id))
assert first.reason == "reconcile_warned"
assert second.reason == "reconcile_warned"
assert third.reason == "reconcile_warned"
assert len(alarms.calls) == 1
def test_reconcile_warned_alarm_emits_per_distinct_intent_id_on_same_slot():
repo = InMemorySlotRepository()
svc = SlotService(repo)
warn_store = InMemoryReconcileWarningStore()
intent_store = InMemoryIntentStore()
alarms = RecordingAlarmEmitter()
repo.reconcile_universe("default", {"pf-A": "005930"})
slot = repo.find_slot_by_portfolio("default", "pf-A")
warn_store.set_warned(str(slot.slot_id), portfolio_id="pf-A", case_code="B")
intent_svc = IntentService(
svc, repo, intent_store, instance_key="default", warn_store=warn_store, alarm_emitter=alarms
)
d1 = intent_svc.submit_buy(BuyIntent(intent_id="b-uniq-1", slot_id=slot.slot_id))
d2 = intent_svc.submit_buy(BuyIntent(intent_id="b-uniq-2", slot_id=slot.slot_id))
assert d1.reason == "reconcile_warned"
assert d2.reason == "reconcile_warned"
assert len(alarms.calls) == 2
def test_reconcile_warned_alarm_not_published_for_duplicate_in_flight_or_other_decisions():
repo = InMemorySlotRepository()
svc = SlotService(repo)
alarms = RecordingAlarmEmitter()
duplicate = IntentService(
svc, repo, DuplicateInFlightIntentStore(), instance_key="default", alarm_emitter=alarms
)
d_dup = duplicate.submit_buy(BuyIntent(intent_id="dup", portfolio_id="pf-A"))
assert d_dup.reason == "duplicate_in_flight"
assert alarms.calls == []
repo.reconcile_universe("default", {"pf-A": "005930"})
slot = repo.find_slot_by_portfolio("default", "pf-A")
normal = IntentService(
svc, repo, InMemoryIntentStore(), instance_key="default", alarm_emitter=alarms
)
admitted = normal.submit_buy(BuyIntent(intent_id="admitted", slot_id=slot.slot_id, qty=1, entry_price=100))
assert admitted.status == "admitted"
invalid = normal.submit_buy(BuyIntent(intent_id="bad-venue", slot_id=slot.slot_id, venue="BAD"))
assert invalid.reason == "invalid_venue"
repo.reconcile_universe("default", {"pf-A": "005930", "pf-B": "000660"})
slot_b = repo.find_slot_by_portfolio("default", "pf-B")
capped = IntentService(
svc, repo, InMemoryIntentStore(), instance_key="default", max_active_slots=0, alarm_emitter=alarms
)
full = capped.submit_buy(BuyIntent(intent_id="full", slot_id=slot_b.slot_id))
assert full.reason == "capacity_full"
assert alarms.calls == []
def test_reconcile_warned_alarm_finalize_failure_and_publish_failure_are_isolated():
repo = InMemorySlotRepository()
svc = SlotService(repo)
warn_store = InMemoryReconcileWarningStore()
repo.reconcile_universe("default", {"pf-A": "005930"})
slot = repo.find_slot_by_portfolio("default", "pf-A")
warn_store.set_warned(str(slot.slot_id), portfolio_id="pf-A", case_code="B")
alarms = RecordingAlarmEmitter()
failing_store = RecordingIntentStore(fail_finalize=True)
failing_finalize = IntentService(
svc, repo, failing_store,
instance_key="default",
warn_store=warn_store,
alarm_emitter=alarms,
)
with pytest.raises(RuntimeError, match="finalize failed"):
failing_finalize.submit_buy(BuyIntent(intent_id="b-finalize-fail", slot_id=slot.slot_id))
assert alarms.calls == []
soft_fail_alarm = RecordingAlarmEmitter(return_value=False)
raising_alarm = RecordingAlarmEmitter(raise_error=True)
non_blocking_1 = IntentService(
svc, repo, InMemoryIntentStore(), instance_key="default", warn_store=warn_store, alarm_emitter=soft_fail_alarm
)
non_blocking_2 = IntentService(
svc, repo, InMemoryIntentStore(), instance_key="default", warn_store=warn_store, alarm_emitter=raising_alarm
)
d1 = non_blocking_1.submit_buy(BuyIntent(intent_id="b-alarm-false", slot_id=slot.slot_id))
d2 = non_blocking_2.submit_buy(BuyIntent(intent_id="b-alarm-exc", slot_id=slot.slot_id))
d3 = IntentService(
svc, repo, InMemoryIntentStore(), instance_key="default", warn_store=warn_store, alarm_emitter=None
).submit_buy(BuyIntent(intent_id="b-no-emitter", slot_id=slot.slot_id))
assert d1.reason == "reconcile_warned"
assert d2.reason == "reconcile_warned"
assert d3.reason == "reconcile_warned"
def test_reconcile_warned_buy_context_lookup_failure_does_not_change_response():
repo = PostFinalizeContextFailingSlotRepository()
svc = SlotService(repo)
warn_store = InMemoryReconcileWarningStore()
intent_store = FinalizeArmingIntentStore(repo)
alarms = RecordingAlarmEmitter()
repo.reconcile_universe("default", {"pf-A": "005930"})
slot = repo.find_slot_by_portfolio("default", "pf-A")
warn_store.set_warned(str(slot.slot_id), portfolio_id="pf-A", case_code="B")
intent_svc = IntentService(
svc, repo, intent_store, instance_key="default", warn_store=warn_store, alarm_emitter=alarms
)
decision = intent_svc.submit_buy(
BuyIntent(intent_id="buy-context-fail", slot_id=slot.slot_id, strategy_id="stg-buy")
)
assert decision.status == "rejected"
assert decision.reason == "reconcile_warned"
assert len(alarms.calls) == 1
_, _, _, fields = alarms.calls[0]
assert fields["intent_id"] == "buy-context-fail"
assert fields["strategy_id"] == "stg-buy"
assert "slot_id" not in fields
assert "portfolio_id" not in fields
assert "symbol" not in fields
def test_reconcile_warned_sell_context_lookup_failure_does_not_change_response():
repo = PostFinalizeContextFailingSlotRepository()
svc = SlotService(repo)
warn_store = InMemoryReconcileWarningStore()
intent_store = FinalizeArmingIntentStore(repo)
alarms = RecordingAlarmEmitter()
slot = _make_holding_slot(repo, svc, qty=10, portfolio_id="pf-A", symbol="005930")
warn_store.set_warned(str(slot.slot_id), portfolio_id="pf-A", case_code="C")
intent_svc = IntentService(
svc, repo, intent_store, instance_key="default", warn_store=warn_store, alarm_emitter=alarms
)
decision = intent_svc.submit_sell(
SellIntent(intent_id="sell-context-fail", slot_id=slot.slot_id, sell_strategy_id="stg-sell")
)
assert decision.status == "rejected"
assert decision.reason == "reconcile_warned"
assert len(alarms.calls) == 1
_, _, _, fields = alarms.calls[0]
assert fields["intent_id"] == "sell-context-fail"
assert fields["strategy_id"] == "stg-sell"
assert "slot_id" not in fields
assert "portfolio_id" not in fields
assert "symbol" not in fields
# REQ-104 AC-3: no warn, no pause => buy/sell admitted normally
def test_intent_service_no_warn_admitted_normally():
repo = InMemorySlotRepository()
svc = SlotService(repo)
warn_store = InMemoryReconcileWarningStore()
intent_store = InMemoryIntentStore()
repo.reconcile_universe("default", {"pf-A": "005930"})
slot = repo.find_slot_by_portfolio("default", "pf-A")
intent_svc = IntentService(
svc, repo, intent_store,
instance_key="default",
warn_store=warn_store,
)
d = intent_svc.submit_buy(BuyIntent(
intent_id="b-002",
slot_id=slot.slot_id,
entry_price=100.0,
qty=10.0,
))
assert d.status == "admitted"
# REQ-104: warn_store=None => gate skipped, backward compat
def test_intent_service_no_warn_store_backward_compat():
repo = InMemorySlotRepository()
svc = SlotService(repo)
intent_store = InMemoryIntentStore()
repo.reconcile_universe("default", {"pf-A": "005930"})
slot = repo.find_slot_by_portfolio("default", "pf-A")
intent_svc = IntentService(
svc, repo, intent_store,
instance_key="default",
warn_store=None, # no gate
)
d = intent_svc.submit_buy(BuyIntent(
intent_id="b-003",
slot_id=slot.slot_id,
entry_price=100.0,
qty=10.0,
))
assert d.status == "admitted"
# ===========================================================================
# REQ-104: safety-sell / kill-switch / watchdog bypass (OQ-B 확정)
# Internal path: SlotService.sell() / transit_slot() directly — NOT IntentService
# ===========================================================================
def test_safety_sell_bypasses_reconcile_warning_gate():
"""Safety-sell calls SlotService directly — reconcile_warned slot can still be sold."""
repo = InMemorySlotRepository()
svc = SlotService(repo)
warn_store = InMemoryReconcileWarningStore()
slot = _make_holding_slot(repo, svc, qty=10)
warn_store.set_warned(str(slot.slot_id), portfolio_id="pf-A", case_code="B")
# Safety-sell path: SlotService.sell() directly (no IntentService gate)
# Verify that transit_slot HOLDING -> PENDING_SELL succeeds regardless of warn_store.
updated = svc.transit_slot(
slot.slot_id,
SlotState.HOLDING,
SlotState.PENDING_SELL,
reason="safety_sell",
actor="trex-safety-sell",
)
assert updated.status == SlotState.PENDING_SELL
# reconcile_warned flag unchanged — sell went through
assert warn_store.is_warned(str(slot.slot_id)) is True
# ===========================================================================
# REQ-104 AC-4/5/6/7: slots RPC reconcile_warned field
# ===========================================================================
def _wire_with_warn(warned_slot_ids=None):
repo = InMemorySlotRepository()
svc = SlotService(repo)
repo.reconcile_universe("default", {"pf-A": "SYM-A", "pf-B": "SYM-B"})
store = SnapshotStore()
store.swap([
PortfolioItem(portfolio_id="pf-A", symbol="SYM-A", cash_balance=0, asset_quantity=0),
PortfolioItem(portfolio_id="pf-B", symbol="SYM-B", cash_balance=0, asset_quantity=0),
])
pause = InMemoryPauseStore()
warn_store = InMemoryReconcileWarningStore()
if warned_slot_ids:
for sid in warned_slot_ids:
warn_store.set_warned(sid, portfolio_id="pf-A", case_code="B")
handler = make_slots_rpc_handler(
repo,
snapshot_store=store,
pause_store=pause,
warn_store=warn_store,
)
return repo, svc, handler, pause, warn_store
def _req(filter=None):
return RpcRequest(payload={"filter": filter or "all", "spy": True}, correlation_id="t")
# REQ-104 AC-5: reconcile_warned=True for warned slot
def test_slots_rpc_reconcile_warned_true():
repo, svc, handler, _, warn_store = _wire_with_warn()
slot_a = repo.find_slot_by_portfolio("default", "pf-A")
warn_store.set_warned(str(slot_a.slot_id), portfolio_id="pf-A", case_code="B")
resp = handler(_req())
rows = {r["portfolio_id"]: r for r in resp.payload["slots"]}
assert rows["pf-A"]["reconcile_warned"] is True
assert rows["pf-B"]["reconcile_warned"] is False
# REQ-104 AC-4: pause only => paused=True, reconcile_warned=False
def test_slots_rpc_paused_only():
repo, svc, handler, pause, _ = _wire_with_warn()
pause.pause("pf-A")
resp = handler(_req())
rows = {r["portfolio_id"]: r for r in resp.payload["slots"]}
assert rows["pf-A"]["paused"] is True
assert rows["pf-A"]["reconcile_warned"] is False
# REQ-104 AC-6: both pause + reconcile_warned
def test_slots_rpc_both_pause_and_reconcile_warned():
repo, svc, handler, pause, warn_store = _wire_with_warn()
slot_a = repo.find_slot_by_portfolio("default", "pf-A")
pause.pause("pf-A")
warn_store.set_warned(str(slot_a.slot_id), portfolio_id="pf-A", case_code="C")
resp = handler(_req())
rows = {r["portfolio_id"]: r for r in resp.payload["slots"]}
assert rows["pf-A"]["paused"] is True
assert rows["pf-A"]["reconcile_warned"] is True
# REQ-104 AC-7: warn_store=None => reconcile_warned=False (backward compat, NFR-4)
def test_slots_rpc_no_warn_store_reconcile_warned_false():
repo = InMemorySlotRepository()
repo.reconcile_universe("default", {"pf-A": "SYM"})
handler = make_slots_rpc_handler(repo, warn_store=None)
resp = handler(_req())
for row in resp.payload["slots"]:
assert row["reconcile_warned"] is False
# REQ-104 AC-8: existing 'warned' field unchanged
def test_slots_rpc_existing_warned_field_unchanged():
"""warned field (slot qty vs hub asset_quantity divergence) must not be affected."""
repo = InMemorySlotRepository()
svc = SlotService(repo)
repo.reconcile_universe("default", {"pf-A": "SYM"})
store = SnapshotStore()
store.swap([
PortfolioItem(portfolio_id="pf-A", symbol="SYM", cash_balance=0, asset_quantity=5),
])
slot_a = repo.find_slot_by_portfolio("default", "pf-A")
pending = svc.transit_slot(slot_a.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
reason="buy", actor="test", symbol="SYM")
svc.transit_slot(pending.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
reason="filled", actor="test", avg_price=100.0, qty=10.0)
warn_store = InMemoryReconcileWarningStore()
handler = make_slots_rpc_handler(repo, snapshot_store=store, warn_store=warn_store)
resp = handler(RpcRequest(payload={"filter": "holding", "spy": True}, correlation_id="t"))
row = resp.payload["slots"][0]
# slot.qty=10 != hub asset_quantity=5 => warned=True (existing field)
assert row["warned"] is True
# reconcile_warned is separate
assert row["reconcile_warned"] is False
# ===========================================================================
# REQ-107: logging fields
# ===========================================================================
def test_reconcile_core_holdings_b_case_logging(caplog):
repo = InMemorySlotRepository()
svc = SlotService(repo)
warn_store = InMemoryReconcileWarningStore()
_make_holding_slot(repo, svc, qty=10)
broker = FakeBroker([BalanceItem(symbol="005930", hldg_qty=5)])
with caplog.at_level(logging.WARNING, logger="trex_core.application.reconciliation"):
reconcile_core_holdings(
slot_repo=repo, slot_service=svc, broker=broker,
instance_key="default", warn_store=warn_store,
)
log_text = caplog.text
assert "reconcile_warning set" in log_text
assert "case_code=B" in log_text
assert "broker_qty=5" in log_text
def test_reconcile_positions_c_case_logging(caplog):
repo = InMemorySlotRepository()
svc = SlotService(repo)
warn_store = InMemoryReconcileWarningStore()
_make_holding_slot(repo, svc, qty=10, portfolio_id="pf-A")
store = _store_with([
PortfolioItem(portfolio_id="pf-A", symbol="005930", cash_balance=0, asset_quantity=10),
])
broker = FakeBroker([BalanceItem(symbol="005930", hldg_qty=7)])
with caplog.at_level(logging.WARNING, logger="trex_core.application.reconciliation"):
reconcile_positions(
store=store, broker=broker,
slot_repo=repo, instance_key="default",
warn_store=warn_store,
)
log_text = caplog.text
assert "reconcile_warning set" in log_text
assert "case_code=C" in log_text
# ===========================================================================
# REQ-108: SlotState enum + ALLOWED_TRANSITIONS invariant (NFR-4)
# ===========================================================================
def test_slot_state_enum_unchanged():
"""SlotState enum has exactly the same 5 values as before BAT-050."""
from trex_core.domain.slot.state import SlotState
expected = {"inactive", "pending_buy", "holding", "pending_sell", "error"}
assert {s.value for s in SlotState} == expected
def test_allowed_transitions_count():
"""ALLOWED_TRANSITIONS count: 7 base + 4 to_error + 2 from_error
+ 1 sync-onboarding (BAT-051 REQ-105 OQ-A: INACTIVE→HOLDING) = 14.
BAT-050 did not add transitions; BAT-051 added exactly 1 (INACTIVE→HOLDING)
for cold-start onboarding (actor=sync, wire contract unchanged).
SlotState enum remains 5 values.
"""
from trex_core.domain.slot.state import ALLOWED_TRANSITIONS, SlotState
# 14 = 7 base + 4 to_error + 2 from_error + 1 sync-onboarding (BAT-051)
assert len(ALLOWED_TRANSITIONS) == 14
# BAT-051 REQ-105 OQ-A: INACTIVE→HOLDING is now allowed (sync-only actor)
assert (SlotState.INACTIVE, SlotState.HOLDING) in ALLOWED_TRANSITIONS
# SlotState enum still 5 values — wire contract unchanged
assert len(list(SlotState)) == 5
# NFR-2: short-term instance normal scenario (slot qty == broker qty) => no warn
def test_short_term_instance_normal_no_warn():
"""M1/M2 daily slot matching scenario — no false positive blocking (S-4, NFR-2)."""
repo = InMemorySlotRepository()
svc = SlotService(repo)
warn_store = InMemoryReconcileWarningStore()
_make_holding_slot(repo, svc, qty=10, portfolio_id="pf-A")
store = _store_with([
PortfolioItem(portfolio_id="pf-A", symbol="005930", cash_balance=0, asset_quantity=10),
])
broker = FakeBroker([BalanceItem(symbol="005930", hldg_qty=10)]) # perfect match
reconcile_positions(
store=store, broker=broker,
slot_repo=repo, instance_key="default",
warn_store=warn_store,
)
reconcile_core_holdings(
slot_repo=repo, slot_service=svc, broker=broker,
instance_key="default", warn_store=warn_store,
)
# No new warn_store entries
assert warn_store.list_warned() == set()
# NFR-3: reconcile does NOT auto-modify slot qty
def test_reconcile_no_auto_fix_slot_qty():
"""Reconcile must only detect and block — no auto-modification of slot qty."""
repo = InMemorySlotRepository()
svc = SlotService(repo)
warn_store = InMemoryReconcileWarningStore()
slot = _make_holding_slot(repo, svc, qty=10)
broker = FakeBroker([BalanceItem(symbol="005930", hldg_qty=5)])
reconcile_core_holdings(
slot_repo=repo, slot_service=svc, broker=broker,
instance_key="default", warn_store=warn_store,
)
# Slot qty must remain 10 — not auto-corrected to 5
updated_slot = repo.fetch_slot(slot.slot_id)
assert float(updated_slot.qty) == 10.0
# ===========================================================================
# slot_to_dict unit tests for reconcile_warned field
# ===========================================================================
def test_slot_to_dict_reconcile_warned_false_when_no_warn_store():
"""When warn_store is None, reconcile_warned=False (NFR-4 additive)."""
repo = InMemorySlotRepository()
svc = SlotService(repo)
slot = _make_holding_slot(repo, svc, qty=5)
row = slot_to_dict(slot, warn_store=None)
assert "reconcile_warned" in row
assert row["reconcile_warned"] is False
def test_slot_to_dict_reconcile_warned_true_when_warned():
repo = InMemorySlotRepository()
svc = SlotService(repo)
slot = _make_holding_slot(repo, svc, qty=5)
warn_store = InMemoryReconcileWarningStore()
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
def test_slot_to_dict_paused_and_warned_independent():
"""paused and reconcile_warned are independent fields."""
repo = InMemorySlotRepository()
svc = SlotService(repo)
slot = _make_holding_slot(repo, svc, qty=5)
pause_store = InMemoryPauseStore()
pause_store.pause("pf-A")
warn_store = InMemoryReconcileWarningStore()
warn_store.set_warned(str(slot.slot_id), portfolio_id="pf-A", case_code="C")
row = slot_to_dict(slot, pause_store=pause_store, warn_store=warn_store)
assert row["paused"] is True
assert row["reconcile_warned"] is True
# ===========================================================================
# AC-0023 review: warn_store.is_warned() raises → intent fail-closed
# ===========================================================================
class _RaisingWarnStore:
"""Stub warn_store whose is_warned always raises (simulates DB unavailable)."""
def is_warned(self, slot_id: str) -> bool:
raise RuntimeError("DB unavailable")
def list_c_case_warned(self) -> set:
raise RuntimeError("DB unavailable")
def test_buy_intent_fail_closed_when_warn_store_raises():
"""If warn_store.is_warned() raises, buy intent must be rejected (fail-closed)."""
repo = InMemorySlotRepository()
svc = SlotService(repo)
intent_store = InMemoryIntentStore()
repo.reconcile_universe("default", {"pf-A": "005930"})
slot = repo.find_slot_by_portfolio("default", "pf-A")
intent_svc = IntentService(
svc, repo, intent_store,
instance_key="default",
warn_store=_RaisingWarnStore(),
)
d = intent_svc.submit_buy(BuyIntent(
intent_id="b-fc-001",
slot_id=slot.slot_id,
entry_price=100.0,
qty=10.0,
))
assert d.status == "rejected"
assert d.reason == "reconcile_warning_unavailable"
def test_sell_intent_fail_closed_when_warn_store_raises():
"""If warn_store.is_warned() raises, sell intent must be rejected (fail-closed)."""
repo = InMemorySlotRepository()
svc = SlotService(repo)
intent_store = InMemoryIntentStore()
slot = _make_holding_slot(repo, svc, qty=10)
intent_svc = IntentService(
svc, repo, intent_store,
instance_key="default",
warn_store=_RaisingWarnStore(),
)
d = intent_svc.submit_sell(SellIntent(
intent_id="s-fc-001",
slot_id=slot.slot_id,
reason="strategy_exit",
))
assert d.status == "rejected"
assert d.reason == "reconcile_warning_unavailable"