dcy-007(core 자율 안전 매도) 전체 delivery. BAT-010 기반 인프라가 미커밋 상태였고 BAT-011 트리거 레이어와 공유 파일에서 섞여 있어 한 커밋으로 묶음. BAT-010 (REQ-034/035/036/041, release-candidate): - WatchdogStore(application/watchdog.py) + snapshot RPC 워치독 갱신 - Slot stop_loss_price/take_profit_price 필드 + 004_add_price_thresholds.sql - SlotService.sell() 자율 매도 내부 경로 + idempotency - SafetySellConfig env 5종 파싱 BAT-011 (REQ-037/038/039/040/042, release-candidate): - SafetySellService(application/safety_sell.py): forced_close 만기 / stop-loss· take-profit KIS polling / 장 마감 강제 청산 + trex-safety-sell 7번째 스레드 - BrokerPort.get_current_price + KIS inquire-price(TTTC8397R/VTTC8397R, stck_prpr) + paper stub - SlotService.update_thresholds() + intent RPC update_thresholds 액션 (부분 갱신/null 해제, 워치독 last_seen_at 리셋) - baseline 3문서 정합(interface-contract/runtime-flows/project-structure) 거버넌스: REQ-034~042 Implemented 동기화, BAT-010/011 release-candidate, dcy-007 discovery 포함. 검증: 신규 38 테스트 green, 단위 236 passed/23 skipped, 인프라 게이트(PG+RabbitMQ) 259 passed(마이그레이션 001~004 round-trip 포함), ruff clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
104 lines
3.9 KiB
Python
104 lines
3.9 KiB
Python
"""WatchdogStore unit tests (REQ-034 / BAT-010)."""
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
|
|
from trex_core.application.watchdog import WatchdogStore
|
|
|
|
|
|
def _now():
|
|
return datetime.now(tz=timezone.utc)
|
|
|
|
|
|
class TestWatchdogUpdate:
|
|
def test_update_marks_slot_as_seen(self):
|
|
"""AC-1: update(slot_id) records current time in store."""
|
|
store = WatchdogStore()
|
|
before = _now()
|
|
store.update("slot-A")
|
|
after = _now()
|
|
last = store._store.get("slot-A")
|
|
assert last is not None
|
|
assert before <= last <= after
|
|
|
|
def test_update_overwrites_previous_timestamp(self):
|
|
"""update() on already-seen slot refreshes the timestamp."""
|
|
store = WatchdogStore()
|
|
store.update("slot-A")
|
|
first = store._store["slot-A"]
|
|
store.update("slot-A")
|
|
second = store._store["slot-A"]
|
|
assert second >= first
|
|
|
|
|
|
class TestIsExpired:
|
|
def test_never_seen_slot_is_expired(self):
|
|
"""AC-2: slot never updated → is_expired returns True."""
|
|
store = WatchdogStore()
|
|
assert store.is_expired("slot-X", timeout_sec=120) is True
|
|
|
|
def test_recently_updated_slot_not_expired(self):
|
|
"""AC-3: slot updated now → not expired with generous timeout."""
|
|
store = WatchdogStore()
|
|
store.update("slot-A")
|
|
assert store.is_expired("slot-A", timeout_sec=120) is False
|
|
|
|
def test_old_update_is_expired(self):
|
|
"""AC-2: slot last updated 200s ago is expired with 120s timeout."""
|
|
store = WatchdogStore()
|
|
old_time = _now() - timedelta(seconds=200)
|
|
store._store["slot-A"] = old_time
|
|
assert store.is_expired("slot-A", timeout_sec=120) is True
|
|
|
|
def test_just_within_timeout_not_expired(self):
|
|
"""Slot updated 100s ago is NOT expired with 120s timeout."""
|
|
store = WatchdogStore()
|
|
recent = _now() - timedelta(seconds=100)
|
|
store._store["slot-A"] = recent
|
|
assert store.is_expired("slot-A", timeout_sec=120) is False
|
|
|
|
|
|
class TestGracePeriod:
|
|
def test_in_grace_period_immediately_after_init(self):
|
|
"""AC-4: just-created store is in grace period for 60s."""
|
|
store = WatchdogStore()
|
|
assert store.in_grace_period(grace_sec=60) is True
|
|
|
|
def test_not_in_grace_period_after_expiry(self):
|
|
"""AC-4: store started >60s ago is no longer in grace period."""
|
|
store = WatchdogStore()
|
|
store._started_at = _now() - timedelta(seconds=61)
|
|
assert store.in_grace_period(grace_sec=60) is False
|
|
|
|
def test_grace_period_exactly_at_boundary(self):
|
|
"""Elapsed == grace_sec is NOT in grace (strictly less than)."""
|
|
store = WatchdogStore()
|
|
store._started_at = _now() - timedelta(seconds=60)
|
|
assert store.in_grace_period(grace_sec=60) is False
|
|
|
|
|
|
class TestGracePeriodDefer:
|
|
def test_grace_period_defers_expiry_check(self):
|
|
"""AC-4: callers skip is_expired during grace period (combined scenario).
|
|
|
|
The REQ states that during grace period no slot should be treated as
|
|
expired. This test verifies the pattern: in_grace_period() → True means
|
|
callers must not act on is_expired() results.
|
|
"""
|
|
store = WatchdogStore()
|
|
# Store is brand-new → in grace period
|
|
assert store.in_grace_period(grace_sec=60) is True
|
|
# Even though slot is never seen (would be expired), grace is active
|
|
assert store.is_expired("slot-never-seen", timeout_sec=120) is True
|
|
# Caller should defer: grace overrides expiry judgement.
|
|
effective_expired = store.is_expired("slot-never-seen", timeout_sec=120) and not store.in_grace_period(grace_sec=60)
|
|
assert effective_expired is False
|
|
|
|
|
|
class TestStoreReset:
|
|
def test_new_store_is_empty(self):
|
|
"""AC-5: new WatchdogStore has no entries (simulates daemon restart)."""
|
|
store = WatchdogStore()
|
|
assert len(store._store) == 0
|