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:
2026-06-11 07:42:03 +09:00
co-authored by Claude Sonnet 4.6
parent 5ee5f7f4e2
commit fb05a73130
56 changed files with 3151 additions and 103 deletions
+342
View File
@@ -0,0 +1,342 @@
"""Execution policy tests: slippage dispatch + timeout cancel (dcy-022 / REQ-069/070).
Uses ProgrammableBroker from test_execution_cancel for fine-grained control.
Also covers operator cancel_pending_slot (REQ-071).
"""
from __future__ import annotations
import logging
from dataclasses import replace
from datetime import datetime, timedelta, timezone
from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore
from trex_core.application.broker_port import BalanceItem, FillStatus, OrderAck, OrderRequest
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 _RecordingBroker:
"""Broker that records submitted orders and lets tests control poll responses."""
def __init__(self):
self._seq = 0
self.orders: list[OrderRequest] = []
self.cancel_calls: list = []
self.cancel_raises = False
self.responder = lambda side: FillStatus(
state="filled", filled_qty=10, avg_price=1000.0,
execution_id="exec-1", fee=0.0, tax=0.0,
)
self.balance: dict[str, int] = {"005930": 10}
def name(self):
return "stub"
def submit_order(self, req: OrderRequest):
self._seq += 1
self.orders.append(req)
return OrderAck(accepted=True, broker_order_id=f"o{self._seq}")
def poll_fill(self, broker_order_id, *, side, symbol):
return self.responder(side)
def cancel_order(self, broker_order_id, *, side, symbol, qty):
self.cancel_calls.append((broker_order_id, side, symbol, qty))
if self.cancel_raises:
raise RuntimeError("broker error")
return True
def get_balance(self):
return [BalanceItem(symbol=s, hldg_qty=q) for s, q in self.balance.items() if q > 0]
def get_current_price(self, symbol):
return None
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"})
return repo, svc, intents, execution
def _buy_to_pending(intents, qty=10, entry_price=1000, timeout_sec=None, slippage_pct=None):
return intents.submit_buy(BuyIntent(
intent_id="b1", qty=qty, entry_price=entry_price,
portfolio_id="PF-1",
timeout_sec=timeout_sec, slippage_pct=slippage_pct,
)).slot_id
# ============================================================================
# REQ-070: slippage effective limit price
# ============================================================================
class TestSlippageDispatch:
def test_buy_slippage_ceil_applied_to_limit(self):
"""buy: effective_limit = ceil(entry_price * (1 + pct/100))."""
broker = _RecordingBroker()
repo, svc, intents, execution = _wire(broker)
_buy_to_pending(intents, entry_price=1000, slippage_pct=0.5)
broker.responder = lambda side: FillStatus(state="accepted")
execution.run_once()
order = broker.orders[0]
# ceil(1000 * 1.005) = ceil(1005.0) = 1005
assert order.limit_price == 1005
def test_buy_slippage_fractional_ceil(self):
"""ceil(1001 * 1.003) = ceil(1004.003) = 1005."""
broker = _RecordingBroker()
repo, svc, intents, execution = _wire(broker)
_buy_to_pending(intents, entry_price=1001, slippage_pct=0.3)
broker.responder = lambda side: FillStatus(state="accepted")
execution.run_once()
import math
expected = int(math.ceil(1001 * 1.003))
assert broker.orders[0].limit_price == expected
def test_sell_slippage_floor_applied_to_limit(self):
"""sell: effective_limit = floor(limit_price * (1 - pct/100))."""
broker = _RecordingBroker()
repo, svc, intents, execution = _wire(broker)
# drive to holding first
broker.responder = lambda side: FillStatus(
state="filled", filled_qty=10, avg_price=1000.0,
execution_id="e1", fee=0.0, tax=0.0,
)
_buy_to_pending(intents)
execution.run_once() # buy filled -> holding
# now submit sell with slippage
sid = [s.slot_id for s in repo.list_slots("default")
if s.status is SlotState.HOLDING][0]
intents.submit_sell(SellIntent(
intent_id="s1", slot_id=sid, slippage_pct=1.0, limit_price=2000.0,
))
broker.responder = lambda side: FillStatus(state="accepted")
execution.run_once() # dispatch sell (pending state already has limit_price)
sell_orders = [o for o in broker.orders if o.side == "sell"]
# floor(2000 * 0.99) = floor(1980.0) = 1980
import math
expected = int(math.floor(2000 * 0.99))
assert sell_orders[0].limit_price == expected
def test_no_slippage_when_slippage_pct_is_none(self):
"""No slippage_pct → effective_limit = entry_price unchanged."""
broker = _RecordingBroker()
repo, svc, intents, execution = _wire(broker)
_buy_to_pending(intents, entry_price=1000, slippage_pct=None)
broker.responder = lambda side: FillStatus(state="accepted")
execution.run_once()
assert broker.orders[0].limit_price == 1000
def test_no_slippage_when_market_order_no_entry_price(self):
"""Market order (no entry_price → limit=None) → slippage not applied."""
broker = _RecordingBroker()
repo, svc, intents, execution = _wire(broker)
intents.submit_buy(BuyIntent(
intent_id="b1", qty=10, entry_price=None,
portfolio_id="PF-1", slippage_pct=5.0,
))
broker.responder = lambda side: FillStatus(state="accepted")
execution.run_once()
assert broker.orders[0].limit_price is None
def test_zero_slippage_uses_base_price(self):
"""slippage_pct=0 → effective_limit = ceil(1000 * 1.0) = 1000."""
broker = _RecordingBroker()
repo, svc, intents, execution = _wire(broker)
_buy_to_pending(intents, entry_price=1000, slippage_pct=0.0)
broker.responder = lambda side: FillStatus(state="accepted")
execution.run_once()
# 0.0 is falsy → slippage block skipped → no change
assert broker.orders[0].limit_price == 1000
# ============================================================================
# REQ-069: timeout auto-cancel
# ============================================================================
class TestTimeoutCancel:
def _buy_and_dispatch(self, intents, execution, repo, timeout_sec):
"""Drive a pending buy into dispatched state, then age the slot."""
sid = _buy_to_pending(intents, timeout_sec=timeout_sec)
# keep order in "accepted" (not filled) so poll always lands in else branch
execution._repo # just to confirm it's wired
return sid
def test_timeout_exceeded_sends_cancel_order(self):
broker = _RecordingBroker()
repo, svc, intents, execution = _wire(broker)
sid = _buy_to_pending(intents, timeout_sec=30)
broker.responder = lambda side: FillStatus(state="accepted")
execution.run_once() # dispatch (no fill yet)
assert len(broker.cancel_calls) == 0
# age the slot past timeout
s = repo.fetch_slot(sid)
repo._slots[sid] = replace(
s, updated_at=datetime.now(tz=timezone.utc) - timedelta(seconds=60),
)
r = execution.run_once()
assert r.timeout_cancelled == 1
assert len(broker.cancel_calls) == 1
def test_timeout_not_exceeded_no_cancel(self):
broker = _RecordingBroker()
repo, svc, intents, execution = _wire(broker)
_buy_to_pending(intents, timeout_sec=3600)
broker.responder = lambda side: FillStatus(state="accepted")
execution.run_once() # dispatch
# do NOT age the slot — timeout_sec is large
r = execution.run_once()
assert r.timeout_cancelled == 0
assert len(broker.cancel_calls) == 0
def test_no_timeout_sec_no_cancel(self):
broker = _RecordingBroker()
repo, svc, intents, execution = _wire(broker)
sid = _buy_to_pending(intents, timeout_sec=None)
broker.responder = lambda side: FillStatus(state="accepted")
execution.run_once() # dispatch
# age the slot
s = repo.fetch_slot(sid)
repo._slots[sid] = replace(
s, updated_at=datetime.now(tz=timezone.utc) - timedelta(seconds=9999),
)
r = execution.run_once()
assert r.timeout_cancelled == 0
assert len(broker.cancel_calls) == 0
def test_timeout_cancel_best_effort_on_exception(self, caplog):
"""Broker exception during cancel must not raise — loop continues."""
broker = _RecordingBroker()
broker.cancel_raises = True
repo, svc, intents, execution = _wire(broker)
sid = _buy_to_pending(intents, timeout_sec=1)
broker.responder = lambda side: FillStatus(state="accepted")
execution.run_once() # dispatch
s = repo.fetch_slot(sid)
repo._slots[sid] = replace(
s, updated_at=datetime.now(tz=timezone.utc) - timedelta(seconds=60),
)
with caplog.at_level(logging.WARNING):
r = execution.run_once()
# timeout_cancelled NOT incremented when the cancel call raises
assert r.timeout_cancelled == 0
assert repo.fetch_slot(sid).status is SlotState.PENDING_BUY # state unchanged
def test_timeout_cancel_does_not_change_slot_state(self):
"""Timeout cancel is best-effort: slot stays PENDING_BUY after cancel_order."""
broker = _RecordingBroker()
repo, svc, intents, execution = _wire(broker)
sid = _buy_to_pending(intents, timeout_sec=1)
broker.responder = lambda side: FillStatus(state="accepted")
execution.run_once()
s = repo.fetch_slot(sid)
repo._slots[sid] = replace(
s, updated_at=datetime.now(tz=timezone.utc) - timedelta(seconds=60),
)
execution.run_once()
# slot still PENDING_BUY — cancel is best-effort; rollback on next poll
assert repo.fetch_slot(sid).status is SlotState.PENDING_BUY
def test_timeout_cancel_then_fill_race_handled(self):
"""Cancel sent, but broker fills the order — is_filled path wins on next poll."""
broker = _RecordingBroker()
repo, svc, intents, execution = _wire(broker)
sid = _buy_to_pending(intents, timeout_sec=1)
broker.responder = lambda side: FillStatus(state="accepted")
execution.run_once() # dispatch
# age the slot → cancel sent
s = repo.fetch_slot(sid)
repo._slots[sid] = replace(
s, updated_at=datetime.now(tz=timezone.utc) - timedelta(seconds=60),
)
r1 = execution.run_once()
assert r1.timeout_cancelled == 1
# next poll: broker says filled anyway (race — fill beat the cancel)
broker.responder = lambda side: FillStatus(
state="filled", filled_qty=10, avg_price=1000.0,
execution_id="exec-race", fee=0.0, tax=0.0,
)
r2 = execution.run_once()
assert r2.filled == 1
assert repo.fetch_slot(sid).status is SlotState.HOLDING
# ============================================================================
# REQ-071: cancel_pending_slot (operator backstop)
# ============================================================================
class TestCancelPendingSlot:
def test_ok_sends_cancel_and_returns_ok(self):
broker = _RecordingBroker()
repo, svc, intents, execution = _wire(broker)
sid = _buy_to_pending(intents)
# dispatch so broker_order_id is set
broker.responder = lambda side: FillStatus(state="accepted")
execution.run_once()
result = execution.cancel_pending_slot(sid)
assert result == "ok"
assert len(broker.cancel_calls) == 1
def test_not_pending_returns_not_pending(self):
broker = _RecordingBroker()
repo, svc, intents, execution = _wire(broker)
sid = _buy_to_pending(intents)
broker.responder = lambda side: FillStatus(
state="filled", filled_qty=10, avg_price=1000.0,
execution_id="e1", fee=0.0, tax=0.0,
)
execution.run_once() # fill -> holding
assert repo.fetch_slot(sid).status is SlotState.HOLDING
result = execution.cancel_pending_slot(sid)
assert result == "not_pending"
def test_slot_not_found_returns_slot_not_found(self):
broker = _RecordingBroker()
repo, svc, intents, execution = _wire(broker)
from uuid import uuid4
result = execution.cancel_pending_slot(uuid4())
assert result == "slot_not_found"
def test_not_dispatched_returns_not_dispatched(self):
"""Slot is PENDING_BUY but broker_order_id not yet set."""
broker = _RecordingBroker()
repo, svc, intents, execution = _wire(broker)
sid = _buy_to_pending(intents)
# Do NOT call run_once — broker_order_id is still None
result = execution.cancel_pending_slot(sid)
assert result == "not_dispatched"
def test_cancel_fail_returns_cancel_failed(self):
broker = _RecordingBroker()
broker.cancel_raises = True
repo, svc, intents, execution = _wire(broker)
sid = _buy_to_pending(intents)
broker.responder = lambda side: FillStatus(state="accepted")
execution.run_once() # dispatch
result = execution.cancel_pending_slot(sid)
assert result == "cancel_failed"
def test_cancel_does_not_change_slot_state(self):
"""Operator cancel sends cancel_order; slot stays PENDING_BUY (execution loop transitions)."""
broker = _RecordingBroker()
repo, svc, intents, execution = _wire(broker)
sid = _buy_to_pending(intents)
broker.responder = lambda side: FillStatus(state="accepted")
execution.run_once()
execution.cancel_pending_slot(sid)
assert repo.fetch_slot(sid).status is SlotState.PENDING_BUY
@@ -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"
@@ -163,3 +163,27 @@ def test_buy_symbol_resolved_from_universe():
from uuid import UUID
slot = repo.fetch_slot(UUID(resp.payload["slot_id"]))
assert slot.symbol == "005930" and slot.portfolio_id == "PF-9"
def test_buy_payload_allocated_capital_is_ignored():
"""dcy-023 / REQ-007 AC-6: allocated_capital is no longer a contract field.
Senders (e.g. trex-orb-v1) may still include it — admission proceeds
normally and the value lands nowhere (no Slot/BuyIntent field exists)."""
repo, _, intents = _wire()
handler = make_intent_rpc_handler(intents)
req = RpcRequest(
payload={
"intent_type": "buy",
"intent_id": "buy-cap",
"portfolio_id": "PF-1",
"qty": 10,
"allocated_capital": 1_000_000, # unknown key — ignored
},
correlation_id="buy-cap",
)
resp = handler(req)
assert resp.payload["status"] == "admitted"
from uuid import UUID
slot = repo.fetch_slot(UUID(resp.payload["slot_id"]))
assert slot.status is SlotState.PENDING_BUY and slot.qty == 10
assert not hasattr(slot, "allocated_capital")