fix: persist uncertain cancel outcomes

This commit is contained in:
jordan
2026-07-29 17:17:14 +09:00
parent a656350a35
commit 3dd8eefc92
21 changed files with 416 additions and 76 deletions
+10 -2
View File
@@ -7,7 +7,12 @@ from __future__ import annotations
from trex_core.adapters.broker.paper import PaperBroker
from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore
from trex_core.application.broker_port import FillStatus, OrderAck
from trex_core.application.broker_port import (
CancelOutcome,
CancelState,
FillStatus,
OrderAck,
)
from trex_core.application.execution import ExecutionService
from trex_core.application.intents import BuyIntent, IntentService, SellIntent
from trex_core.domain.slot import SlotService, SlotState
@@ -78,7 +83,10 @@ def test_unknown_submit_outcome_stays_dispatched_without_retry():
def cancel_order(self, broker_order_id, *, side, symbol, qty, venue="KRX"):
self.cancel_count += 1
return False
return CancelOutcome(
CancelState.UNKNOWN,
"submission_outcome_unknown",
)
broker = UnknownOutcomeBroker()
repo, svc, intents, execution = _wire(broker)
+9 -3
View File
@@ -13,7 +13,13 @@ from datetime import datetime, timedelta, timezone
from trex_core.adapters.ledger.in_memory import InMemoryLedger
from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore
from trex_core.application.broker_port import BalanceItem, FillStatus, OrderAck
from trex_core.application.broker_port import (
BalanceItem,
CancelOutcome,
CancelState,
FillStatus,
OrderAck,
)
from trex_core.application.execution import ExecutionService
from trex_core.application.intents import BuyIntent, IntentService, SellIntent
from trex_core.application.ledger_outbox import InMemoryLedgerOutbox, LedgerWriter
@@ -46,8 +52,8 @@ class ProgrammableBroker:
self.polls += 1
return self.responder(side)
def cancel_order(self, broker_order_id, *, side, symbol, qty):
return True
def cancel_order(self, broker_order_id, *, side, symbol, qty, venue="KRX"):
return CancelOutcome(CancelState.REQUESTED)
def get_balance(self):
# M2 (dcy-020/REQ-062): sell dispatch reads the broker balance.
@@ -19,6 +19,8 @@ from trex_core.adapters.ledger.in_memory import InMemoryLedger
from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore
from trex_core.application.broker_port import (
BalanceItem,
CancelOutcome,
CancelState,
FillStatus,
OrderAck,
OrderRequest,
@@ -83,8 +85,16 @@ class StubBroker:
return FillStatus(state="unknown")
return o.fill_status
def cancel_order(self, broker_order_id: str, *, side: str, symbol: str, qty: int) -> bool:
return False
def cancel_order(
self,
broker_order_id: str,
*,
side: str,
symbol: str,
qty: int,
venue: str = "KRX",
) -> CancelOutcome:
return CancelOutcome(CancelState.REJECTED, "order_not_cancellable")
def get_balance(self) -> list[BalanceItem]:
return [
+62 -6
View File
@@ -11,7 +11,14 @@ 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.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
@@ -26,6 +33,7 @@ class _RecordingBroker:
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,
@@ -47,7 +55,7 @@ class _RecordingBroker:
self.cancel_calls.append((broker_order_id, side, symbol, qty, venue))
if self.cancel_raises:
raise RuntimeError("broker error")
return True
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]
@@ -287,6 +295,9 @@ class TestTimeoutCancel:
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()
@@ -330,7 +341,12 @@ class TestTimeoutCancel:
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
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."""
@@ -347,6 +363,28 @@ class TestTimeoutCancel:
# 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,
updated_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()
@@ -368,7 +406,10 @@ class TestTimeoutCancel:
)
r2 = execution.run_once()
assert r2.filled == 1
assert repo.fetch_slot(sid).status is SlotState.HOLDING
slot = repo.fetch_slot(sid)
assert slot.status is SlotState.HOLDING
assert slot.cancel_state is None
assert slot.cancel_requested_at is None
# ============================================================================
@@ -418,7 +459,7 @@ class TestCancelPendingSlot:
result = execution.cancel_pending_slot(sid)
assert result == "not_dispatched"
def test_cancel_fail_returns_cancel_failed(self):
def test_cancel_exception_returns_unknown(self):
broker = _RecordingBroker()
broker.cancel_raises = True
repo, svc, intents, execution = _wire(broker)
@@ -427,7 +468,22 @@ class TestCancelPendingSlot:
execution.run_once() # dispatch
result = execution.cancel_pending_slot(sid)
assert result == "cancel_failed"
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)."""
+10 -3
View File
@@ -4,7 +4,14 @@ from __future__ import annotations
from uuid import UUID
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.broker_port import (
BalanceItem,
CancelOutcome,
CancelState,
FillStatus,
OrderAck,
OrderRequest,
)
from trex_core.application.execution import ExecutionService
from trex_core.application.intents import BuyIntent, IntentService
from trex_core.application.messaging import RpcRequest
@@ -38,9 +45,9 @@ class RecordingBroker:
symbol: str,
qty: int,
venue: str = "KRX",
) -> bool:
) -> CancelOutcome:
self.cancel_venues.append(venue)
return True
return CancelOutcome(CancelState.REQUESTED)
def get_balance(self) -> list[BalanceItem]:
return []