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>
This commit is contained in:
@@ -168,6 +168,7 @@ def make_snapshot_rpc_handler(
|
||||
pause_store: Optional[PauseStore] = None,
|
||||
default_total_slots: int = 10,
|
||||
watchdog_store: Optional[WatchdogStore] = None,
|
||||
watchdog_timeout_sec: int = 120,
|
||||
killswitch_store=None,
|
||||
):
|
||||
"""Build the RPC handler for `trex.snapshot.req` (REQ-028).
|
||||
@@ -225,7 +226,9 @@ def make_snapshot_rpc_handler(
|
||||
trading_date=day,
|
||||
killswitch_store=killswitch_store,
|
||||
)
|
||||
return RpcResponse(payload=envelope.to_dict())
|
||||
payload = envelope.to_dict()
|
||||
_attach_watchdog(payload, slot_repo, day, watchdog_store, watchdog_timeout_sec)
|
||||
return RpcResponse(payload=payload)
|
||||
|
||||
# --- BAT-007 legacy stub-shape path ---
|
||||
view_raw = str(body.get("view") or "all")
|
||||
@@ -236,7 +239,7 @@ def make_snapshot_rpc_handler(
|
||||
if strategy_filter is not None:
|
||||
slots = [s for s in slots if s.strategy_id == strategy_filter]
|
||||
cap = slot_service.compute_slot_capacity(day, max(1, len(slot_repo.list_slots(day))))
|
||||
return RpcResponse(payload={
|
||||
payload = {
|
||||
"view": view_raw,
|
||||
"strategy_id": strategy_filter,
|
||||
"trading_date": day.isoformat(),
|
||||
@@ -248,11 +251,40 @@ def make_snapshot_rpc_handler(
|
||||
"total_slots": cap.total_slots,
|
||||
"scarcity_ratio": cap.scarcity_ratio,
|
||||
},
|
||||
})
|
||||
}
|
||||
_attach_watchdog(payload, slot_repo, day, watchdog_store, watchdog_timeout_sec)
|
||||
return RpcResponse(payload=payload)
|
||||
|
||||
return handler
|
||||
|
||||
|
||||
def _attach_watchdog(payload, slot_repo, day, watchdog_store, timeout_sec) -> None:
|
||||
"""Attach holding-slot watchdog state to a snapshot response (dcy-017/REQ-034).
|
||||
|
||||
`watchdog` = list of {slot_id, portfolio_id, symbol, expired, last_seen} for
|
||||
holding slots — lets `trex-core slots` surface watchdog ownership (strategy
|
||||
absence). Best-effort: any failure leaves the response unchanged (FR-4)."""
|
||||
if watchdog_store is None:
|
||||
return
|
||||
try:
|
||||
states = []
|
||||
for s in slot_repo.list_slots(day):
|
||||
if s.status is not SlotState.HOLDING:
|
||||
continue
|
||||
sid = str(s.slot_id)
|
||||
last = watchdog_store.last_seen(sid)
|
||||
states.append({
|
||||
"slot_id": sid,
|
||||
"portfolio_id": s.portfolio_id,
|
||||
"symbol": s.symbol,
|
||||
"expired": watchdog_store.is_expired(sid, timeout_sec),
|
||||
"last_seen": last.isoformat() if last else None,
|
||||
})
|
||||
payload["watchdog"] = states
|
||||
except Exception: # noqa: BLE001 - visibility must never break the response
|
||||
return
|
||||
|
||||
|
||||
# ---- pause/resume RPC handler (REQ-032 AC-4) --------------------------------
|
||||
|
||||
def make_pause_rpc_handler(pause_store: PauseStore):
|
||||
|
||||
Reference in New Issue
Block a user