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:
2026-06-07 08:32:30 +09:00
co-authored by Claude Opus 4.8
parent e4e5228972
commit a30efa8f3b
47 changed files with 3399 additions and 36 deletions
+100
View File
@@ -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"
+103
View File
@@ -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
+111
View File
@@ -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")
+69
View File
@@ -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]