전략 이탈로 슬롯이 내부 청산 루틴(워치독) 소유로 넘어간 상태가 무성이라 운영자가 인지할 수 없던 문제(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>
57 lines
2.3 KiB
Python
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
|