feat: add kill-switch control flow
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
"""Kill-switch execution service (REQ-045 / BAT-012).
|
||||
|
||||
Operator emergency stop: liquidate all `holding` slots in a scope
|
||||
(portfolio or global) by reusing SlotService.sell() (REQ-036), then mark the
|
||||
scope day-excluded (REQ-044) so strategy execution is masked for the day
|
||||
(REQ-046). Independent of the dcy-007 safety-sell worker — concurrent sells
|
||||
on the same slot are idempotent (REQ-036 PENDING_SELL guard).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import date as date_cls
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from trex_core.application.portfolio import GLOBAL_KILLSWITCH, KillSwitchStore
|
||||
from trex_core.domain.slot import SlotRepository, SlotService, SlotState
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
_ACTOR = "kill-switch"
|
||||
|
||||
|
||||
class KillSwitchService:
|
||||
"""Range-scoped emergency liquidation + day-scoped exclusion (REQ-045)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
slot_repo: SlotRepository,
|
||||
slot_service: SlotService,
|
||||
killswitch_store: KillSwitchStore,
|
||||
) -> None:
|
||||
self._repo = slot_repo
|
||||
self._svc = slot_service
|
||||
self._store = killswitch_store
|
||||
|
||||
@staticmethod
|
||||
def _trading_date(now: datetime) -> date_cls:
|
||||
return now.astimezone(timezone.utc).date()
|
||||
|
||||
def fire(
|
||||
self,
|
||||
*,
|
||||
scope: str,
|
||||
portfolio_id: str | None = None,
|
||||
actor: str = "operator",
|
||||
now: datetime | None = None,
|
||||
) -> int:
|
||||
"""Liquidate holding slots in scope + mark day-excluded. Returns count.
|
||||
|
||||
scope: "portfolio" (requires portfolio_id) | "global".
|
||||
"""
|
||||
now = now or datetime.now(tz=timezone.utc)
|
||||
day = self._trading_date(now)
|
||||
holding = [s for s in self._repo.list_slots(day) if s.status is SlotState.HOLDING]
|
||||
if scope == "portfolio":
|
||||
holding = [s for s in holding if s.portfolio_id == portfolio_id]
|
||||
|
||||
liquidated = 0
|
||||
for s in holding:
|
||||
try:
|
||||
self._svc.sell(s.slot_id, actor=_ACTOR, reason="emergency_stop")
|
||||
liquidated += 1
|
||||
except Exception: # noqa: BLE001 - one slot must not stop the sweep
|
||||
_log.warning("kill-switch sell failed (slot=%s)", s.slot_id, exc_info=True)
|
||||
|
||||
# Mark the scope day-excluded so strategy execution is masked (REQ-046).
|
||||
key = GLOBAL_KILLSWITCH if scope == "global" else (portfolio_id or "")
|
||||
self._store.exclude(key, day, actor=actor)
|
||||
|
||||
_log.warning(
|
||||
"kill-switch fired: actor=%s scope=%s portfolio_id=%s liquidated=%d",
|
||||
actor, scope, portfolio_id, liquidated,
|
||||
)
|
||||
return liquidated
|
||||
|
||||
def resume(
|
||||
self,
|
||||
*,
|
||||
scope: str,
|
||||
portfolio_id: str | None = None,
|
||||
now: datetime | None = None,
|
||||
) -> None:
|
||||
"""Manually clear the day-scoped exclusion (REQ-046). Sold slots stay sold."""
|
||||
now = now or datetime.now(tz=timezone.utc)
|
||||
day = self._trading_date(now)
|
||||
key = GLOBAL_KILLSWITCH if scope == "global" else (portfolio_id or "")
|
||||
self._store.clear(key, day)
|
||||
_log.info("kill-switch resume: scope=%s portfolio_id=%s", scope, portfolio_id)
|
||||
Reference in New Issue
Block a user