Merge pull request #36 from fourblocks2020/feat/slot-filled-no-route

fix(slot-filled): 매도 체결 이벤트 NO_ROUTE 라우팅 갭 해소 (M3)
This commit is contained in:
2026-06-30 13:54:37 +09:00
committed by GitHub
3 changed files with 131 additions and 10 deletions
+24 -5
View File
@@ -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
+10 -2
View File
@@ -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)