"""Execution policy handler tests — intent payload parsing + control cancel_pending. dcy-022 / REQ-068 (parsing), REQ-071 (cancel_pending control action). """ from __future__ import annotations from uuid import UUID, uuid4 from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore from trex_core.application.messaging import RpcRequest from trex_core.application.messaging_handlers import ( make_control_rpc_handler, make_intent_rpc_handler, ) from trex_core.application.portfolio import InMemoryPauseStore from trex_core.domain.slot import SlotService, SlotState from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository from trex_core.application.intents import BuyIntent, IntentService # ============================================================================ # Intent handler: execution policy payload parsing (REQ-068) # ============================================================================ def _wire(): repo = InMemorySlotRepository() svc = SlotService(repo) intents = IntentService(svc, repo, InMemoryIntentStore(), resolve_symbol=lambda pid: "005930") repo.reconcile_universe("default", {"PF-1": "SYM"}) return repo, svc, intents def _buy_req(timeout_sec=None, slippage_pct=None): body = { "intent_type": "buy", "intent_id": "b1", "portfolio_id": "PF-1", "qty": 10, } if timeout_sec is not None: body["timeout_sec"] = timeout_sec if slippage_pct is not None: body["slippage_pct"] = slippage_pct return RpcRequest(payload=body, correlation_id="b1") class TestIntentPayloadParsing: def test_buy_timeout_sec_parsed_and_stored_on_slot(self): repo, _, intents = _wire() handler = make_intent_rpc_handler(intents) resp = handler(_buy_req(timeout_sec=30)) assert resp.payload["status"] == "admitted" sid = UUID(resp.payload["slot_id"]) slot = repo.fetch_slot(sid) assert slot.timeout_sec == 30 def test_buy_slippage_pct_parsed_and_stored_on_slot(self): repo, _, intents = _wire() handler = make_intent_rpc_handler(intents) resp = handler(_buy_req(slippage_pct=0.5)) assert resp.payload["status"] == "admitted" sid = UUID(resp.payload["slot_id"]) slot = repo.fetch_slot(sid) assert slot.slippage_pct == 0.5 def test_buy_both_policy_fields_parsed(self): repo, _, intents = _wire() handler = make_intent_rpc_handler(intents) resp = handler(_buy_req(timeout_sec=60, slippage_pct=1.0)) sid = UUID(resp.payload["slot_id"]) slot = repo.fetch_slot(sid) assert slot.timeout_sec == 60 assert slot.slippage_pct == 1.0 def test_buy_policy_fields_absent_uses_hard_fallback(self): """No policy field still receives the hard 3,600-second fallback.""" repo, _, intents = _wire() handler = make_intent_rpc_handler(intents) resp = handler(_buy_req()) sid = UUID(resp.payload["slot_id"]) slot = repo.fetch_slot(sid) assert slot.timeout_sec == 3600 assert slot.slippage_pct is None def test_sell_timeout_sec_parsed(self): repo, svc, intents = _wire() handler = make_intent_rpc_handler(intents) # first buy to get a holding slot buy_resp = handler(_buy_req()) sid_str = buy_resp.payload["slot_id"] sid = UUID(sid_str) svc.transit_slot(sid, SlotState.PENDING_BUY, SlotState.HOLDING, reason="filled", actor="t") sell_resp = handler(RpcRequest( payload={ "intent_type": "sell", "intent_id": "s1", "slot_id": sid_str, "timeout_sec": 45, }, correlation_id="s1", )) assert sell_resp.payload["status"] == "admitted" slot = repo.fetch_slot(sid) assert slot.status is SlotState.PENDING_SELL assert slot.timeout_sec == 45 def test_sell_slippage_pct_parsed(self): repo, svc, intents = _wire() handler = make_intent_rpc_handler(intents) buy_resp = handler(_buy_req()) sid_str = buy_resp.payload["slot_id"] sid = UUID(sid_str) svc.transit_slot(sid, SlotState.PENDING_BUY, SlotState.HOLDING, reason="filled", actor="t") sell_resp = handler(RpcRequest( payload={ "intent_type": "sell", "intent_id": "s1", "slot_id": sid_str, "slippage_pct": 0.3, }, correlation_id="s1", )) assert sell_resp.payload["status"] == "admitted" slot = repo.fetch_slot(sid) assert slot.slippage_pct == 0.3 # ============================================================================ # default_order_timeout_sec: config-level default applies when intent omits timeout_sec # (REQ-068 AC-3/AC-5) # ============================================================================ class TestDefaultOrderTimeoutSec: def _wire_with_default(self, default_timeout): repo = InMemorySlotRepository() svc = SlotService(repo) intents = IntentService( svc, repo, InMemoryIntentStore(), resolve_symbol=lambda pid: "005930", default_order_timeout_sec=default_timeout, ) repo.reconcile_universe("default", {"PF-1": "SYM"}) return repo, svc, intents def test_config_default_applied_when_intent_has_no_timeout(self): repo, _, intents = self._wire_with_default(default_timeout=120) d = intents.submit_buy(BuyIntent( intent_id="b1", portfolio_id="PF-1", qty=10, )) assert d.status == "admitted" slot = repo.fetch_slot(d.slot_id) assert slot.timeout_sec == 120 def test_per_intent_timeout_overrides_config_default(self): repo, _, intents = self._wire_with_default(default_timeout=120) d = intents.submit_buy(BuyIntent( intent_id="b1", portfolio_id="PF-1", qty=10, timeout_sec=30, )) slot = repo.fetch_slot(d.slot_id) assert slot.timeout_sec == 30 def test_no_config_default_no_intent_timeout_uses_hard_fallback(self): repo, _, intents = self._wire_with_default(default_timeout=None) d = intents.submit_buy(BuyIntent( intent_id="b1", portfolio_id="PF-1", qty=10, )) slot = repo.fetch_slot(d.slot_id) assert slot.timeout_sec == 3600 # ============================================================================ # Control handler: cancel_pending action (REQ-071) # ============================================================================ class TestControlCancelPending: def _handler(self, cancel_fn): return make_control_rpc_handler( InMemoryPauseStore(), cancel_pending_fn=cancel_fn, ) def _req(self, **body): return RpcRequest(payload=body, correlation_id="c1") def test_cancel_pending_ok(self): sid = str(uuid4()) handler = self._handler(cancel_fn=lambda s: "ok") resp = handler(self._req(action="cancel_pending", slot_id=sid)) assert resp.payload["status"] == "ok" assert resp.payload["slot_id"] == sid def test_cancel_pending_not_pending(self): sid = str(uuid4()) handler = self._handler(cancel_fn=lambda s: "not_pending") resp = handler(self._req(action="cancel_pending", slot_id=sid)) assert resp.payload["status"] == "ignored" assert resp.payload["reason"] == "not_pending" def test_cancel_pending_slot_not_found(self): sid = str(uuid4()) handler = self._handler(cancel_fn=lambda s: "slot_not_found") resp = handler(self._req(action="cancel_pending", slot_id=sid)) assert resp.payload["status"] == "error" assert resp.payload["reason"] == "slot_not_found" def test_cancel_pending_without_slot_id_returns_error(self): handler = self._handler(cancel_fn=lambda s: "ok") resp = handler(self._req(action="cancel_pending")) assert resp.payload["status"] == "error" assert resp.payload["reason"] == "slot_id_required" def test_cancel_pending_unsupported_without_fn(self): handler = make_control_rpc_handler(InMemoryPauseStore()) resp = handler(self._req(action="cancel_pending", slot_id="x")) assert resp.payload["status"] == "error" assert resp.payload["reason"] == "cancel_pending_unsupported" def test_cancel_pending_cancel_failed(self): sid = str(uuid4()) handler = self._handler(cancel_fn=lambda s: "cancel_failed") resp = handler(self._req(action="cancel_pending", slot_id=sid)) assert resp.payload["status"] == "error" assert resp.payload["reason"] == "cancel_failed"