"""Sell-order pricing tests (dcy-012 / issue #10). Verifies the slot's side-agnostic ``limit_price`` drives dispatch and that a buy limit never leaks into the sell order (the entry_price-reuse bug). A recording paper broker captures each OrderRequest so order_type/limit_price are asserted directly. Critical: the bug only reproduces on a LIMIT-buy slot (limit_price populated), so every scenario enters via a limit buy. """ from __future__ import annotations 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 class _RecordingPaper(PaperBroker): """PaperBroker that records every submitted OrderRequest.""" def __init__(self, **kw) -> None: super().__init__(**kw) self.requests = [] def submit_order(self, req): self.requests.append(req) return super().submit_order(req) def _wire(broker): repo = InMemorySlotRepository() svc = SlotService(repo) intents = IntentService(svc, repo, InMemoryIntentStore(), resolve_symbol=lambda pid: "005930") execution = ExecutionService(svc, repo, broker) repo.reconcile_universe("default", {"PF-1": "SYM"}) return repo, svc, intents, execution def _limit_buy_to_holding(intents, execution, *, entry_price=1000): """Admit a LIMIT buy (entry_price set) and run to holding.""" d = intents.submit_buy( BuyIntent( intent_id="b1", portfolio_id="PF-1", symbol="005930", qty=10, entry_price=entry_price, ) ) execution.run_once() # dispatch buy + fill -> holding return d.slot_id def test_limit_buy_then_market_sell_does_not_reuse_buy_limit(): """S-1 (a): the core bug — limit-buy slot sells at MARKET, not the buy limit.""" broker = _RecordingPaper(fill_after_polls=0) repo, svc, intents, execution = _wire(broker) sid = _limit_buy_to_holding(intents, execution) # buy went out as a limit order at the entry price buy_req = broker.requests[0] assert buy_req.side == "buy" and buy_req.order_type == "00" and buy_req.limit_price == 1000 # holding slot's limit_price is cleared (no buy-limit leak) assert repo.fetch_slot(sid).limit_price is None intents.submit_sell(SellIntent(intent_id="s1", slot_id=sid)) # no price => market execution.run_once() # dispatch sell + fill -> closed sell_req = broker.requests[1] assert sell_req.side == "sell" assert sell_req.order_type == "01" # market, NOT "00" assert sell_req.limit_price is None # buy entry price NOT reused assert repo.fetch_slot(sid).status is SlotState.INACTIVE def test_limit_buy_then_limit_sell_uses_intent_price(): """S-1 (b): a sell intent that carries a price dispatches as a limit order.""" broker = _RecordingPaper(fill_after_polls=0) repo, svc, intents, execution = _wire(broker) sid = _limit_buy_to_holding(intents, execution) intents.submit_sell(SellIntent(intent_id="s1", slot_id=sid, limit_price=2000)) assert repo.fetch_slot(sid).limit_price == 2000 # set on pending_sell entry execution.run_once() sell_req = broker.requests[1] assert sell_req.side == "sell" assert sell_req.order_type == "00" # limit assert sell_req.limit_price == 2000 # the intent's price, not the buy entry def test_safety_sell_dispatches_at_market(): """S-2: SlotService.sell (no price) => market sell, slot closes (no pending_sell trap).""" broker = _RecordingPaper(fill_after_polls=0) repo, svc, intents, execution = _wire(broker) sid = _limit_buy_to_holding(intents, execution) svc.sell(sid, actor="safety_sell", reason="stop_loss") # autonomous, no price assert repo.fetch_slot(sid).status is SlotState.PENDING_SELL assert repo.fetch_slot(sid).limit_price is None execution.run_once() sell_req = broker.requests[1] assert sell_req.order_type == "01" and sell_req.limit_price is None assert repo.fetch_slot(sid).status is SlotState.INACTIVE def test_market_buy_slot_sells_at_market_regression(): """S-1 (c): market-buy slot (entry_price=None) keeps selling at market.""" broker = _RecordingPaper(fill_after_polls=0) repo, svc, intents, execution = _wire(broker) intents.submit_buy( BuyIntent( intent_id="b1", portfolio_id="PF-1", symbol="005930", qty=10, entry_price=None, # market buy ) ) execution.run_once() buy_req = broker.requests[0] assert buy_req.order_type == "01" and buy_req.limit_price is None sid = repo.fetch_slot # noqa: F841 - keep readability # find the holding slot holding = next(s for s in repo._slots.values() if s.status is SlotState.HOLDING) intents.submit_sell(SellIntent(intent_id="s1", slot_id=holding.slot_id)) execution.run_once() sell_req = broker.requests[1] assert sell_req.order_type == "01" and sell_req.limit_price is None