"""Order execution loop tests via paper broker (REQ-013, REQ-014; BAT-030 asset-centric). In-memory adapters; deterministic paper double drives the full async path. """ from __future__ import annotations from datetime import UTC, datetime, timedelta import pytest from trex_core.adapters.broker.paper import PaperBroker from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore from trex_core.application.account_snapshot import AccountSnapshotUnavailable from trex_core.application.broker_port import ( BalanceItem, CancelOutcome, CancelState, FillStatus, OrderAck, ) from trex_core.application.execution import ExecutionService from trex_core.application.intents import BuyIntent, IntentService, SellIntent from trex_core.domain.slot import SlotService, SlotState from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository INST = "default" SYMBOLS = {f"PF-{i}": f"00593{i}" for i in range(5)} def _wire(broker, max_active_slots=None): repo = InMemorySlotRepository() svc = SlotService(repo, instance_key=INST) intents = IntentService( svc, repo, InMemoryIntentStore(), resolve_symbol=SYMBOLS.get, instance_key=INST, max_active_slots=max_active_slots, ) execution = ExecutionService(svc, repo, broker, instance_key=INST) repo.reconcile_universe(INST, SYMBOLS) # permanent asset slots return repo, svc, intents, execution def _buy(intents, iid="b1", pid="PF-1", qty=10): return intents.submit_buy(BuyIntent(intent_id=iid, portfolio_id=pid, qty=qty, entry_price=1000)) def test_buy_lifecycle_to_holding(): repo, svc, intents, execution = _wire(PaperBroker(fill_after_polls=0)) d = _buy(intents) sid = d.slot_id assert repo.fetch_slot(sid).status is SlotState.PENDING_BUY execution.run_once() # dispatch + poll(fill immediately) slot = repo.fetch_slot(sid) assert slot.status is SlotState.HOLDING and slot.qty == 10 and slot.avg_price == 1000.0 def test_buy_dispatch_is_idempotent_no_double_order(): broker = PaperBroker(fill_after_polls=5) # stays accepted (not filled) across ticks repo, svc, intents, execution = _wire(broker) sid = _buy(intents).slot_id execution.run_once() # dispatch (1 order) + poll(accepted) bid1 = repo.fetch_slot(sid).broker_order_id execution.run_once() # must NOT re-dispatch bid2 = repo.fetch_slot(sid).broker_order_id assert bid1 == bid2 and len(broker._orders) == 1 # exactly one broker order def test_unknown_submit_outcome_stays_dispatched_without_retry(): class UnknownOutcomeBroker(PaperBroker): def __init__(self): super().__init__() self.submit_count = 0 self.cancel_count = 0 def submit_order(self, req): self.submit_count += 1 return OrderAck( accepted=True, broker_order_id=f"submission-unknown:{req.slot_id}", reason="submit_outcome_unknown:ReadTimeout", ) def poll_fill(self, broker_order_id, *, side, symbol): return FillStatus(state="unknown") def cancel_order(self, broker_order_id, *, side, symbol, qty, venue="KRX"): self.cancel_count += 1 return CancelOutcome( CancelState.UNKNOWN, "submission_outcome_unknown", ) broker = UnknownOutcomeBroker() repo, svc, intents, execution = _wire(broker) sid = _buy(intents).slot_id first = execution.run_once() second = execution.run_once() slot = repo.fetch_slot(sid) assert first.dispatched == 1 assert second.dispatched == 0 assert broker.submit_count == 1 assert broker.cancel_count == 0 assert slot.status is SlotState.PENDING_BUY assert slot.broker_order_id == f"submission-unknown:{sid}" def test_unknown_submit_resolution_is_persisted_before_restart_reconcile(): class ResolvedOutcomeBroker(PaperBroker): def __init__(self): super().__init__() self.submit_count = 0 self.poll_count = 0 def submit_order(self, req): self.submit_count += 1 return OrderAck( accepted=True, broker_order_id=f"submission-unknown:{req.slot_id}", reason="submit_outcome_unknown:ReadTimeout", ) def poll_fill(self, broker_order_id, *, side, symbol): self.poll_count += 1 if self.poll_count == 1: return FillStatus( state="accepted", raw={"resolved_broker_order_id": "91258:0012345678"}, ) return FillStatus( state="filled", filled_qty=10, avg_price=1000, execution_id="resolved-fill-1", ) broker = ResolvedOutcomeBroker() repo, svc, intents, execution = _wire(broker) sid = _buy(intents).slot_id execution.run_once() assert repo.fetch_slot(sid).broker_order_id == "91258:0012345678" execution.reconcile() slot = repo.fetch_slot(sid) assert slot.status is SlotState.HOLDING assert broker.submit_count == 1 def test_buy_reject_rolls_back_to_inactive(): repo, svc, intents, execution = _wire(PaperBroker(reject_symbols={"005931"})) sid = _buy(intents).slot_id execution.run_once() # dispatch + poll(reject) slot = repo.fetch_slot(sid) assert slot.status is SlotState.INACTIVE and slot.broker_order_id is None assert slot.portfolio_id == "PF-1" # permanent slot keeps its asset binding def test_sell_lifecycle_returns_to_inactive(): repo, svc, intents, execution = _wire(PaperBroker(fill_after_polls=0)) sid = _buy(intents).slot_id execution.run_once() # -> holding assert repo.fetch_slot(sid).status is SlotState.HOLDING intents.submit_sell(SellIntent(intent_id="s1", slot_id=sid)) assert repo.fetch_slot(sid).status is SlotState.PENDING_SELL execution.run_once() # dispatch sell + fill -> inactive (asset slot at rest) slot = repo.fetch_slot(sid) assert slot.status is SlotState.INACTIVE assert slot.qty is None and slot.portfolio_id == "PF-1" def test_full_sell_qty_is_broker_balance(): """dcy-020/REQ-062 (D3): full sell (slot.qty None) dispatches the broker hldg_qty — independent of any stale mirror.""" broker = PaperBroker(fill_after_polls=0) repo, svc, intents, execution = _wire(broker) sid = _buy(intents, qty=10).slot_id execution.run_once() # holding, broker balance = 10 svc.sell(sid, actor="t", reason="full") # qty=None -> sell all execution.run_once() sell_orders = [o for o in broker._orders.values() if o.side == "sell"] assert len(sell_orders) == 1 and sell_orders[0].qty == 10 assert repo.fetch_slot(sid).status is SlotState.INACTIVE # full exit def test_pending_sell_uses_injected_bounded_balance_reader(): class LiveReadFailPaper(PaperBroker): fail_live_balance = False def get_balance(self): if self.fail_live_balance: raise AssertionError("raw balance reader must not be called") return super().get_balance() broker = LiveReadFailPaper(fill_after_polls=0) repo, svc, intents, execution = _wire(broker) sid = _buy(intents, qty=10).slot_id execution.run_once() svc.sell(sid, actor="t", reason="full") broker.fail_live_balance = True broker._fill_after = 5 snapshot_execution = ExecutionService( svc, repo, broker, instance_key=INST, balance_reader=lambda: [BalanceItem("005931", 10)], ) snapshot_execution.run_once() sell_orders = [o for o in broker._orders.values() if o.side == "sell"] assert len(sell_orders) == 1 and sell_orders[0].qty == 10 assert repo.fetch_slot(sid).status is SlotState.PENDING_SELL def test_pending_sell_balance_failure_is_fail_closed_with_backoff_and_recovery(): now = {"t": datetime(2026, 9, 12, tzinfo=UTC)} broker = PaperBroker(fill_after_polls=0) repo, svc, intents, _ = _wire(broker) sid = _buy(intents, qty=3).slot_id execution = ExecutionService( svc, repo, broker, instance_key=INST, clock=lambda: now["t"], ) execution.run_once() svc.sell(sid, actor="t", reason="full") broker._fill_after = 5 calls = {"n": 0} reader_state = {"ok": False} def failing_reader(): calls["n"] += 1 if not reader_state["ok"]: raise AccountSnapshotUnavailable("expired") return [BalanceItem("005931", 3)] execution = ExecutionService( svc, repo, broker, instance_key=INST, balance_reader=failing_reader, clock=lambda: now["t"], ) first = execution.run_once() assert first.sell_skipped_no_balance == 1 assert calls["n"] == 1 health = execution.balance_worker_health(stale_after_sec=600) assert health["state"] == "unavailable" assert health["failure_code"] == "stale_snapshot" assert health["consecutive_failures"] == 1 second = execution.run_once() assert second.sell_skipped_no_balance == 1 assert calls["n"] == 1 # bounded backoff prevented another live read now["t"] += timedelta(seconds=16) reader_state["ok"] = True third = execution.run_once() assert third.dispatched == 1 health = execution.balance_worker_health(stale_after_sec=600) assert health["state"] == "ok" assert health["consecutive_failures"] == 0 def test_pending_sell_health_preserves_non_transient_snapshot_failure_code(): broker = PaperBroker(fill_after_polls=0) repo, svc, intents, _ = _wire(broker) sid = _buy(intents, qty=3).slot_id ExecutionService( svc, repo, broker, instance_key=INST, ).run_once() svc.sell(sid, actor="t", reason="full") execution = ExecutionService( svc, repo, broker, instance_key=INST, balance_reader=lambda: (_ for _ in ()).throw( AccountSnapshotUnavailable( "live_read_failed", detail_code="authentication_failed", ) ), ) result = execution.run_once() assert result.sell_skipped_no_balance == 1 health = execution.balance_worker_health(stale_after_sec=600) assert health["state"] == "unavailable" assert health["failure_code"] == "authentication_failed" @pytest.mark.parametrize( "error_code", [ "timeout", "http_5xx", "authentication_failed", "token_issuance_failed", "malformed_response", ], ids=["timeout", "http_5xx", "auth", "token", "pagination_middle_malformed"], ) def test_execution_wrapper_classification_fail_closed_matrix(error_code): broker = PaperBroker(fill_after_polls=0) repo, svc, intents, _ = _wire(broker) sid = _buy(intents, qty=3).slot_id ExecutionService(svc, repo, broker, instance_key=INST).run_once() svc.sell(sid, actor="t", reason="full") calls = {"n": 0} def fail_reader(): calls["n"] += 1 raise AccountSnapshotUnavailable("live_read_failed", detail_code=error_code) execution = ExecutionService( svc, repo, broker, instance_key=INST, balance_reader=fail_reader, ) result = execution.run_once() assert result.dispatched == 0 assert result.sell_skipped_no_balance == 1 assert calls["n"] == 1 health = execution.balance_worker_health(stale_after_sec=600) assert health["state"] == "unavailable" assert health["consecutive_failures"] == 1 assert health["failure_code"] == error_code def test_execution_wrapper_treats_validated_empty_success_as_success_not_failure(): broker = PaperBroker(fill_after_polls=0) repo, svc, intents, _ = _wire(broker) sid = _buy(intents, qty=3).slot_id ExecutionService(svc, repo, broker, instance_key=INST).run_once() svc.sell(sid, actor="t", reason="full") execution = ExecutionService( svc, repo, broker, instance_key=INST, balance_reader=lambda: [], ) result = execution.run_once() assert result.dispatched == 0 assert result.sell_skipped_no_balance == 1 # broker flat is valid success health = execution.balance_worker_health(stale_after_sec=600) assert health["state"] == "ok" assert health["consecutive_failures"] == 0 assert health["failure_code"] is None def test_partial_sell_qty_is_min_intent_broker(): """dcy-021/REQ-062 (D4): partial sell dispatches min(intent qty, broker); remainder stays holding (broker-authoritative qty), cost basis preserved.""" broker = PaperBroker(fill_after_polls=0) repo, svc, intents, execution = _wire(broker) sid = _buy(intents, qty=10).slot_id execution.run_once() # holding 10 @ avg 1000 intents.submit_sell(SellIntent(intent_id="ps", slot_id=sid, qty=4)) execution.run_once() # dispatch sell 4 + fill -> holding remainder 6 sell_orders = [o for o in broker._orders.values() if o.side == "sell"] assert len(sell_orders) == 1 and sell_orders[0].qty == 4 slot = repo.fetch_slot(sid) assert slot.status is SlotState.HOLDING and slot.qty == 6 assert slot.avg_price == 1000.0 # cost basis unchanged def test_partial_sell_caps_at_broker_balance(): """intent qty above the broker balance => full sell (min caps at balance).""" broker = PaperBroker(fill_after_polls=0) repo, svc, intents, execution = _wire(broker) sid = _buy(intents, qty=10).slot_id execution.run_once() # holding 10 intents.submit_sell(SellIntent(intent_id="ps", slot_id=sid, qty=999)) execution.run_once() sell_orders = [o for o in broker._orders.values() if o.side == "sell"] assert sell_orders[0].qty == 10 # capped at balance assert repo.fetch_slot(sid).status is SlotState.INACTIVE # nothing left def test_increase_accumulates_qty_and_weights_avg_price(): """dcy-021/REQ-066: buy on a holding slot increases the position; held qty = broker balance, avg_price = weighted average.""" broker = PaperBroker(fill_after_polls=0) repo, svc, intents, execution = _wire(broker) sid = _buy(intents, pid="PF-1").slot_id # qty 10 @ 1000 execution.run_once() # holding 10 @ 1000 assert repo.fetch_slot(sid).qty == 10 and repo.fetch_slot(sid).avg_price == 1000.0 # increase: same asset, +5 @ 1200 (buy on the holding slot) d = intents.submit_buy(BuyIntent(intent_id="inc", slot_id=sid, qty=5, entry_price=1200)) assert d.status == "admitted" assert repo.fetch_slot(sid).status is SlotState.PENDING_BUY execution.run_once() # fill increase -> holding 15, weighted avg slot = repo.fetch_slot(sid) assert slot.status is SlotState.HOLDING and slot.qty == 15 assert slot.avg_price == (10 * 1000 + 5 * 1200) / 15 # 1066.67 def test_increase_does_not_consume_capacity(): """dcy-021: increasing an already-active asset is not blocked by M cap.""" repo, svc, intents, execution = _wire(PaperBroker(fill_after_polls=0), max_active_slots=1) sid = _buy(intents, iid="b0", pid="PF-0").slot_id execution.run_once() # PF-0 holding (active=1=M) # a NEW asset would be capacity_full, but an increase on PF-0 is allowed d = intents.submit_buy(BuyIntent(intent_id="inc", slot_id=sid, qty=5, entry_price=1000)) assert d.status == "admitted" def test_sell_skipped_when_broker_flat(): """dcy-020/REQ-062 (D3): broker flat -> sell skipped (no mirror fallback), surfaced via the sell_skipped_no_balance counter; no broker order is sent.""" broker = PaperBroker(fill_after_polls=0) repo, svc, intents, execution = _wire(broker) sid = _buy(intents).slot_id execution.run_once() # holding (broker balance 10) broker._orders.clear() # simulate divergence: broker is actually flat svc.transit_slot(sid, SlotState.HOLDING, SlotState.PENDING_SELL, reason="r", actor="t") r = execution.run_once() assert r.sell_skipped_no_balance == 1 assert r.dispatched == 0 assert repo.fetch_slot(sid).status is SlotState.PENDING_SELL # untouched assert not [o for o in broker._orders.values() if o.side == "sell"] def test_skipped_sell_does_not_block_other_dispatches(): """A broker-flat sell must not head-block the dispatch queue.""" broker = PaperBroker(fill_after_polls=0) repo, svc, intents, execution = _wire(broker) sid = _buy(intents, iid="b1", pid="PF-1").slot_id execution.run_once() # PF-1 holding broker._orders.clear() # broker flat for PF-1's symbol svc.transit_slot(sid, SlotState.HOLDING, SlotState.PENDING_SELL, reason="r", actor="t") d2 = _buy(intents, iid="b2", pid="PF-2") # a buy behind the stuck sell r = execution.run_once() assert r.sell_skipped_no_balance == 1 assert r.dispatched >= 1 # the buy went out assert repo.fetch_slot(d2.slot_id).status is SlotState.HOLDING def test_reconcile_after_crash_completes_fill_without_redispatch(): broker = PaperBroker(fill_after_polls=1) repo, svc, intents, execution = _wire(broker) sid = _buy(intents).slot_id execution.run_once() # dispatch + poll1 (accepted, not filled) — simulates crash here assert repo.fetch_slot(sid).status is SlotState.PENDING_BUY orders_before = len(broker._orders) execution.reconcile() # restart: re-poll -> fills, no new order slot = repo.fetch_slot(sid) assert slot.status is SlotState.HOLDING assert len(broker._orders) == orders_before # reconcile did not submit a new order def test_capacity_full_blocks_further_buys(): """dcy-020/REQ-059: at M active slots further buys reject capacity_full.""" repo, svc, intents, execution = _wire( PaperBroker(fill_after_polls=0), max_active_slots=2, ) assert _buy(intents, iid="b0", pid="PF-0").status == "admitted" assert _buy(intents, iid="b1", pid="PF-1").status == "admitted" over = _buy(intents, iid="b2", pid="PF-2") assert over.status == "rejected" and over.reason == "capacity_full"