feat(rel-006): dcy-022/023 + BAT-032~034 — 실행 정책·qty 검증·allocated_capital 제거 통합
BAT-032 (dcy-022): timeout_sec/slippage_pct 실행 정책, 미체결 자동 취소, 보호 지정가, `trex-core cancel` 백스톱. 마이그레이션 012. BAT-033: `trex-core enter --qty` 필수 검증 — 미지정 시 RPC 전 즉시 오류. BAT-034 (dcy-023): allocated_capital 죽은 필드 전 경로 제거 (domain·intent· persistence·snapshot·CLI). 마이그레이션 013(비가역 DROP). 외부 발신자 unknown key 무시 수신 의미론 적용. REL-006 confirmed: BAT-026~034 통합 릴리즈, pre-release smoke 증거 포함. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
"""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"})
|
||||
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"})
|
||||
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"
|
||||
Reference in New Issue
Block a user