"""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 typing import Optional from trex_core.application.alarm_emitter import AlarmEmitter from trex_core.application.portfolio import GLOBAL_KILLSWITCH, KillSwitchStore from trex_core.domain.slot import SlotRepository, SlotService _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, instance_key: str = "default", alarm_emitter: Optional[AlarmEmitter] = None, ) -> None: self._repo = slot_repo self._svc = slot_service self._store = killswitch_store self._instance_key = instance_key # dcy-020/REQ-056: instance-scoped sweep # dcy-033 / REQ-100: best-effort alarm emitter. self._alarm_emitter = alarm_emitter @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) # day-scoped exclusion key (REQ-044) holding = self._repo.list_holding_slots(self._instance_key) 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, ) # dcy-033 / REQ-100: CRITICAL alarm — emergency liquidation fired. if self._alarm_emitter is not None: self._alarm_emitter.publish_alarm( "kill_switch_fired", f"긴급 청산 발동: scope={scope} liquidated={liquidated}", fields={"scope": scope, "portfolio_id": portfolio_id, "liquidated": liquidated, "actor": actor}, ) 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)