Files
trex-core/trex_core/application/watchdog.py
T
genichinandClaude Opus 4.8 6d7bca1460 feat(safety-sell): dcy-017 워치독 소유권 가시화 — snapshot 상태 + 발동 로깅 (BAT-021)
전략 이탈로 슬롯이 내부 청산 루틴(워치독) 소유로 넘어간 상태가 무성이라
운영자가 인지할 수 없던 문제(issue #12)를 가시화한다.

거버넌스: 전부 change-req (신규 REQ 없음, dcy-017 OQ-3; Redis 미채택).
- REQ-034/021: WatchdogStore.last_seen + snapshot RPC 응답에 holding 슬롯
  워치독 상태(expired/last_seen) read-only 동봉(best-effort)
- REQ-032: trex-core slots 가 expired 슬롯을 WATCHDOG-OWNED(전략 이탈) 표시
- REQ-037/038/039: safety-sell 워치독 소유 진입(WARN)/이탈(INFO) + 발동
  (forced_close·stop_loss=WARN, take_profit·market_close=INFO) 로그 +
  in-process 카운터(SafetySellService.counters), 구조화 필드(slot_id/
  portfolio_id/reason/phase/instance_key). per-tick 스킵은 카운터만
- REQ-042: runtime-flows/interface-contract 정합

best-effort 격리(NFR-1): 모든 가시화는 예외 격리 — 워치독 판정·청산 불변.
dcy-016 억제 읽기로 관측이 워치독을 리셋하지 않음(비침습).

검증: 단위 335 passed/34 skipped, 인프라 게이트(PG+RabbitMQ) 369 passed, ruff clean.
신규 test_safety_sell_visibility(6) + snapshot watchdog(1) + CLI(1). BAT-021 rc.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 17:10:26 +09:00

57 lines
2.3 KiB
Python

"""Per-slot watchdog store for safety-sell (REQ-034 / BAT-010).
WatchdogStore tracks the last time a snapshot RPC response included each
holding slot. BAT-011 trigger loops use is_expired() to decide whether a
slot is unmonitored (strategy module absent) and therefore eligible for
autonomous safety-sell actions.
Thread-safety: dict reads and writes in CPython are GIL-atomic for simple
assignments, which is sufficient here — one writer (snapshot-rpc thread)
and one or more readers (BAT-011 trigger-loop threads). No explicit lock
is needed.
"""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Optional
class WatchdogStore:
"""In-memory per-slot last_seen_at tracker (REQ-034)."""
def __init__(self) -> None:
self._store: dict[str, datetime] = {}
self._started_at: datetime = datetime.now(tz=timezone.utc)
def update(self, slot_id: str) -> None:
"""Record that slot_id appeared in a snapshot RPC response."""
self._store[slot_id] = datetime.now(tz=timezone.utc)
def last_seen(self, slot_id: str) -> Optional[datetime]:
"""Return the last time slot_id was seen, or None if never (dcy-017).
Read-only accessor for watchdog-ownership visibility (REQ-034)."""
return self._store.get(slot_id)
def is_expired(self, slot_id: str, timeout_sec: int) -> bool:
"""Return True if slot_id has not been seen for longer than timeout_sec.
A slot that has never been seen is treated as expired (returns True)
unless in_grace_period() is True — callers must check grace period
before calling this method when they want to suppress false positives.
"""
last = self._store.get(slot_id)
if last is None:
return True
elapsed = (datetime.now(tz=timezone.utc) - last).total_seconds()
return elapsed > timeout_sec
def in_grace_period(self, grace_sec: int) -> bool:
"""Return True if the daemon started less than grace_sec seconds ago.
During the grace period ALL expiry judgements must be deferred —
no slot should be treated as watchdog-expired (REQ-034 AC-4).
"""
elapsed = (datetime.now(tz=timezone.utc) - self._started_at).total_seconds()
return elapsed < grace_sec