fix: preserve sell event routing owner
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user