fix(slot-filled): 매도 체결 이벤트 NO_ROUTE 해소 — strategy_id 전이 전 캡처
INACTIVE 전이가 strategy_id를 None 초기화(REQ-057) → 라우팅키 slot_filled_sell._.{sym}
→ orb/tsl 바인딩 불일치 NO_ROUTE/DLQ. _on_filled/_on_partial_cancel에서 전이 직전
strategy_id 캡처해 emitter에 전달. wire 계약 불변(포맷 유지·값 교정). QC opus PASS, 747 passed/회귀 0.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VHtM5Ws1HKCKQ4JGi6GRb5
This commit is contained in:
@@ -41,7 +41,14 @@ _log = logging.getLogger(__name__)
|
||||
# slot transition events through MessagingTransport. Application layer stays
|
||||
# transport-agnostic (NFR-1). Slot type is duck-typed to keep the application
|
||||
# layer free of domain re-imports (the slot is just used for attribute access).
|
||||
SlotEventEmitter = Callable[[str, object], None] # (event_type, slot) -> None
|
||||
#
|
||||
# The optional third argument `routing_strategy_id` is a hint for the routing
|
||||
# key when the slot's own strategy_id has been cleared by the transition (e.g.
|
||||
# PENDING_SELL -> INACTIVE clears strategy_id on the slot row — REQ-057
|
||||
# _apply_transition_fields). The emitter MUST prefer this hint over
|
||||
# `slot.strategy_id` so that slot_filled_sell events reach the correct
|
||||
# per-strategy queue instead of being NO_ROUTEd to the DLQ.
|
||||
SlotEventEmitter = Callable[[str, object, Optional[str]], None] # (event_type, slot, routing_strategy_id) -> None
|
||||
|
||||
_MAX_DISPATCH_PER_TICK = 64
|
||||
|
||||
@@ -288,6 +295,13 @@ class ExecutionService:
|
||||
|
||||
def _on_filled(self, slot, fs, side: str, r: ExecutionResult) -> None:
|
||||
broker_order_id = slot.broker_order_id # captured BEFORE clear_dispatch
|
||||
# Capture strategy_id BEFORE the transition clears it.
|
||||
# PENDING_SELL -> INACTIVE clears strategy_id on the slot row
|
||||
# (_apply_transition_fields REQ-057), so we must capture it here to
|
||||
# supply as a routing hint for the slot_filled_sell event — otherwise the
|
||||
# routing key uses "_" and the message is NO_ROUTEd to the DLQ instead
|
||||
# of reaching trex.events.strategy.{strategy_id} (M2 REL-009 feedback).
|
||||
routing_strategy_id = slot.strategy_id
|
||||
# REQ-095: resolve fill price with limit_price backfill guard.
|
||||
fill_price, _was_backfilled = self._resolve_fill_price(fs, slot)
|
||||
if fill_price is None:
|
||||
@@ -342,7 +356,9 @@ class ExecutionService:
|
||||
self._record_ledger(slot, fs, side, broker_order_id, r, resolved_price=fill_price)
|
||||
# REQ-021/023: emit event AFTER transition + ledger enqueue (single
|
||||
# record subject). Best-effort: a publish failure must not roll back.
|
||||
self._emit_event(event_type, updated)
|
||||
# Pass routing_strategy_id so sell fills route correctly even after
|
||||
# strategy_id is cleared from the slot row (slot_filled_sell NO_ROUTE fix).
|
||||
self._emit_event(event_type, updated, routing_strategy_id)
|
||||
self._repo.clear_dispatch(slot.slot_id) # done with this order
|
||||
|
||||
def _on_partial_cancel(self, slot, fs, side: str, r: ExecutionResult) -> None:
|
||||
@@ -355,6 +371,9 @@ class ExecutionService:
|
||||
portion that actually filled.
|
||||
"""
|
||||
broker_order_id = slot.broker_order_id # captured BEFORE clear_dispatch
|
||||
# Capture strategy_id BEFORE the transition (same reason as _on_filled:
|
||||
# PENDING_SELL -> INACTIVE clears strategy_id, causing NO_ROUTE).
|
||||
routing_strategy_id = slot.strategy_id
|
||||
# REQ-095: resolve fill price with limit_price backfill guard.
|
||||
fill_price, _was_backfilled = self._resolve_fill_price(fs, slot)
|
||||
if fill_price is None:
|
||||
@@ -395,7 +414,7 @@ class ExecutionService:
|
||||
except SlotStateMismatchError:
|
||||
return # slot moved concurrently; next tick reconciles
|
||||
self._record_ledger(slot, fs, side, broker_order_id, r, resolved_price=fill_price)
|
||||
self._emit_event(event_type, updated)
|
||||
self._emit_event(event_type, updated, routing_strategy_id)
|
||||
self._repo.clear_dispatch(slot.slot_id)
|
||||
|
||||
def _check_stale_pending(self, slot, now: datetime, r: ExecutionResult) -> None:
|
||||
@@ -454,11 +473,11 @@ class ExecutionService:
|
||||
slot.slot_id, slot.broker_order_id,
|
||||
)
|
||||
|
||||
def _emit_event(self, event_type: str, slot) -> None:
|
||||
def _emit_event(self, event_type: str, slot, routing_strategy_id: Optional[str] = None) -> None:
|
||||
if self._event_emitter is None:
|
||||
return
|
||||
try:
|
||||
self._event_emitter(event_type, slot)
|
||||
self._event_emitter(event_type, slot, routing_strategy_id)
|
||||
except Exception: # noqa: BLE001 - event publish failure is non-critical
|
||||
return
|
||||
|
||||
|
||||
@@ -264,10 +264,18 @@ class Daemon:
|
||||
|
||||
# ------------------------------------------------------------ event hook
|
||||
def _make_event_emitter(self):
|
||||
def emit(event_type: str, slot) -> None:
|
||||
def emit(event_type: str, slot, routing_strategy_id: str | None = None) -> None:
|
||||
payload = slot_transition_event_payload(slot, event_type=event_type)
|
||||
# routing_strategy_id is supplied when the transition clears
|
||||
# slot.strategy_id (e.g. PENDING_SELL -> INACTIVE for slot_filled_sell).
|
||||
# Without this hint the routing key would use "_" and the message would
|
||||
# be NO_ROUTEd to the DLQ instead of reaching the strategy queue
|
||||
# (M2 REL-009 feedback / slot_filled NO_ROUTE fix).
|
||||
effective_strategy_id = (
|
||||
routing_strategy_id or getattr(slot, "strategy_id", None) or "_"
|
||||
)
|
||||
rk = topology.event_routing_key(
|
||||
event_type, slot.strategy_id or "_", slot.symbol or "_",
|
||||
event_type, effective_strategy_id, slot.symbol or "_",
|
||||
)
|
||||
try:
|
||||
self.event_publisher.publish_event(rk, payload)
|
||||
|
||||
Reference in New Issue
Block a user