529 lines
22 KiB
Python
529 lines
22 KiB
Python
"""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,
|
|
CancelOutcome,
|
|
CancelState,
|
|
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.cancel_outcome = CancelOutcome(CancelState.REQUESTED)
|
|
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, venue="KRX"):
|
|
self.cancel_calls.append((broker_order_id, side, symbol, qty, venue))
|
|
if self.cancel_raises:
|
|
raise RuntimeError("broker error")
|
|
return self.cancel_outcome
|
|
|
|
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": "SYM"})
|
|
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_floor_to_tick(self):
|
|
"""buy: effective_limit = floor_to_tick(entry * (1 + pct/100)).
|
|
|
|
dcy-030/REQ-070 CHG: cap-respecting direction (was ceil). entry=1000
|
|
is in the <2,000 band (tick 1) so floor_to_tick(1005.0) = 1005.
|
|
"""
|
|
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]
|
|
assert order.limit_price == 1005
|
|
|
|
def test_buy_slippage_fractional_floors_down(self):
|
|
"""floor_to_tick(1001 * 1.003) = floor(1004.003) = 1004 (tick 1)."""
|
|
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()
|
|
# cap-respecting: floor (not ceil). 1004.003 -> 1004.
|
|
assert broker.orders[0].limit_price == 1004
|
|
|
|
def test_sell_slippage_ceil_to_tick(self):
|
|
"""sell: effective_limit = ceil_to_tick(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"]
|
|
# ceil_to_tick(2000 * 0.99) = ceil_to_tick(1980.0) = 1980 (tick 5, exact).
|
|
assert sell_orders[0].limit_price == 1980
|
|
|
|
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
|
|
|
|
def test_issue28_buy_147700_snaps_to_valid_tick(self):
|
|
"""issue #28: 147,700 @ 0.5% → floor_to_tick(148438.5) = 148400 (tick 100)."""
|
|
broker = _RecordingBroker()
|
|
repo, svc, intents, execution = _wire(broker)
|
|
_buy_to_pending(intents, entry_price=147700, slippage_pct=0.5)
|
|
broker.responder = lambda side: FillStatus(state="accepted")
|
|
execution.run_once()
|
|
assert broker.orders[0].limit_price == 148400
|
|
|
|
def test_issue28_buy_54500_snaps_to_valid_tick(self):
|
|
"""issue #28: 54,500 @ 0.5% → floor_to_tick(54772.5) = 54700 (tick 100)."""
|
|
broker = _RecordingBroker()
|
|
repo, svc, intents, execution = _wire(broker)
|
|
_buy_to_pending(intents, entry_price=54500, slippage_pct=0.5)
|
|
broker.responder = lambda side: FillStatus(state="accepted")
|
|
execution.run_once()
|
|
assert broker.orders[0].limit_price == 54700
|
|
|
|
def test_buy_sub_tick_band_floors_to_entry_tick(self):
|
|
"""band < 1 tick: buy effective ≥ entry's valid tick (no under-fill).
|
|
|
|
entry=147700 (on tick 100), pct=0.01 → raw=147714.77, floor_to_tick=147700;
|
|
guard max(147700, ceil_to_tick(147700)=147700) = 147700 ≥ entry, valid tick.
|
|
"""
|
|
broker = _RecordingBroker()
|
|
repo, svc, intents, execution = _wire(broker)
|
|
_buy_to_pending(intents, entry_price=147700, slippage_pct=0.01)
|
|
broker.responder = lambda side: FillStatus(state="accepted")
|
|
execution.run_once()
|
|
eff = broker.orders[0].limit_price
|
|
assert eff == 147700
|
|
assert eff % 100 == 0 # valid KRX tick
|
|
assert eff <= 147700 * 1.0001 # within slippage ceiling
|
|
|
|
def test_buy_snap_never_exceeds_slippage_ceiling(self):
|
|
"""cap-respecting: buy effective ≤ entry*(1+pct/100), always a valid tick."""
|
|
broker = _RecordingBroker()
|
|
repo, svc, intents, execution = _wire(broker)
|
|
_buy_to_pending(intents, entry_price=35000, slippage_pct=0.7)
|
|
broker.responder = lambda side: FillStatus(state="accepted")
|
|
execution.run_once()
|
|
eff = broker.orders[0].limit_price
|
|
# 35000*1.007 = 35245 -> tick 50 -> floor_to_tick = 35200
|
|
assert eff == 35200
|
|
assert eff % 50 == 0
|
|
assert eff <= 35000 * 1.007
|
|
|
|
|
|
# ============================================================================
|
|
# REQ-085 / REQ-087: order-result logging + reject ERROR visibility (dcy-029)
|
|
# ============================================================================
|
|
|
|
class TestOrderResultLogging:
|
|
|
|
def test_submit_and_fill_are_logged_with_event_keys(self, caplog):
|
|
from trex_core.runtime import logging_setup as ls
|
|
broker = _RecordingBroker()
|
|
repo, svc, intents, execution = _wire(broker)
|
|
_buy_to_pending(intents, entry_price=1000, slippage_pct=None)
|
|
broker.responder = lambda side: FillStatus(
|
|
state="filled", filled_qty=10, avg_price=1000.0,
|
|
execution_id="e1", fee=1.5, tax=0.0,
|
|
)
|
|
with caplog.at_level(logging.INFO):
|
|
execution.run_once() # dispatch (submit) + poll (fill)
|
|
text = "\n".join(r.getMessage() for r in caplog.records)
|
|
assert "event=order_submit" in text
|
|
assert "event=order_fill" in text
|
|
assert "fee=1.5" in text
|
|
# correlation id set to the slot_id (intent->order->fill join key)
|
|
sid = broker.orders[0].slot_id
|
|
assert ls.get_correlation_id() == sid
|
|
|
|
def test_reject_is_logged_at_error_level(self, caplog):
|
|
broker = _RecordingBroker()
|
|
repo, svc, intents, execution = _wire(broker)
|
|
broker.submit_order = lambda req: OrderAck(
|
|
accepted=False, broker_order_id=None, reason="주식주문호가단위 오류",
|
|
)
|
|
_buy_to_pending(intents, entry_price=1000)
|
|
with caplog.at_level(logging.INFO):
|
|
execution.run_once()
|
|
rejects = [r for r in caplog.records if "event=order_reject" in r.getMessage()]
|
|
assert rejects, "reject must be logged"
|
|
assert all(r.levelno == logging.ERROR for r in rejects)
|
|
assert "호가단위" in rejects[0].getMessage()
|
|
|
|
def test_sell_reject_returns_to_holding_without_new_attempt(self):
|
|
broker = _RecordingBroker()
|
|
repo, _, intents, execution = _wire(broker)
|
|
slot_id = _buy_to_pending(intents)
|
|
assert slot_id is not None
|
|
execution.run_once()
|
|
holding = repo.fetch_slot(slot_id)
|
|
assert holding is not None and holding.status is SlotState.HOLDING
|
|
|
|
intents.submit_sell(SellIntent(intent_id="s1", slot_id=slot_id))
|
|
broker.submit_order = lambda req: (
|
|
broker.orders.append(req)
|
|
or OrderAck(accepted=False, reason="broker_rejected", venue=req.venue)
|
|
)
|
|
|
|
result = execution.run_once()
|
|
recovered = repo.fetch_slot(slot_id)
|
|
assert recovered is not None
|
|
assert recovered.status is SlotState.HOLDING
|
|
assert recovered.qty == 10
|
|
assert recovered.broker_order_id is None
|
|
assert result.rolled_back == 1
|
|
assert len([order for order in broker.orders if order.side == "sell"]) == 1
|
|
|
|
execution.run_once()
|
|
assert len([order for order in broker.orders if order.side == "sell"]) == 1
|
|
|
|
def test_normal_fill_emits_no_error(self, caplog):
|
|
broker = _RecordingBroker()
|
|
repo, svc, intents, execution = _wire(broker)
|
|
_buy_to_pending(intents, entry_price=1000)
|
|
broker.responder = lambda side: FillStatus(
|
|
state="filled", filled_qty=10, avg_price=1000.0,
|
|
execution_id="e1", fee=0.0, tax=0.0,
|
|
)
|
|
with caplog.at_level(logging.INFO):
|
|
execution.run_once()
|
|
assert not [r for r in caplog.records if r.levelno >= logging.ERROR]
|
|
|
|
|
|
# ============================================================================
|
|
# 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,
|
|
order_submitted_at=datetime.now(tz=timezone.utc) - timedelta(seconds=60),
|
|
)
|
|
r = execution.run_once()
|
|
assert r.timeout_cancelled == 1
|
|
assert len(broker.cancel_calls) == 1
|
|
assert repo.fetch_slot(sid).cancel_state == "requested"
|
|
execution.run_once()
|
|
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_updated_at_does_not_move_hard_fallback_deadline(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
|
|
# Mutable row bookkeeping is not the dispatch/deadline clock.
|
|
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,
|
|
order_submitted_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
|
|
slot = repo.fetch_slot(sid)
|
|
assert slot.status is SlotState.PENDING_BUY
|
|
assert slot.cancel_state == "unknown"
|
|
execution.run_once()
|
|
ExecutionService(svc, repo, broker).run_once()
|
|
assert len(broker.cancel_calls) == 1
|
|
|
|
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,
|
|
order_submitted_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_rejection_is_persisted_without_retry(self):
|
|
broker = _RecordingBroker()
|
|
broker.cancel_outcome = CancelOutcome(
|
|
CancelState.REJECTED,
|
|
"broker_rejected",
|
|
)
|
|
repo, svc, intents, execution = _wire(broker)
|
|
sid = _buy_to_pending(intents, timeout_sec=1)
|
|
broker.responder = lambda side: FillStatus(state="accepted")
|
|
execution.run_once()
|
|
slot = repo.fetch_slot(sid)
|
|
repo._slots[sid] = replace(
|
|
slot,
|
|
order_submitted_at=datetime.now(tz=timezone.utc) - timedelta(seconds=60),
|
|
)
|
|
|
|
result = execution.run_once()
|
|
assert result.timeout_cancelled == 0
|
|
assert repo.fetch_slot(sid).cancel_state == "rejected"
|
|
ExecutionService(svc, repo, broker).run_once()
|
|
assert len(broker.cancel_calls) == 1
|
|
|
|
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,
|
|
order_submitted_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
|
|
slot = repo.fetch_slot(sid)
|
|
assert slot.status is SlotState.HOLDING
|
|
assert slot.cancel_state is None
|
|
assert slot.cancel_requested_at is None
|
|
|
|
|
|
# ============================================================================
|
|
# 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_exception_returns_unknown(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_unknown"
|
|
assert repo.fetch_slot(sid).cancel_state == "unknown"
|
|
|
|
def test_cancel_rejected_is_distinct_and_persisted(self):
|
|
broker = _RecordingBroker()
|
|
broker.cancel_outcome = CancelOutcome(
|
|
CancelState.REJECTED,
|
|
"broker_rejected",
|
|
)
|
|
repo, svc, intents, execution = _wire(broker)
|
|
sid = _buy_to_pending(intents)
|
|
broker.responder = lambda side: FillStatus(state="accepted")
|
|
execution.run_once()
|
|
|
|
assert execution.cancel_pending_slot(sid) == "cancel_rejected"
|
|
assert repo.fetch_slot(sid).cancel_state == "rejected"
|
|
|
|
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
|