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,100 @@
|
||||
"""KIS current-price adapter unit tests (REQ-038 / BAT-011).
|
||||
|
||||
Network is monkeypatched — these run always (no KIS credentials needed).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
import httpx
|
||||
|
||||
from trex_core.adapters.broker.kis import KISBroker
|
||||
from trex_core.adapters.broker.paper import PaperBroker
|
||||
|
||||
_MOCK_URL = "https://openapivts.koreainvestment.com:29443"
|
||||
_LIVE_URL = "https://openapi.koreainvestment.com:9443"
|
||||
|
||||
|
||||
class _FakeResp:
|
||||
def __init__(self, payload):
|
||||
self._payload = payload
|
||||
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
|
||||
def _mock_broker():
|
||||
return KISBroker("12345678-01", app_key="k", app_secret="s", base_url=_MOCK_URL)
|
||||
|
||||
|
||||
def _live_broker():
|
||||
return KISBroker("12345678-01", app_key="k", app_secret="s",
|
||||
base_url=_LIVE_URL, allow_live=True)
|
||||
|
||||
|
||||
def test_tr_price_live_vs_mock():
|
||||
"""AC-7: live uses TTTC8397R, mock uses VTTC8397R."""
|
||||
assert _live_broker()._tr_price() == "TTTC8397R"
|
||||
assert _mock_broker()._tr_price() == "VTTC8397R"
|
||||
|
||||
|
||||
def test_get_current_price_parses_stck_prpr(monkeypatch):
|
||||
"""get_current_price parses output.stck_prpr into a Decimal."""
|
||||
captured = {}
|
||||
|
||||
def fake_get(url, headers=None, params=None, timeout=None):
|
||||
captured["url"] = url
|
||||
captured["tr_id"] = headers["tr_id"]
|
||||
captured["params"] = params
|
||||
return _FakeResp({"rt_cd": "0", "output": {"stck_prpr": "49000"}})
|
||||
|
||||
b = _mock_broker()
|
||||
monkeypatch.setattr(b, "_ensure_token", lambda: "tok")
|
||||
monkeypatch.setattr(httpx, "get", fake_get)
|
||||
price = b.get_current_price("005930")
|
||||
assert price == Decimal("49000")
|
||||
assert captured["tr_id"] == "VTTC8397R"
|
||||
assert captured["params"]["FID_INPUT_ISCD"] == "005930"
|
||||
|
||||
|
||||
def test_get_current_price_network_error_returns_none(monkeypatch):
|
||||
"""Network error → None (worker skips the slot, no false trigger)."""
|
||||
def boom(*a, **kw):
|
||||
raise httpx.ConnectError("down")
|
||||
|
||||
b = _mock_broker()
|
||||
monkeypatch.setattr(b, "_ensure_token", lambda: "tok")
|
||||
monkeypatch.setattr(httpx, "get", boom)
|
||||
assert b.get_current_price("005930") is None
|
||||
|
||||
|
||||
def test_get_current_price_error_rt_cd_returns_none(monkeypatch):
|
||||
"""Non-zero rt_cd → None."""
|
||||
def fake_get(*a, **kw):
|
||||
return _FakeResp({"rt_cd": "1", "msg1": "error"})
|
||||
|
||||
b = _mock_broker()
|
||||
monkeypatch.setattr(b, "_ensure_token", lambda: "tok")
|
||||
monkeypatch.setattr(httpx, "get", fake_get)
|
||||
assert b.get_current_price("005930") is None
|
||||
|
||||
|
||||
def test_get_current_price_missing_field_returns_none(monkeypatch):
|
||||
"""Missing/blank stck_prpr → None."""
|
||||
def fake_get(*a, **kw):
|
||||
return _FakeResp({"rt_cd": "0", "output": {"stck_prpr": ""}})
|
||||
|
||||
b = _mock_broker()
|
||||
monkeypatch.setattr(b, "_ensure_token", lambda: "tok")
|
||||
monkeypatch.setattr(httpx, "get", fake_get)
|
||||
assert b.get_current_price("005930") is None
|
||||
|
||||
|
||||
class TestPaperCurrentPrice:
|
||||
def test_unset_returns_none(self):
|
||||
assert PaperBroker().get_current_price("005930") is None
|
||||
|
||||
def test_set_and_get(self):
|
||||
b = PaperBroker()
|
||||
b.set_current_price("005930", Decimal("51000"))
|
||||
assert b.get_current_price("005930") == Decimal("51000")
|
||||
@@ -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
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Price threshold field tests (REQ-035 / BAT-010)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
import dataclasses
|
||||
|
||||
from trex_core.domain.slot import EXPECTED_SLOT_FIELDS, Slot, 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)
|
||||
|
||||
|
||||
class TestSlotModelPriceFields:
|
||||
def test_price_fields_default_to_none(self):
|
||||
"""AC-1: stop_loss_price and take_profit_price default to None."""
|
||||
from uuid import uuid4
|
||||
slot = Slot(slot_id=uuid4(), trading_date=date(2026, 6, 7), status=SlotState.IDLE)
|
||||
assert slot.stop_loss_price is None
|
||||
assert slot.take_profit_price is None
|
||||
|
||||
def test_price_fields_in_expected_contract(self):
|
||||
"""REQ-035 fields are declared in EXPECTED_SLOT_FIELDS."""
|
||||
assert "stop_loss_price" in EXPECTED_SLOT_FIELDS
|
||||
assert "take_profit_price" in EXPECTED_SLOT_FIELDS
|
||||
|
||||
def test_price_fields_are_in_dataclass(self):
|
||||
"""Slot dataclass actually has both fields."""
|
||||
field_names = {f.name for f in dataclasses.fields(Slot)}
|
||||
assert "stop_loss_price" in field_names
|
||||
assert "take_profit_price" in field_names
|
||||
|
||||
|
||||
class TestTransitSlotPreservesPriceFields:
|
||||
def test_price_fields_set_on_pending_buy(self, service, repo, trading_day):
|
||||
"""AC-2/AC-4: price threshold fields are stored in the slot on IDLE→PENDING_BUY."""
|
||||
service.preallocate_daily_slots(trading_day, 1)
|
||||
idle = repo.find_idle_slot(trading_day)
|
||||
pending = service.transit_slot(
|
||||
idle.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
|
||||
reason="buy", actor="test", symbol="005930",
|
||||
stop_loss_price=Decimal("48000"),
|
||||
take_profit_price=Decimal("62000"),
|
||||
)
|
||||
assert pending.stop_loss_price == Decimal("48000")
|
||||
assert pending.take_profit_price == Decimal("62000")
|
||||
|
||||
def test_price_fields_preserved_through_holding(self, service, repo, trading_day):
|
||||
"""Price fields survive PENDING_BUY→HOLDING fill transition."""
|
||||
service.preallocate_daily_slots(trading_day, 1)
|
||||
idle = repo.find_idle_slot(trading_day)
|
||||
pending = service.transit_slot(
|
||||
idle.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
|
||||
reason="buy", actor="test", symbol="005930",
|
||||
stop_loss_price=Decimal("48000"),
|
||||
take_profit_price=Decimal("62000"),
|
||||
)
|
||||
holding = service.transit_slot(
|
||||
pending.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
|
||||
reason="filled", actor="test", avg_price=50000.0, qty=1.0,
|
||||
)
|
||||
assert holding.stop_loss_price == Decimal("48000")
|
||||
assert holding.take_profit_price == Decimal("62000")
|
||||
|
||||
def test_idle_reset_clears_price_fields(self, service, repo, trading_day):
|
||||
"""PENDING_BUY→IDLE rollback clears stop_loss_price and take_profit_price."""
|
||||
service.preallocate_daily_slots(trading_day, 1)
|
||||
idle = repo.find_idle_slot(trading_day)
|
||||
pending = service.transit_slot(
|
||||
idle.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
|
||||
reason="buy", actor="test", symbol="005930",
|
||||
stop_loss_price=Decimal("48000"),
|
||||
take_profit_price=Decimal("62000"),
|
||||
)
|
||||
rolled_back = service.transit_slot(
|
||||
pending.slot_id, SlotState.PENDING_BUY, SlotState.IDLE,
|
||||
reason="rejected", actor="test",
|
||||
)
|
||||
assert rolled_back.stop_loss_price is None
|
||||
assert rolled_back.take_profit_price is None
|
||||
|
||||
def test_no_price_fields_sets_none(self, service, repo, trading_day):
|
||||
"""AC-5: transit without price fields leaves them as None."""
|
||||
service.preallocate_daily_slots(trading_day, 1)
|
||||
idle = repo.find_idle_slot(trading_day)
|
||||
pending = service.transit_slot(
|
||||
idle.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
|
||||
reason="buy", actor="test", symbol="005930",
|
||||
)
|
||||
assert pending.stop_loss_price is None
|
||||
assert pending.take_profit_price is None
|
||||
@@ -0,0 +1,111 @@
|
||||
"""SlotService.sell() unit tests (REQ-036 / BAT-010)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
|
||||
from trex_core.domain.slot import (
|
||||
SlotNotFoundError,
|
||||
SlotService,
|
||||
SlotState,
|
||||
SlotTransitionError,
|
||||
)
|
||||
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 _holding_slot(service, repo, trading_day):
|
||||
"""Create one slot in HOLDING state."""
|
||||
service.preallocate_daily_slots(trading_day, 1)
|
||||
idle = repo.find_idle_slot(trading_day)
|
||||
pending = service.transit_slot(
|
||||
idle.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
|
||||
reason="buy", actor="test", symbol="005930",
|
||||
)
|
||||
return service.transit_slot(
|
||||
pending.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
|
||||
reason="filled", actor="test", avg_price=50000.0, qty=1.0,
|
||||
)
|
||||
|
||||
|
||||
class TestSell:
|
||||
def test_sell_holding_transitions_to_pending_sell(self, service, repo, trading_day):
|
||||
"""AC-1: sell() on a holding slot transitions it to pending_sell."""
|
||||
holding = _holding_slot(service, repo, trading_day)
|
||||
service.sell(holding.slot_id, actor="safety-sell", reason="stop_loss")
|
||||
updated = repo.fetch_slot(holding.slot_id)
|
||||
assert updated.status is SlotState.PENDING_SELL
|
||||
|
||||
def test_sell_records_transition_log(self, service, repo, trading_day):
|
||||
"""sell() appends a transition log entry."""
|
||||
holding = _holding_slot(service, repo, trading_day)
|
||||
service.sell(holding.slot_id, actor="safety-sell", reason="stop_loss")
|
||||
log = repo.transition_log_for(holding.slot_id)
|
||||
sell_entries = [e for e in log if e.to_state is SlotState.PENDING_SELL]
|
||||
assert len(sell_entries) == 1
|
||||
assert sell_entries[0].actor == "safety-sell"
|
||||
assert sell_entries[0].reason == "stop_loss"
|
||||
|
||||
def test_sell_idempotent_on_pending_sell(self, service, repo, trading_day):
|
||||
"""AC-2: sell() on already-pending_sell slot returns silently, no second transition."""
|
||||
holding = _holding_slot(service, repo, trading_day)
|
||||
service.sell(holding.slot_id, actor="safety-sell", reason="stop_loss")
|
||||
# Call again — must not raise and must not add another transition
|
||||
service.sell(holding.slot_id, actor="safety-sell", reason="stop_loss")
|
||||
log = repo.transition_log_for(holding.slot_id)
|
||||
sell_entries = [e for e in log if e.to_state is SlotState.PENDING_SELL]
|
||||
assert len(sell_entries) == 1 # exactly one, not two
|
||||
|
||||
def test_sell_idle_slot_raises_transition_error(self, service, repo, trading_day):
|
||||
"""AC-3: sell() on an idle slot raises SlotTransitionError."""
|
||||
service.preallocate_daily_slots(trading_day, 1)
|
||||
idle = repo.find_idle_slot(trading_day)
|
||||
with pytest.raises(SlotTransitionError):
|
||||
service.sell(idle.slot_id, actor="safety-sell", reason="stop_loss")
|
||||
# Slot state unchanged
|
||||
assert repo.fetch_slot(idle.slot_id).status is SlotState.IDLE
|
||||
|
||||
def test_sell_unknown_slot_raises_not_found(self, service, trading_day):
|
||||
"""sell() with unknown slot_id raises SlotNotFoundError."""
|
||||
from uuid import uuid4
|
||||
with pytest.raises(SlotNotFoundError):
|
||||
service.sell(uuid4(), actor="safety-sell", reason="stop_loss")
|
||||
|
||||
|
||||
class TestSellPreservesPriceFields:
|
||||
def test_sell_preserves_stop_loss_price(self, service, repo, trading_day):
|
||||
"""Price threshold fields survive the sell transition."""
|
||||
from decimal import Decimal
|
||||
service.preallocate_daily_slots(trading_day, 1)
|
||||
idle = repo.find_idle_slot(trading_day)
|
||||
pending = service.transit_slot(
|
||||
idle.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
|
||||
reason="buy", actor="test", symbol="005930",
|
||||
stop_loss_price=Decimal("45000"),
|
||||
take_profit_price=Decimal("60000"),
|
||||
)
|
||||
holding = service.transit_slot(
|
||||
pending.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
|
||||
reason="filled", actor="test", avg_price=50000.0, qty=1.0,
|
||||
)
|
||||
assert holding.stop_loss_price == Decimal("45000")
|
||||
assert holding.take_profit_price == Decimal("60000")
|
||||
service.sell(holding.slot_id, actor="safety-sell", reason="stop_loss")
|
||||
updated = repo.fetch_slot(holding.slot_id)
|
||||
assert updated.stop_loss_price == Decimal("45000")
|
||||
assert updated.take_profit_price == Decimal("60000")
|
||||
@@ -0,0 +1,69 @@
|
||||
"""SafetySellConfig env-var parsing tests (REQ-041 / BAT-010)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from trex_core.runtime.config import DaemonConfig, SafetySellConfig
|
||||
|
||||
_SAFETY_KEYS = (
|
||||
"TREX_WATCHDOG_TIMEOUT",
|
||||
"TREX_WATCHDOG_GRACE_PERIOD",
|
||||
"TREX_FORCED_CLOSE_CHECK_INTERVAL",
|
||||
"TREX_STOP_LOSS_POLL_INTERVAL",
|
||||
"TREX_MARKET_CLOSE_LIQUIDATION_TIME",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_env(monkeypatch):
|
||||
for k in _SAFETY_KEYS:
|
||||
monkeypatch.delenv(k, raising=False)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _base_env(monkeypatch):
|
||||
monkeypatch.setenv("TREX_POSTGRESQL_DSN", "dbname=x")
|
||||
|
||||
|
||||
def test_safety_sell_config_defaults(tmp_path, monkeypatch, _base_env):
|
||||
"""AC-1: unset env vars produce documented defaults."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
cfg = DaemonConfig.from_env()
|
||||
ss = cfg.safety_sell
|
||||
assert ss.watchdog_timeout_sec == 120
|
||||
assert ss.watchdog_grace_period_sec == 60
|
||||
assert ss.forced_close_check_interval_sec == 60
|
||||
assert ss.stop_loss_poll_interval_sec == 30
|
||||
assert ss.market_close_liquidation_time is None
|
||||
|
||||
|
||||
def test_safety_sell_config_env_tuning(tmp_path, monkeypatch, _base_env):
|
||||
"""AC-2~5: all five env vars are parsed correctly."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.setenv("TREX_WATCHDOG_TIMEOUT", "180")
|
||||
monkeypatch.setenv("TREX_WATCHDOG_GRACE_PERIOD", "90")
|
||||
monkeypatch.setenv("TREX_FORCED_CLOSE_CHECK_INTERVAL", "45")
|
||||
monkeypatch.setenv("TREX_STOP_LOSS_POLL_INTERVAL", "15")
|
||||
monkeypatch.setenv("TREX_MARKET_CLOSE_LIQUIDATION_TIME", "15:30")
|
||||
cfg = DaemonConfig.from_env()
|
||||
ss = cfg.safety_sell
|
||||
assert ss.watchdog_timeout_sec == 180
|
||||
assert ss.watchdog_grace_period_sec == 90
|
||||
assert ss.forced_close_check_interval_sec == 45
|
||||
assert ss.stop_loss_poll_interval_sec == 15
|
||||
assert ss.market_close_liquidation_time == "15:30"
|
||||
|
||||
|
||||
def test_market_close_liquidation_time_empty_string_becomes_none(tmp_path, monkeypatch, _base_env):
|
||||
"""Empty string for optional field collapses to None."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.setenv("TREX_MARKET_CLOSE_LIQUIDATION_TIME", "")
|
||||
cfg = DaemonConfig.from_env()
|
||||
assert cfg.safety_sell.market_close_liquidation_time is None
|
||||
|
||||
|
||||
def test_safety_sell_config_is_frozen():
|
||||
"""SafetySellConfig is a frozen dataclass."""
|
||||
ss = SafetySellConfig()
|
||||
with pytest.raises((AttributeError, TypeError)):
|
||||
ss.watchdog_timeout_sec = 999 # type: ignore[misc]
|
||||
Reference in New Issue
Block a user