"""Order execution loop tests via paper broker (REQ-013, REQ-014). In-memory adapters; deterministic paper double drives the full async path. """ from __future__ import annotations from datetime import date from trex_core.adapters.broker.paper import PaperBroker from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore 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 DAY = date(2026, 6, 4) def _wire(broker): repo = InMemorySlotRepository() svc = SlotService(repo) intents = IntentService(svc, repo, InMemoryIntentStore()) execution = ExecutionService(svc, repo, broker) svc.preallocate_daily_slots(DAY, 5) return repo, svc, intents, execution def _buy(intents, iid="b1", symbol="005930", qty=10): return intents.submit_buy(BuyIntent(intent_id=iid, trading_date=DAY, symbol=symbol, qty=qty, entry_price=1000, portfolio_id="PF-1")) 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_buy_reject_rolls_back_to_idle(): repo, svc, intents, execution = _wire(PaperBroker(reject_symbols={"005930"})) sid = _buy(intents).slot_id execution.run_once() # dispatch + poll(reject) slot = repo.fetch_slot(sid) assert slot.status is SlotState.IDLE and slot.symbol is None and slot.broker_order_id is None def test_sell_lifecycle_to_closed_bumps_entry_count(): 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 -> closed assert repo.fetch_slot(sid).status is SlotState.CLOSED assert svc.get_today_entry_count(DAY, "005930") == 0 # +1 happens on closed->idle recovery 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_no_idle_slots_blocks_further_buys(): repo, svc, intents, execution = _wire(PaperBroker(fill_after_polls=0)) for i in range(5): assert _buy(intents, iid=f"b{i}").status == "admitted" assert _buy(intents, iid="overflow").status == "rejected"