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,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")
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Execution policy (timeout_sec / slippage_pct) lifecycle tests (dcy-022 / REQ-068).
|
||||
|
||||
slot_service.transit_slot sets timeout_sec/slippage_pct on PENDING entry and
|
||||
clears them when the slot exits to HOLDING, INACTIVE, or ERROR.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from trex_core.domain.slot import SlotService, SlotState
|
||||
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
|
||||
|
||||
INST = "default"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def repo():
|
||||
return InMemorySlotRepository()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service(repo):
|
||||
return SlotService(repo, instance_key=INST)
|
||||
|
||||
|
||||
def _seed(repo):
|
||||
repo.reconcile_universe(INST, {"PF-1"})
|
||||
return repo.list_slots(INST)[0]
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# PENDING_BUY: execution policy set on entry
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
def test_timeout_sec_set_on_pending_buy_entry(service, repo):
|
||||
slot = _seed(repo)
|
||||
updated = service.transit_slot(
|
||||
slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
reason="r", actor="t", symbol="005930", qty=10,
|
||||
timeout_sec=30,
|
||||
)
|
||||
assert updated.timeout_sec == 30
|
||||
|
||||
|
||||
def test_slippage_pct_set_on_pending_buy_entry(service, repo):
|
||||
slot = _seed(repo)
|
||||
updated = service.transit_slot(
|
||||
slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
reason="r", actor="t", symbol="005930", qty=10,
|
||||
slippage_pct=0.5,
|
||||
)
|
||||
assert updated.slippage_pct == 0.5
|
||||
|
||||
|
||||
def test_both_policy_fields_set_together(service, repo):
|
||||
slot = _seed(repo)
|
||||
updated = service.transit_slot(
|
||||
slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
reason="r", actor="t", symbol="005930", qty=10,
|
||||
timeout_sec=60, slippage_pct=1.0,
|
||||
)
|
||||
assert updated.timeout_sec == 60
|
||||
assert updated.slippage_pct == 1.0
|
||||
|
||||
|
||||
def test_policy_fields_default_to_none_when_not_given(service, repo):
|
||||
slot = _seed(repo)
|
||||
updated = service.transit_slot(
|
||||
slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
reason="r", actor="t", symbol="005930", qty=10,
|
||||
)
|
||||
assert updated.timeout_sec is None
|
||||
assert updated.slippage_pct is None
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# PENDING_SELL: same lifecycle as PENDING_BUY
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
def test_slippage_pct_set_on_pending_sell_entry(service, repo):
|
||||
slot = _seed(repo)
|
||||
service.transit_slot(
|
||||
slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
reason="buy", actor="t", symbol="005930", qty=10,
|
||||
)
|
||||
service.transit_slot(
|
||||
slot.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
|
||||
reason="filled", actor="t",
|
||||
)
|
||||
updated = service.transit_slot(
|
||||
slot.slot_id, SlotState.HOLDING, SlotState.PENDING_SELL,
|
||||
reason="sell", actor="t",
|
||||
timeout_sec=45, slippage_pct=0.3,
|
||||
)
|
||||
assert updated.timeout_sec == 45
|
||||
assert updated.slippage_pct == 0.3
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# HOLDING: execution policy cleared on exit from pending
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
def test_policy_cleared_on_transition_to_holding(service, repo):
|
||||
slot = _seed(repo)
|
||||
service.transit_slot(
|
||||
slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
reason="r", actor="t", symbol="005930", qty=10,
|
||||
timeout_sec=30, slippage_pct=0.5,
|
||||
)
|
||||
updated = service.transit_slot(
|
||||
slot.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
|
||||
reason="filled", actor="t",
|
||||
)
|
||||
assert updated.timeout_sec is None
|
||||
assert updated.slippage_pct is None
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# INACTIVE: execution policy cleared on inactive reset
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
def test_policy_cleared_on_transition_to_inactive(service, repo):
|
||||
slot = _seed(repo)
|
||||
service.transit_slot(
|
||||
slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
reason="r", actor="t", symbol="005930", qty=10,
|
||||
timeout_sec=30, slippage_pct=0.5,
|
||||
)
|
||||
updated = service.transit_slot(
|
||||
slot.slot_id, SlotState.PENDING_BUY, SlotState.INACTIVE,
|
||||
reason="rejected", actor="t",
|
||||
)
|
||||
assert updated.timeout_sec is None
|
||||
assert updated.slippage_pct is None
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# PENDING state: existing policy preserved when not overridden
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
def test_existing_policy_preserved_when_not_overridden(service, repo):
|
||||
"""PENDING→PENDING re-entry keeps existing policy if not overridden."""
|
||||
slot = _seed(repo)
|
||||
service.transit_slot(
|
||||
slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
reason="r", actor="t", symbol="005930", qty=10,
|
||||
timeout_sec=30, slippage_pct=0.5,
|
||||
)
|
||||
# Simulate a HOLDING→PENDING_BUY increase (re-enters pending with no new policy).
|
||||
# We need to go to HOLDING first, then re-enter PENDING_BUY.
|
||||
service.transit_slot(
|
||||
slot.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
|
||||
reason="filled", actor="t",
|
||||
)
|
||||
# From HOLDING re-enter PENDING_BUY without providing policy — policy starts fresh
|
||||
updated = service.transit_slot(
|
||||
slot.slot_id, SlotState.HOLDING, SlotState.PENDING_BUY,
|
||||
reason="increase", actor="t", qty=5,
|
||||
)
|
||||
# Policy cleared during HOLDING, not re-set → None (fresh PENDING entry)
|
||||
assert updated.timeout_sec is None
|
||||
assert updated.slippage_pct is None
|
||||
@@ -54,7 +54,6 @@ class TestTransitHappyPath:
|
||||
actor="buy_decision_thread",
|
||||
symbol="005930",
|
||||
qty=10,
|
||||
allocated_capital=1_000_000,
|
||||
strategy_id="orb_v1",
|
||||
)
|
||||
assert updated.status is SlotState.PENDING_BUY
|
||||
@@ -94,7 +93,7 @@ class TestTransitHappyPath:
|
||||
service.transit_slot(
|
||||
slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
reason="orb_breakout", actor="buy", symbol="005930", qty=10,
|
||||
allocated_capital=1_000_000, strategy_id="orb_v1",
|
||||
strategy_id="orb_v1",
|
||||
)
|
||||
rolled = service.transit_slot(
|
||||
slot.slot_id, SlotState.PENDING_BUY, SlotState.INACTIVE,
|
||||
@@ -102,7 +101,6 @@ class TestTransitHappyPath:
|
||||
)
|
||||
assert rolled.status is SlotState.INACTIVE
|
||||
assert rolled.qty is None
|
||||
assert rolled.allocated_capital is None
|
||||
assert rolled.strategy_id is None
|
||||
assert rolled.limit_price is None
|
||||
assert rolled.broker_order_id is None
|
||||
|
||||
@@ -34,11 +34,11 @@ _SNAPSHOT = {
|
||||
"slots": [
|
||||
{"slot_id": "S-1", "status": "holding",
|
||||
"symbol": "005930", "qty": 10.0, "avg_price": 28700.0, "entry_price": None,
|
||||
"allocated_capital": None, "strategy_id": "orb", "sell_strategy_id": None,
|
||||
"strategy_id": "orb", "sell_strategy_id": None,
|
||||
"portfolio_id": "PF-1", "forced_close_at": None},
|
||||
{"slot_id": "S-2", "status": "inactive",
|
||||
"symbol": None, "qty": None, "avg_price": None, "entry_price": None,
|
||||
"allocated_capital": None, "strategy_id": None, "sell_strategy_id": None,
|
||||
"strategy_id": None, "sell_strategy_id": None,
|
||||
"portfolio_id": None, "forced_close_at": None},
|
||||
],
|
||||
}
|
||||
@@ -134,7 +134,7 @@ def test_slots_marks_watchdog_owned(capsys):
|
||||
"slots": [{"slot_id": "S-1",
|
||||
"status": "holding", "symbol": "005930", "qty": 10.0,
|
||||
"avg_price": 28700.0, "entry_price": None,
|
||||
"allocated_capital": None, "strategy_id": "orb",
|
||||
"strategy_id": "orb",
|
||||
"sell_strategy_id": None, "portfolio_id": "PF-1",
|
||||
"forced_close_at": None}],
|
||||
"watchdog": [{"slot_id": "S-1", "portfolio_id": "PF-1", "symbol": "005930",
|
||||
@@ -161,26 +161,45 @@ def test_json_output_is_valid_json(capsys):
|
||||
# --- enter (buy intent RPC) + safeguards ------------------------------------
|
||||
def test_enter_aborts_without_confirmation_no_rpc():
|
||||
rpc = _RpcRecorder(_ADMITTED)
|
||||
rc = cli.main(["enter", "PF-1", "--capital", "100000"],
|
||||
rc = cli.main(["enter", "PF-1", "--qty", "10"],
|
||||
rpc=rpc, confirm=lambda _p: False)
|
||||
assert rc == 1 and rpc.calls == [] # aborted -> no order
|
||||
|
||||
|
||||
def test_enter_without_qty_errors_before_rpc(capsys):
|
||||
# REQ-032 AC-7 (CHG-20260610-06, issue #23): missing --qty → rc=1, no RPC.
|
||||
rpc = _RpcRecorder(_ADMITTED)
|
||||
rc = cli.main(["enter", "PF-1", "--yes"], rpc=rpc)
|
||||
assert rc == 1 and rpc.calls == []
|
||||
assert "--qty" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_enter_rejects_capital_option(capsys):
|
||||
# REQ-032 AC-8 (CHG-20260610-07, dcy-023/issue #22): --capital is gone —
|
||||
# argparse rejects it as an unknown argument before any RPC.
|
||||
rpc = _RpcRecorder(_ADMITTED)
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
cli.main(["enter", "PF-1", "--qty", "10", "--capital", "100000", "--yes"], rpc=rpc)
|
||||
assert exc.value.code == 2 and rpc.calls == []
|
||||
assert "--capital" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_enter_with_yes_submits_buy_intent():
|
||||
rpc = _RpcRecorder(_ADMITTED)
|
||||
rc = cli.main(["enter", "PF-1", "--capital", "100000", "--yes"], rpc=rpc)
|
||||
rc = cli.main(["enter", "PF-1", "--qty", "10", "--yes"], rpc=rpc)
|
||||
assert rc == 0
|
||||
p = rpc.calls[0]["payload"]
|
||||
assert rpc.calls[0]["queue"] == "trex.intent.req"
|
||||
assert p["intent_type"] == "buy" and p["portfolio_id"] == "PF-1"
|
||||
assert p["allocated_capital"] == 100000
|
||||
assert p["qty"] == 10
|
||||
assert "allocated_capital" not in p # dcy-023 / REQ-032 AC-8
|
||||
assert "symbol" not in p # server-resolved (dcy-011)
|
||||
|
||||
|
||||
def test_enter_with_forced_close_at_maps_to_payload():
|
||||
# REQ-032 AC-5: --forced-close-at (absolute) → buy intent forced_close_at.
|
||||
rpc = _RpcRecorder(_ADMITTED)
|
||||
rc = cli.main(["enter", "PF-1", "--capital", "100000", "--yes",
|
||||
rc = cli.main(["enter", "PF-1", "--qty", "10", "--yes",
|
||||
"--forced-close-at", "2026-06-09T15:20:00+09:00"], rpc=rpc)
|
||||
assert rc == 0
|
||||
p = rpc.calls[0]["payload"]
|
||||
@@ -190,7 +209,7 @@ def test_enter_with_forced_close_at_maps_to_payload():
|
||||
def test_enter_without_forced_close_at_omits_field():
|
||||
# REQ-032 AC-6: unspecified → field absent (backward compat).
|
||||
rpc = _RpcRecorder(_ADMITTED)
|
||||
rc = cli.main(["enter", "PF-1", "--capital", "100000", "--yes"], rpc=rpc)
|
||||
rc = cli.main(["enter", "PF-1", "--qty", "10", "--yes"], rpc=rpc)
|
||||
assert rc == 0
|
||||
assert "forced_close_at" not in rpc.calls[0]["payload"]
|
||||
|
||||
@@ -198,27 +217,27 @@ def test_enter_without_forced_close_at_omits_field():
|
||||
def test_enter_rejects_malformed_forced_close_at_before_submit():
|
||||
# REQ-032 AC-6: invalid timestamp rejected at CLI, no RPC submitted.
|
||||
rpc = _RpcRecorder(_ADMITTED)
|
||||
rc = cli.main(["enter", "PF-1", "--capital", "100000", "--yes",
|
||||
rc = cli.main(["enter", "PF-1", "--qty", "10", "--yes",
|
||||
"--forced-close-at", "notatime"], rpc=rpc)
|
||||
assert rc == 1 and rpc.calls == []
|
||||
|
||||
|
||||
def test_enter_rejected_returns_nonzero():
|
||||
rpc = _RpcRecorder(_REJECTED)
|
||||
rc = cli.main(["enter", "PF-x", "--yes"], rpc=rpc)
|
||||
rc = cli.main(["enter", "PF-x", "--qty", "10", "--yes"], rpc=rpc)
|
||||
assert rc == 1
|
||||
|
||||
|
||||
def test_enter_dry_run_does_not_submit(capsys):
|
||||
rpc = _RpcRecorder(_ADMITTED)
|
||||
rc = cli.main(["enter", "PF-1", "--capital", "5", "--dry-run"], rpc=rpc)
|
||||
rc = cli.main(["enter", "PF-1", "--qty", "5", "--dry-run"], rpc=rpc)
|
||||
assert rc == 0 and rpc.calls == []
|
||||
assert "dry-run" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_enter_json_requires_yes():
|
||||
rpc = _RpcRecorder(_ADMITTED)
|
||||
rc = cli.main(["enter", "PF-1", "--json"], rpc=rpc)
|
||||
rc = cli.main(["enter", "PF-1", "--qty", "10", "--json"], rpc=rpc)
|
||||
assert rc == 2 and rpc.calls == [] # no auto-submit (FR-7)
|
||||
|
||||
|
||||
@@ -241,14 +260,14 @@ def test_exit_market_when_no_price():
|
||||
def test_live_requires_live_flag(monkeypatch):
|
||||
monkeypatch.setattr(cli, "_broker_mode", lambda cfg: ("kis", True))
|
||||
rpc = _RpcRecorder(_ADMITTED)
|
||||
rc = cli.main(["enter", "PF-1", "--yes"], rpc=rpc) # --yes but no --live
|
||||
rc = cli.main(["enter", "PF-1", "--qty", "10", "--yes"], rpc=rpc) # --yes but no --live
|
||||
assert rc == 2 and rpc.calls == []
|
||||
|
||||
|
||||
def test_live_proceeds_with_yes_live(monkeypatch):
|
||||
monkeypatch.setattr(cli, "_broker_mode", lambda cfg: ("kis", True))
|
||||
rpc = _RpcRecorder(_ADMITTED)
|
||||
rc = cli.main(["enter", "PF-1", "--yes", "--live"], rpc=rpc)
|
||||
rc = cli.main(["enter", "PF-1", "--qty", "10", "--yes", "--live"], rpc=rpc)
|
||||
assert rc == 0 and len(rpc.calls) == 1
|
||||
|
||||
|
||||
@@ -259,3 +278,54 @@ def test_rpc_failure_surfaces_error(capsys):
|
||||
rc = cli.main(["universe"], rpc=boom)
|
||||
assert rc == 3
|
||||
assert "daemon unreachable" in capsys.readouterr().err
|
||||
|
||||
|
||||
# --- cancel (control RPC operator backstop — dcy-022 / REQ-071) -------------
|
||||
_CANCEL_OK = {"status": "ok", "slot_id": "S-pending"}
|
||||
_CANCEL_IGNORED = {"status": "ignored", "slot_id": "S-1", "reason": "not_pending"}
|
||||
_CANCEL_NOT_FOUND = {"status": "error", "slot_id": "S-x", "reason": "slot_not_found"}
|
||||
|
||||
|
||||
def test_cancel_aborts_without_confirmation_no_rpc():
|
||||
rpc = _RpcRecorder(_CANCEL_OK)
|
||||
rc = cli.main(["cancel", "S-pending"], rpc=rpc, confirm=lambda _p: False)
|
||||
assert rc == 1 and rpc.calls == []
|
||||
|
||||
|
||||
def test_cancel_with_yes_sends_control_rpc(capsys):
|
||||
rpc = _RpcRecorder(_CANCEL_OK)
|
||||
rc = cli.main(["cancel", "S-pending", "--yes"], rpc=rpc)
|
||||
assert rc == 0
|
||||
call = rpc.calls[0]
|
||||
assert call["queue"] == "trex.control.req"
|
||||
assert call["payload"]["action"] == "cancel_pending"
|
||||
assert call["payload"]["slot_id"] == "S-pending"
|
||||
assert "cancel sent" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_cancel_ignored_not_pending_exits_zero(capsys):
|
||||
rpc = _RpcRecorder(_CANCEL_IGNORED)
|
||||
rc = cli.main(["cancel", "S-1", "--yes"], rpc=rpc)
|
||||
assert rc == 0
|
||||
assert "ignored" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_cancel_slot_not_found_exits_nonzero(capsys):
|
||||
rpc = _RpcRecorder(_CANCEL_NOT_FOUND)
|
||||
rc = cli.main(["cancel", "S-x", "--yes"], rpc=rpc)
|
||||
assert rc == 1
|
||||
|
||||
|
||||
def test_cancel_dry_run_does_not_submit(capsys):
|
||||
rpc = _RpcRecorder(_CANCEL_OK)
|
||||
rc = cli.main(["cancel", "S-pending", "--dry-run"], rpc=rpc)
|
||||
assert rc == 0 and rpc.calls == []
|
||||
assert "dry-run" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_cancel_json_outputs_json(capsys):
|
||||
rpc = _RpcRecorder(_CANCEL_OK)
|
||||
rc = cli.main(["cancel", "S-pending", "--yes", "--json"], rpc=rpc)
|
||||
assert rc == 0
|
||||
out = json.loads(capsys.readouterr().out)
|
||||
assert out["status"] == "ok"
|
||||
|
||||
Reference in New Issue
Block a user