fix: preserve sell event routing owner

This commit is contained in:
Hermes Agent
2026-09-20 20:34:00 +09:00
parent 3f6a967aa1
commit c66acf6e47
20 changed files with 766 additions and 32 deletions
+73 -13
View File
@@ -95,6 +95,7 @@ class ExecutionResult:
stale_pending: int = 0 # dcy-013 / REQ-014 FR-3: pending orders over threshold
sell_skipped_no_balance: int = 0 # dcy-020 / REQ-062: broker flat -> sell skipped
timeout_cancelled: int = 0 # dcy-022 / REQ-069: cancel_order sent on timeout
sell_event_owner_missing: int = 0 # req-0011/ac-0039: pre-publish skip count
class ExecutionService:
@@ -555,13 +556,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-0011/ac-0038: SELL admission persists the canonical owner in
# sell_strategy_id. Capture before INACTIVE clears identity fields.
routing_strategy_id = (
self._sell_event_owner(slot)
if slot.status is SlotState.PENDING_SELL
else self._non_empty_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:
@@ -633,7 +634,7 @@ class ExecutionService:
# record subject). Best-effort: a publish failure must not roll back.
# 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._emit_event(event_type, updated, routing_strategy_id, r)
self._repo.clear_dispatch(slot.slot_id) # done with this order
def _on_partial_cancel(self, slot, fs, side: str, r: ExecutionResult) -> None:
@@ -646,9 +647,11 @@ 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
routing_strategy_id = (
self._sell_event_owner(slot)
if slot.status is SlotState.PENDING_SELL
else self._non_empty_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:
@@ -700,7 +703,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, routing_strategy_id)
self._emit_event(event_type, updated, routing_strategy_id, r)
self._repo.clear_dispatch(slot.slot_id)
def _check_stale_pending(self, slot, now: datetime, r: ExecutionResult) -> None:
@@ -860,7 +863,64 @@ class ExecutionService:
},
)
def _emit_event(self, event_type: str, slot, routing_strategy_id: str | None = None) -> None:
@staticmethod
def _non_empty_strategy_id(value: object) -> str | None:
return value if isinstance(value, str) and value.strip() else None
@classmethod
def _sell_event_owner(cls, slot) -> str | None:
return (
cls._non_empty_strategy_id(getattr(slot, "sell_strategy_id", None))
or cls._non_empty_strategy_id(getattr(slot, "strategy_id", None))
)
def _emit_event(
self,
event_type: str,
slot,
routing_strategy_id: str | None = None,
result: ExecutionResult | None = None,
) -> None:
if (
event_type == "slot_filled_sell"
and self._non_empty_strategy_id(routing_strategy_id) is None
):
if result is not None:
result.sell_event_owner_missing += 1
reason = "sell_event_owner_missing"
slot_id = getattr(slot, "slot_id", None) or getattr(slot, "id", None)
symbol = getattr(slot, "symbol", None)
_log.error(
"sell event skipped reason=%s instance_key=%s slot_id=%s "
"symbol=%s event_type=%s",
reason,
self._instance_key,
slot_id,
symbol,
event_type,
)
if self._alarm_emitter is not None:
try:
self._alarm_emitter.publish_alarm(
reason,
"SELL event owner missing; strategy publish skipped",
fields={
"instance_key": self._instance_key,
"slot_id": str(slot_id) if slot_id is not None else None,
"symbol": symbol,
"event_type": event_type,
"reason": reason,
},
)
except Exception: # noqa: BLE001 - alarm is best-effort
_log.error(
"sell owner alarm failed reason=%s instance_key=%s "
"slot_id=%s",
reason,
self._instance_key,
slot_id,
)
return
if self._event_emitter is None:
return
try:
+13 -1
View File
@@ -469,6 +469,18 @@ class IntentService:
return IntentDecision(
intent.intent_id, "rejected", None, f"slot_not_holding:{slot.status.value}"
)
# req-0011 / ac-0038: decide the SELL event owner at admission and
# persist it in the existing canonical slot field. An explicit SELL
# owner wins, then a preserved owner from a prior zero-fill cancel,
# then the legacy holding owner used by autonomous safety sells.
def non_empty(value: Optional[str]) -> Optional[str]:
return value if isinstance(value, str) and value.strip() else None
sell_event_owner = (
non_empty(intent.sell_strategy_id)
or non_empty(slot.sell_strategy_id)
or non_empty(slot.strategy_id)
)
# dcy-022 / REQ-068: resolve effective timeout.
effective_timeout = (
intent.timeout_sec
@@ -482,7 +494,7 @@ class IntentService:
SlotState.PENDING_SELL,
reason=intent.reason,
actor="intent_api",
sell_strategy_id=intent.sell_strategy_id,
sell_strategy_id=sell_event_owner,
# dcy-012 / REQ-007: optional sell limit (None => market sell).
limit_price=intent.limit_price,
# dcy-021 / REQ-062: partial-sell order qty (None => sell all);