"""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"}, 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"}, 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"