Files
genichinandClaude Opus 4.8 d4c5fcbba1 feat(alarm): trex-core 알람 emitter + trex.alerts exchange (dcy-033 BAT-049, Implemented)
AlarmEmitter(application, best-effort 비차단)+trex.alerts topic exchange 가산·격리+8 CRITICAL code 배선. 608 passed, opus QC PASS, 팀장 e2e(텔레그램 HTTP200) 통과. REQ-098~101 Implemented.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VHtM5Ws1HKCKQ4JGi6GRb5
2026-06-21 19:25:55 +09:00

104 lines
3.9 KiB
Python

"""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)