feat(safety-sell): dcy-007 core 자율 안전 매도 — BAT-010 기반 + BAT-011 트리거
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>
This commit is contained in:
@@ -0,0 +1,296 @@
|
||||
"""Safety-sell trigger service unit tests (REQ-037/038/039 / BAT-011)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from trex_core.application.safety_sell import SafetySellService
|
||||
from trex_core.application.watchdog import WatchdogStore
|
||||
from trex_core.domain.slot import SlotService, SlotState
|
||||
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
|
||||
from trex_core.runtime.config import SafetySellConfig
|
||||
|
||||
_DAY = date(2026, 6, 7)
|
||||
# 2026-06-07 06:00Z == 15:00 KST
|
||||
_NOW = datetime(2026, 6, 7, 6, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
class FakeBroker:
|
||||
def __init__(self):
|
||||
self.prices: dict[str, Decimal] = {}
|
||||
self.calls: list[str] = []
|
||||
|
||||
def name(self):
|
||||
return "fake"
|
||||
|
||||
def submit_order(self, req): ...
|
||||
def poll_fill(self, *a, **k): ...
|
||||
def cancel_order(self, *a, **k): ...
|
||||
def get_balance(self):
|
||||
return []
|
||||
|
||||
def get_current_price(self, symbol):
|
||||
self.calls.append(symbol)
|
||||
return self.prices.get(symbol)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def repo():
|
||||
r = InMemorySlotRepository()
|
||||
return r
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service(repo):
|
||||
svc = SlotService(repo)
|
||||
svc.preallocate_daily_slots(_DAY, 5)
|
||||
return svc
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def broker():
|
||||
return FakeBroker()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def watchdog():
|
||||
return WatchdogStore()
|
||||
|
||||
|
||||
def _cfg(**over):
|
||||
base = dict(
|
||||
watchdog_timeout_sec=120,
|
||||
watchdog_grace_period_sec=0, # disable grace unless a test opts in
|
||||
forced_close_check_interval_sec=60,
|
||||
stop_loss_poll_interval_sec=30,
|
||||
market_close_liquidation_time=None,
|
||||
)
|
||||
base.update(over)
|
||||
return SafetySellConfig(**base)
|
||||
|
||||
|
||||
def _make_holding(service, repo, *, symbol="005930", forced_close_at=None,
|
||||
stop_loss_price=None, take_profit_price=None):
|
||||
idle = repo.find_idle_slot(_DAY)
|
||||
pending = service.transit_slot(
|
||||
idle.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
|
||||
reason="buy", actor="test", symbol=symbol,
|
||||
forced_close_at=forced_close_at,
|
||||
stop_loss_price=stop_loss_price,
|
||||
take_profit_price=take_profit_price,
|
||||
)
|
||||
return service.transit_slot(
|
||||
pending.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
|
||||
reason="filled", actor="test", avg_price=50000.0, qty=1.0,
|
||||
)
|
||||
|
||||
|
||||
def _svc(repo, service, broker, watchdog, cfg):
|
||||
return SafetySellService(
|
||||
slot_repo=repo, slot_service=service, broker=broker,
|
||||
watchdog=watchdog, config=cfg,
|
||||
)
|
||||
|
||||
|
||||
# ----------------------------------------------------------- REQ-037 forced_close
|
||||
class TestForcedClose:
|
||||
def test_expired_past_due_sells(self, repo, service, broker, watchdog):
|
||||
"""AC-1: holding + watchdog-expired + forced_close_at past → sell."""
|
||||
slot = _make_holding(service, repo, forced_close_at=_NOW - timedelta(seconds=1))
|
||||
s = _svc(repo, service, broker, watchdog, _cfg())
|
||||
fired = s.check_forced_close(_NOW)
|
||||
assert slot.slot_id in fired
|
||||
assert repo.fetch_slot(slot.slot_id).status is SlotState.PENDING_SELL
|
||||
|
||||
def test_future_not_due_no_sell(self, repo, service, broker, watchdog):
|
||||
"""AC-2: forced_close_at in the future → no sell."""
|
||||
slot = _make_holding(service, repo, forced_close_at=_NOW + timedelta(hours=1))
|
||||
s = _svc(repo, service, broker, watchdog, _cfg())
|
||||
assert s.check_forced_close(_NOW) == []
|
||||
assert repo.fetch_slot(slot.slot_id).status is SlotState.HOLDING
|
||||
|
||||
def test_watchdog_active_protected(self, repo, service, broker, watchdog):
|
||||
"""AC-3: watchdog active (strategy present) → no sell even if past due."""
|
||||
slot = _make_holding(service, repo, forced_close_at=_NOW - timedelta(seconds=1))
|
||||
watchdog.update(str(slot.slot_id)) # strategy module seen → active
|
||||
s = _svc(repo, service, broker, watchdog, _cfg())
|
||||
assert s.check_forced_close(_NOW) == []
|
||||
assert repo.fetch_slot(slot.slot_id).status is SlotState.HOLDING
|
||||
|
||||
def test_no_forced_close_excluded(self, repo, service, broker, watchdog):
|
||||
"""AC-4: forced_close_at None → excluded."""
|
||||
slot = _make_holding(service, repo, forced_close_at=None)
|
||||
s = _svc(repo, service, broker, watchdog, _cfg())
|
||||
assert s.check_forced_close(_NOW) == []
|
||||
assert repo.fetch_slot(slot.slot_id).status is SlotState.HOLDING
|
||||
|
||||
def test_idempotent_on_pending_sell(self, repo, service, broker, watchdog):
|
||||
"""AC-5: a second pass does not add a second transition."""
|
||||
slot = _make_holding(service, repo, forced_close_at=_NOW - timedelta(seconds=1))
|
||||
s = _svc(repo, service, broker, watchdog, _cfg())
|
||||
s.check_forced_close(_NOW)
|
||||
s.check_forced_close(_NOW) # again
|
||||
log = repo.transition_log_for(slot.slot_id)
|
||||
assert len([e for e in log if e.to_state is SlotState.PENDING_SELL]) == 1
|
||||
|
||||
def test_grace_period_defers(self, repo, service, broker, watchdog):
|
||||
"""grace period → no forced_close trigger (REQ-034 protection)."""
|
||||
slot = _make_holding(service, repo, forced_close_at=_NOW - timedelta(seconds=1))
|
||||
s = _svc(repo, service, broker, watchdog, _cfg(watchdog_grace_period_sec=3600))
|
||||
assert s.check_forced_close(_NOW) == []
|
||||
assert repo.fetch_slot(slot.slot_id).status is SlotState.HOLDING
|
||||
|
||||
|
||||
# ------------------------------------------------ REQ-038 stop-loss / take-profit
|
||||
class TestPriceTriggers:
|
||||
def test_stop_loss_triggers(self, repo, service, broker, watchdog):
|
||||
"""AC-1: price <= stop_loss → sell."""
|
||||
slot = _make_holding(service, repo, stop_loss_price=Decimal("50000"))
|
||||
broker.prices["005930"] = Decimal("49000")
|
||||
s = _svc(repo, service, broker, watchdog, _cfg())
|
||||
fired = s.check_prices(_NOW)
|
||||
assert slot.slot_id in fired
|
||||
assert repo.fetch_slot(slot.slot_id).status is SlotState.PENDING_SELL
|
||||
|
||||
def test_take_profit_triggers(self, repo, service, broker, watchdog):
|
||||
"""AC-2: price >= take_profit → sell."""
|
||||
slot = _make_holding(service, repo, take_profit_price=Decimal("80000"))
|
||||
broker.prices["005930"] = Decimal("81000")
|
||||
s = _svc(repo, service, broker, watchdog, _cfg())
|
||||
assert slot.slot_id in s.check_prices(_NOW)
|
||||
assert repo.fetch_slot(slot.slot_id).status is SlotState.PENDING_SELL
|
||||
|
||||
def test_neither_condition_no_sell(self, repo, service, broker, watchdog):
|
||||
"""AC-3: price between thresholds → no sell."""
|
||||
slot = _make_holding(service, repo, stop_loss_price=Decimal("50000"),
|
||||
take_profit_price=Decimal("80000"))
|
||||
broker.prices["005930"] = Decimal("65000")
|
||||
s = _svc(repo, service, broker, watchdog, _cfg())
|
||||
assert s.check_prices(_NOW) == []
|
||||
assert repo.fetch_slot(slot.slot_id).status is SlotState.HOLDING
|
||||
|
||||
def test_watchdog_active_no_kis_call(self, repo, service, broker, watchdog):
|
||||
"""AC-4: watchdog active → no KIS price call at all."""
|
||||
slot = _make_holding(service, repo, stop_loss_price=Decimal("50000"))
|
||||
watchdog.update(str(slot.slot_id))
|
||||
s = _svc(repo, service, broker, watchdog, _cfg())
|
||||
s.check_prices(_NOW)
|
||||
assert broker.calls == [] # never polled
|
||||
assert repo.fetch_slot(slot.slot_id).status is SlotState.HOLDING
|
||||
|
||||
def test_no_thresholds_excluded(self, repo, service, broker, watchdog):
|
||||
"""AC-5: both thresholds None → excluded (no poll)."""
|
||||
_make_holding(service, repo)
|
||||
s = _svc(repo, service, broker, watchdog, _cfg())
|
||||
assert s.check_prices(_NOW) == []
|
||||
assert broker.calls == []
|
||||
|
||||
def test_idempotent_on_pending_sell(self, repo, service, broker, watchdog):
|
||||
"""AC-6: second pass adds no second transition."""
|
||||
slot = _make_holding(service, repo, stop_loss_price=Decimal("50000"))
|
||||
broker.prices["005930"] = Decimal("49000")
|
||||
s = _svc(repo, service, broker, watchdog, _cfg())
|
||||
s.check_prices(_NOW)
|
||||
s.check_prices(_NOW)
|
||||
log = repo.transition_log_for(slot.slot_id)
|
||||
assert len([e for e in log if e.to_state is SlotState.PENDING_SELL]) == 1
|
||||
|
||||
def test_quota_estimate(self, repo, service, broker, watchdog):
|
||||
"""AC-8: hourly call upper bound = N * (3600/interval); interval=30 → N*120."""
|
||||
s = _svc(repo, service, broker, watchdog, _cfg(stop_loss_poll_interval_sec=30))
|
||||
assert s.estimated_hourly_kis_calls(3) == 360 # 3 * 120
|
||||
|
||||
def test_only_eligible_slots_polled(self, repo, service, broker, watchdog):
|
||||
"""AC-8: only watchdog-expired threshold slots are polled."""
|
||||
active = _make_holding(service, repo, symbol="A", stop_loss_price=Decimal("1"))
|
||||
_make_holding(service, repo, symbol="B", stop_loss_price=Decimal("1")) # expired
|
||||
_make_holding(service, repo, symbol="C") # no thresholds
|
||||
watchdog.update(str(active.slot_id)) # A active
|
||||
broker.prices.update({"A": Decimal("100"), "B": Decimal("100"), "C": Decimal("100")})
|
||||
s = _svc(repo, service, broker, watchdog, _cfg())
|
||||
s.check_prices(_NOW)
|
||||
assert broker.calls == ["B"] # only the expired threshold slot
|
||||
|
||||
def test_price_unavailable_skips(self, repo, service, broker, watchdog):
|
||||
"""price None (KIS error) → no false trigger."""
|
||||
slot = _make_holding(service, repo, stop_loss_price=Decimal("50000"))
|
||||
# broker.prices has no entry → returns None
|
||||
s = _svc(repo, service, broker, watchdog, _cfg())
|
||||
assert s.check_prices(_NOW) == []
|
||||
assert repo.fetch_slot(slot.slot_id).status is SlotState.HOLDING
|
||||
|
||||
|
||||
# ------------------------------------------------ REQ-039 market-close liquidation
|
||||
class TestMarketClose:
|
||||
def test_time_reached_liquidates_all(self, repo, service, broker, watchdog):
|
||||
"""AC-1: at the configured KST time, all holding slots are sold."""
|
||||
s1 = _make_holding(service, repo, symbol="A")
|
||||
s2 = _make_holding(service, repo, symbol="B")
|
||||
now = datetime(2026, 6, 7, 6, 10, tzinfo=timezone.utc) # 15:10 KST
|
||||
s = _svc(repo, service, broker, watchdog, _cfg(market_close_liquidation_time="15:10"))
|
||||
fired = s.check_market_close(now)
|
||||
assert set(fired) == {s1.slot_id, s2.slot_id}
|
||||
assert repo.fetch_slot(s1.slot_id).status is SlotState.PENDING_SELL
|
||||
assert repo.fetch_slot(s2.slot_id).status is SlotState.PENDING_SELL
|
||||
|
||||
def test_watchdog_active_included(self, repo, service, broker, watchdog):
|
||||
"""AC-2: watchdog-active slots are liquidated too (watchdog-independent)."""
|
||||
slot = _make_holding(service, repo)
|
||||
watchdog.update(str(slot.slot_id)) # active — but market-close ignores this
|
||||
now = datetime(2026, 6, 7, 6, 10, tzinfo=timezone.utc)
|
||||
s = _svc(repo, service, broker, watchdog, _cfg(market_close_liquidation_time="15:10"))
|
||||
assert slot.slot_id in s.check_market_close(now)
|
||||
|
||||
def test_not_configured_no_sell(self, repo, service, broker, watchdog):
|
||||
"""AC-3: unset → never liquidates."""
|
||||
slot = _make_holding(service, repo)
|
||||
now = datetime(2026, 6, 7, 8, 0, tzinfo=timezone.utc)
|
||||
s = _svc(repo, service, broker, watchdog, _cfg(market_close_liquidation_time=None))
|
||||
assert s.check_market_close(now) == []
|
||||
assert repo.fetch_slot(slot.slot_id).status is SlotState.HOLDING
|
||||
|
||||
def test_before_time_no_sell(self, repo, service, broker, watchdog):
|
||||
slot = _make_holding(service, repo)
|
||||
now = datetime(2026, 6, 7, 6, 0, tzinfo=timezone.utc) # 15:00 < 15:10
|
||||
s = _svc(repo, service, broker, watchdog, _cfg(market_close_liquidation_time="15:10"))
|
||||
assert s.check_market_close(now) == []
|
||||
assert repo.fetch_slot(slot.slot_id).status is SlotState.HOLDING
|
||||
|
||||
def test_once_per_day_guard(self, repo, service, broker, watchdog):
|
||||
"""AC-4: second evaluation on the same trading day does not re-fire."""
|
||||
_make_holding(service, repo, symbol="A")
|
||||
now = datetime(2026, 6, 7, 6, 10, tzinfo=timezone.utc)
|
||||
s = _svc(repo, service, broker, watchdog, _cfg(market_close_liquidation_time="15:10"))
|
||||
s.check_market_close(now)
|
||||
# new holding appears after liquidation — must NOT be re-liquidated same day
|
||||
s2 = _make_holding(service, repo, symbol="B")
|
||||
assert s.check_market_close(now + timedelta(minutes=5)) == []
|
||||
assert repo.fetch_slot(s2.slot_id).status is SlotState.HOLDING
|
||||
|
||||
def test_grace_period_does_not_block(self, repo, service, broker, watchdog):
|
||||
"""AC-6: market-close fires even during the grace period."""
|
||||
slot = _make_holding(service, repo)
|
||||
now = datetime(2026, 6, 7, 6, 10, tzinfo=timezone.utc)
|
||||
s = _svc(repo, service, broker, watchdog,
|
||||
_cfg(market_close_liquidation_time="15:10", watchdog_grace_period_sec=3600))
|
||||
assert slot.slot_id in s.check_market_close(now)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- run_once cadence
|
||||
class TestRunOnce:
|
||||
def test_run_once_fires_all_checks_first_tick(self, repo, service, broker, watchdog):
|
||||
"""First run_once runs forced_close + prices (last-run None)."""
|
||||
fc = _make_holding(service, repo, symbol="A", forced_close_at=_NOW - timedelta(seconds=1))
|
||||
sl = _make_holding(service, repo, symbol="B", stop_loss_price=Decimal("50000"))
|
||||
broker.prices["B"] = Decimal("49000")
|
||||
s = _svc(repo, service, broker, watchdog, _cfg())
|
||||
s.run_once(_NOW)
|
||||
assert repo.fetch_slot(fc.slot_id).status is SlotState.PENDING_SELL
|
||||
assert repo.fetch_slot(sl.slot_id).status is SlotState.PENDING_SELL
|
||||
|
||||
def test_base_tick_is_finest_interval(self, repo, service, broker, watchdog):
|
||||
s = _svc(repo, service, broker, watchdog,
|
||||
_cfg(forced_close_check_interval_sec=60, stop_loss_poll_interval_sec=30))
|
||||
assert s.base_tick_sec == 30.0
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Snapshot RPC handler watchdog integration tests (REQ-034 AC-1 / BAT-010)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
|
||||
from trex_core.application.messaging import RpcRequest
|
||||
from trex_core.application.messaging_handlers import make_snapshot_rpc_handler
|
||||
from trex_core.application.watchdog import WatchdogStore
|
||||
from trex_core.domain.slot import SlotService, SlotState
|
||||
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def trading_day():
|
||||
return date(2026, 6, 7)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def repo():
|
||||
return InMemorySlotRepository()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service(repo):
|
||||
return SlotService(repo)
|
||||
|
||||
|
||||
def _make_req(trading_day):
|
||||
return RpcRequest(
|
||||
payload={"trading_date": trading_day.isoformat(), "view": "all"},
|
||||
correlation_id="test-corr",
|
||||
)
|
||||
|
||||
|
||||
def _buy_slot(service, repo, trading_day, symbol="005930"):
|
||||
"""Preallocate + buy one slot into HOLDING state."""
|
||||
service.preallocate_daily_slots(trading_day, 1)
|
||||
slot = repo.find_idle_slot(trading_day)
|
||||
pending = service.transit_slot(
|
||||
slot.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
|
||||
reason="buy", actor="test", symbol=symbol,
|
||||
)
|
||||
holding = service.transit_slot(
|
||||
pending.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
|
||||
reason="filled", actor="test", avg_price=100.0, qty=10.0,
|
||||
)
|
||||
return holding
|
||||
|
||||
|
||||
class TestSnapshotRpcWatchdog:
|
||||
def test_holding_slot_watchdog_updated_on_snapshot_rpc(self, repo, service, trading_day):
|
||||
"""AC-1: snapshot RPC updates watchdog for each holding slot in response."""
|
||||
holding = _buy_slot(service, repo, trading_day)
|
||||
watchdog = WatchdogStore()
|
||||
handler = make_snapshot_rpc_handler(repo, service, watchdog_store=watchdog)
|
||||
handler(_make_req(trading_day))
|
||||
assert str(holding.slot_id) in watchdog._store
|
||||
|
||||
def test_idle_slot_watchdog_not_updated(self, repo, service, trading_day):
|
||||
"""Only HOLDING slots update the watchdog — idle slots are ignored."""
|
||||
service.preallocate_daily_slots(trading_day, 1)
|
||||
idle = repo.find_idle_slot(trading_day)
|
||||
watchdog = WatchdogStore()
|
||||
handler = make_snapshot_rpc_handler(repo, service, watchdog_store=watchdog)
|
||||
handler(_make_req(trading_day))
|
||||
assert str(idle.slot_id) not in watchdog._store
|
||||
|
||||
def test_no_watchdog_store_does_not_raise(self, repo, service, trading_day):
|
||||
"""Without watchdog_store= the handler works as before (backward compat)."""
|
||||
_buy_slot(service, repo, trading_day)
|
||||
handler = make_snapshot_rpc_handler(repo, service)
|
||||
resp = handler(_make_req(trading_day))
|
||||
assert resp is not None
|
||||
|
||||
def test_multiple_holding_slots_all_updated(self, repo, service, trading_day):
|
||||
"""All holding slots in the response have their watchdog updated."""
|
||||
service.preallocate_daily_slots(trading_day, 3)
|
||||
slots = []
|
||||
for sym in ("A", "B", "C"):
|
||||
slot = repo.find_idle_slot(trading_day)
|
||||
p = service.transit_slot(
|
||||
slot.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
|
||||
reason="buy", actor="test", symbol=sym,
|
||||
)
|
||||
h = service.transit_slot(
|
||||
p.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
|
||||
reason="filled", actor="test", avg_price=100.0, qty=1.0,
|
||||
)
|
||||
slots.append(h)
|
||||
watchdog = WatchdogStore()
|
||||
handler = make_snapshot_rpc_handler(repo, service, watchdog_store=watchdog)
|
||||
handler(_make_req(trading_day))
|
||||
for s in slots:
|
||||
assert str(s.slot_id) in watchdog._store, f"{s.slot_id} not in watchdog"
|
||||
@@ -0,0 +1,135 @@
|
||||
"""update_thresholds intent RPC handler tests (REQ-040 / BAT-011)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from trex_core.application.intents import IntentService
|
||||
from trex_core.application.messaging import RpcRequest
|
||||
from trex_core.application.messaging_handlers import make_intent_rpc_handler
|
||||
from trex_core.application.watchdog import WatchdogStore
|
||||
from trex_core.domain.slot import SlotService, SlotState
|
||||
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
|
||||
|
||||
_DAY = date(2026, 6, 7)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def repo():
|
||||
return InMemorySlotRepository()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service(repo):
|
||||
svc = SlotService(repo)
|
||||
svc.preallocate_daily_slots(_DAY, 3)
|
||||
return svc
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def watchdog():
|
||||
return WatchdogStore()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def handler(repo, service, watchdog):
|
||||
intent_service = IntentService(service, repo, repo._intent_store if hasattr(repo, "_intent_store") else _StubIntentStore())
|
||||
return make_intent_rpc_handler(intent_service, slot_service=service, watchdog_store=watchdog)
|
||||
|
||||
|
||||
class _StubIntentStore:
|
||||
def try_insert(self, *a, **k):
|
||||
return True
|
||||
|
||||
|
||||
def _holding(service, repo, *, symbol="005930", stop_loss_price=None,
|
||||
take_profit_price=None, forced_close_at=None):
|
||||
idle = repo.find_idle_slot(_DAY)
|
||||
pending = service.transit_slot(
|
||||
idle.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
|
||||
reason="buy", actor="test", symbol=symbol,
|
||||
stop_loss_price=stop_loss_price, take_profit_price=take_profit_price,
|
||||
forced_close_at=forced_close_at,
|
||||
)
|
||||
return service.transit_slot(
|
||||
pending.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
|
||||
reason="filled", actor="test", avg_price=50000.0, qty=1.0,
|
||||
)
|
||||
|
||||
|
||||
def _req(**body):
|
||||
return RpcRequest(payload={"action": "update_thresholds", **body}, correlation_id="c1")
|
||||
|
||||
|
||||
def test_updates_stop_loss(handler, service, repo):
|
||||
"""AC-1: stop_loss_price is updated in the slot."""
|
||||
slot = _holding(service, repo)
|
||||
resp = handler(_req(slot_id=str(slot.slot_id), stop_loss_price=50000))
|
||||
assert resp.payload["status"] == "updated"
|
||||
assert repo.fetch_slot(slot.slot_id).stop_loss_price == Decimal("50000")
|
||||
|
||||
|
||||
def test_updates_take_profit_and_forced_close(handler, service, repo):
|
||||
"""AC-2: take_profit_price and forced_close_at updated together."""
|
||||
slot = _holding(service, repo)
|
||||
resp = handler(_req(
|
||||
slot_id=str(slot.slot_id),
|
||||
take_profit_price=80000,
|
||||
forced_close_at="2026-06-07T15:10:00+09:00",
|
||||
))
|
||||
assert resp.payload["status"] == "updated"
|
||||
updated = repo.fetch_slot(slot.slot_id)
|
||||
assert updated.take_profit_price == Decimal("80000")
|
||||
assert updated.forced_close_at == datetime.fromisoformat("2026-06-07T15:10:00+09:00")
|
||||
|
||||
|
||||
def test_resets_watchdog_last_seen(handler, service, repo, watchdog):
|
||||
"""AC-3: update_thresholds resets the watchdog last_seen_at."""
|
||||
slot = _holding(service, repo)
|
||||
assert str(slot.slot_id) not in watchdog._store
|
||||
handler(_req(slot_id=str(slot.slot_id), stop_loss_price=50000))
|
||||
assert str(slot.slot_id) in watchdog._store
|
||||
|
||||
|
||||
def test_partial_update_keeps_other_fields(handler, service, repo):
|
||||
"""AC-4: omitted fields keep their current value."""
|
||||
slot = _holding(service, repo, take_profit_price=Decimal("90000"))
|
||||
handler(_req(slot_id=str(slot.slot_id), stop_loss_price=50000))
|
||||
updated = repo.fetch_slot(slot.slot_id)
|
||||
assert updated.stop_loss_price == Decimal("50000")
|
||||
assert updated.take_profit_price == Decimal("90000") # untouched
|
||||
|
||||
|
||||
def test_explicit_null_clears(handler, service, repo):
|
||||
"""AC-5: explicit null clears the threshold."""
|
||||
slot = _holding(service, repo, stop_loss_price=Decimal("50000"))
|
||||
handler(_req(slot_id=str(slot.slot_id), stop_loss_price=None))
|
||||
assert repo.fetch_slot(slot.slot_id).stop_loss_price is None
|
||||
|
||||
|
||||
def test_non_holding_ignored(handler, service, repo, watchdog):
|
||||
"""AC-6: non-holding slot → ignored + not_holding reason, no watchdog reset."""
|
||||
service.preallocate_daily_slots(_DAY, 3)
|
||||
idle = repo.find_idle_slot(_DAY)
|
||||
resp = handler(_req(slot_id=str(idle.slot_id), stop_loss_price=50000))
|
||||
assert resp.payload["status"] == "ignored"
|
||||
assert resp.payload["reason"] == "not_holding"
|
||||
assert str(idle.slot_id) not in watchdog._store
|
||||
|
||||
|
||||
def test_reply_updated_status(handler, service, repo):
|
||||
"""AC-7: success returns {status: updated, slot_id}."""
|
||||
slot = _holding(service, repo)
|
||||
resp = handler(_req(slot_id=str(slot.slot_id), forced_close_at="2026-06-07T15:10:00+09:00"))
|
||||
assert resp.payload == {"status": "updated", "slot_id": str(slot.slot_id)}
|
||||
|
||||
|
||||
def test_unknown_slot_ignored(handler):
|
||||
"""Unknown slot → ignored + slot_not_found reason."""
|
||||
from uuid import uuid4
|
||||
sid = uuid4()
|
||||
resp = handler(_req(slot_id=str(sid), stop_loss_price=50000))
|
||||
assert resp.payload["status"] == "ignored"
|
||||
assert resp.payload["reason"] == "slot_not_found"
|
||||
@@ -0,0 +1,103 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user