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);
+11
View File
@@ -327,6 +327,16 @@ class SlotService:
raise SlotTransitionError(
f"sell() requires holding state, got: {slot.status.value}"
)
# req-0011 / ac-0038: autonomous sells have no SellIntent, so persist
# the already-owned SELL identity or inherit the holding owner.
sell_event_owner = next(
(
value
for value in (slot.sell_strategy_id, slot.strategy_id)
if isinstance(value, str) and value.strip()
),
None,
)
self.transit_slot(
slot_id,
SlotState.HOLDING,
@@ -334,6 +344,7 @@ class SlotService:
reason=reason,
actor=actor,
limit_price=limit_price,
sell_strategy_id=sell_event_owner,
)
# ------------------------------------------------------------
+30 -11
View File
@@ -386,28 +386,47 @@ class Daemon:
def _make_event_emitter(self):
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).
# routing_strategy_id is captured before transitions that clear owner
# fields (for SELL, the admission-owned sell_strategy_id). Missing
# SELL ownership is blocked below instead of synthesizing "_".
effective_strategy_id = (
routing_strategy_id or getattr(slot, "strategy_id", None) or "_"
routing_strategy_id or getattr(slot, "strategy_id", None)
)
# req-0011/ac-0039 defense in depth: application execution normally
# skips ownerless SELL events before this hook. Never synthesize the
# legacy "_" owner if a direct caller violates that boundary.
if (
event_type == "slot_filled_sell"
and not (
isinstance(effective_strategy_id, str)
and effective_strategy_id.strip()
)
):
_log.error(
"sell event publish blocked reason=sell_event_owner_missing "
"slot_id=%s symbol=%s event_type=%s",
getattr(slot, "slot_id", None) or getattr(slot, "id", None),
getattr(slot, "symbol", None),
event_type,
)
return
effective_strategy_id = effective_strategy_id or "_"
rk = topology.event_routing_key(
event_type, effective_strategy_id, slot.symbol or "_",
)
try:
self.event_publisher.publish_event(rk, payload)
except Exception: # noqa: BLE001 - publish failure must not roll back
# REQ-093: escalate to ERROR so operators can observe via log
# alert. Include routing key + slot identity for correlation.
except Exception as exc: # noqa: BLE001 - publish failure must not roll back
# req-0011/ac-0039: transport failures are observable without
# rendering raw exception text, traceback, broker payload, or
# connection details into logs.
_log.error(
"event publish failed (rk=%s slot_id=%s strategy=%s)",
"event publish failed reason=event_publish_failed "
"rk=%s slot_id=%s strategy=%s exc_type=%s",
rk,
getattr(slot, "slot_id", None) or getattr(slot, "id", None),
getattr(slot, "strategy_id", None),
exc_info=True,
type(exc).__name__,
)
return emit