reconcile_universe 시그니처를 set[str] → dict[str, str] (portfolio_id→symbol)로 변경. 기존 symbol=NULL 슬롯은 다음 reconcile 실행 시 자동 backfill. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
232 lines
8.8 KiB
Python
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_means_none(self):
|
|
"""Backward-compat: no policy fields in payload → None on slot."""
|
|
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 is None
|
|
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_means_none(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 is None
|
|
|
|
|
|
# ============================================================================
|
|
# 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"
|