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
+83 -28
View File
@@ -29,7 +29,12 @@ from datetime import datetime, timezone
from typing import Callable, Optional
from trex_core.application.alarm_emitter import AlarmEmitter
from trex_core.application.broker_port import BrokerPort, OrderRequest
from trex_core.application.broker_port import (
BrokerPort,
CancelOutcome,
CancelState,
OrderRequest,
)
from trex_core.application.ledger_outbox import LedgerOutbox
from trex_core.application.ledger_port import LedgerTrade
from trex_core.domain.pricing import ceil_to_tick, floor_to_tick
@@ -546,12 +551,28 @@ class ExecutionService:
"""
if str(slot.broker_order_id or "").startswith("submission-unknown:"):
return
if slot.cancel_state is not None:
return
if slot.timeout_sec is None or slot.updated_at is None:
return
age = (now - slot.updated_at).total_seconds()
if age < slot.timeout_sec:
return
qty = int(slot.qty or 0)
# Write UNKNOWN before external I/O. If the process dies after KIS
# receives the request but before the response is persisted, restart
# polls the order instead of issuing a blind second cancellation.
if not self._repo.mark_cancel_outcome(
slot.slot_id,
str(slot.broker_order_id),
CancelState.UNKNOWN.value,
):
_log.error(
"cancel attempt reservation conflict: slot=%s broker_order_id=%s",
slot.slot_id,
slot.broker_order_id,
)
return
try:
cancel_kwargs = {
"side": side,
@@ -560,25 +581,40 @@ class ExecutionService:
}
if slot.venue != "KRX":
cancel_kwargs["venue"] = slot.venue
accepted = self._broker.cancel_order(slot.broker_order_id, **cancel_kwargs)
outcome = self._broker.cancel_order(slot.broker_order_id, **cancel_kwargs)
except Exception: # noqa: BLE001 - adapter failure becomes durable unknown
outcome = CancelOutcome(CancelState.UNKNOWN, "adapter_exception")
_log.warning(
"timeout cancel outcome unknown: slot=%s broker_order_id=%s",
slot.slot_id,
slot.broker_order_id,
)
persisted = self._repo.mark_cancel_outcome(
slot.slot_id,
str(slot.broker_order_id),
outcome.state.value,
)
if not persisted:
_log.error(
"cancel outcome persistence conflict: slot=%s broker_order_id=%s "
"outcome=%s",
slot.slot_id,
slot.broker_order_id,
outcome.state.value,
)
return
if outcome.is_requested:
r.timeout_cancelled += 1
_log.warning(
"timeout cancel sent: slot=%s status=%s broker_order_id=%s "
"age_sec=%.0f timeout_sec=%d broker_accepted=%s",
slot.slot_id,
slot.status.value,
slot.broker_order_id,
age,
slot.timeout_sec,
accepted,
)
except Exception: # noqa: BLE001 - best-effort; do not block the loop
_log.warning(
"timeout cancel failed (exception): slot=%s broker_order_id=%s "
"(will retry next tick if still pending)",
slot.slot_id,
slot.broker_order_id,
)
_log.warning(
"timeout cancel result: slot=%s status=%s broker_order_id=%s "
"age_sec=%.0f timeout_sec=%d outcome=%s",
slot.slot_id,
slot.status.value,
slot.broker_order_id,
age,
slot.timeout_sec,
outcome.state.value,
)
def _emit_event(self, event_type: str, slot, routing_strategy_id: Optional[str] = None) -> None:
if self._event_emitter is None:
@@ -655,9 +691,9 @@ class ExecutionService:
def cancel_pending_slot(self, slot_id) -> str:
"""Operator backstop: send cancel_order for a pending slot (dcy-022/REQ-071).
Returns "ok" (cancel sent), "not_pending" (slot is HOLDING/INACTIVE),
or "slot_not_found". Does NOT transition the slot — the execution loop's
next poll handles the rollback via the normal is_terminal_reject path.
Returns "ok" (request accepted), "cancel_rejected", "cancel_unknown",
"not_pending", "not_dispatched", or "slot_not_found". Does NOT
transition the slot — fill polling alone confirms cancelled/filled.
"""
from uuid import UUID
@@ -680,21 +716,40 @@ class ExecutionService:
}
if slot.venue != "KRX":
cancel_kwargs["venue"] = slot.venue
self._broker.cancel_order(slot.broker_order_id, **cancel_kwargs)
except Exception: # noqa: BLE001 - best-effort
# Durable unknown reservation closes the crash window between the
# outbound request and saving its immediate result.
if not self._repo.mark_cancel_outcome(
slot.slot_id,
str(slot.broker_order_id),
CancelState.UNKNOWN.value,
):
return "cancel_state_conflict"
outcome = self._broker.cancel_order(slot.broker_order_id, **cancel_kwargs)
except Exception: # noqa: BLE001 - adapter failure becomes durable unknown
outcome = CancelOutcome(CancelState.UNKNOWN, "adapter_exception")
_log.warning(
"operator cancel failed: slot=%s broker_order_id=%s",
"operator cancel outcome unknown: slot=%s broker_order_id=%s",
slot.slot_id,
slot.broker_order_id,
)
return "cancel_failed"
if not self._repo.mark_cancel_outcome(
slot.slot_id,
str(slot.broker_order_id),
outcome.state.value,
):
return "cancel_state_conflict"
_log.warning(
"operator cancel sent: slot=%s status=%s broker_order_id=%s",
"operator cancel result: slot=%s status=%s broker_order_id=%s outcome=%s",
slot.slot_id,
slot.status.value,
slot.broker_order_id,
outcome.state.value,
)
return "ok"
if outcome.state is CancelState.REQUESTED:
return "ok"
if outcome.state is CancelState.REJECTED:
return "cancel_rejected"
return "cancel_unknown"
def _rollback(self, slot, *, reason: str) -> None:
total = self._balance_map().get(slot.symbol or "", 0)