Files
trex-core/tests/application/test_execution_policy_handler.py

232 lines
8.8 KiB
Python

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