feat: add kill-switch control flow

This commit is contained in:
2026-06-07 14:00:20 +09:00
parent c912c08071
commit 7e23648489
30 changed files with 1424 additions and 51 deletions
@@ -0,0 +1,79 @@
"""Kill-switch day-scoped exclusion store tests (REQ-044 / BAT-012).
In-memory tests run always; PostgreSQL tests are gated on TREX_POSTGRESQL_TEST_DSN.
"""
from __future__ import annotations
from datetime import date
import pytest
from trex_core.application.portfolio import GLOBAL_KILLSWITCH, InMemoryKillSwitchStore
_D = date(2026, 6, 7)
_D_NEXT = date(2026, 6, 8)
class TestInMemoryKillSwitchStore:
def test_exclude_then_list(self):
s = InMemoryKillSwitchStore()
s.exclude("pf-A", _D)
assert "pf-A" in s.list_excluded(_D)
def test_day_scoped_auto_clear(self):
"""AC-4: next trading day does not include today's exclusion."""
s = InMemoryKillSwitchStore()
s.exclude("pf-A", _D)
assert "pf-A" not in s.list_excluded(_D_NEXT)
def test_idempotent_exclude(self):
s = InMemoryKillSwitchStore()
s.exclude("pf-A", _D)
s.exclude("pf-A", _D)
assert s.list_excluded(_D) == {"pf-A"}
def test_clear(self):
s = InMemoryKillSwitchStore()
s.exclude("pf-A", _D)
s.clear("pf-A", _D)
assert "pf-A" not in s.list_excluded(_D)
def test_global_sentinel(self):
"""AC-7: global exclusion stored under the '*' sentinel."""
s = InMemoryKillSwitchStore()
s.exclude(GLOBAL_KILLSWITCH, _D)
assert GLOBAL_KILLSWITCH in s.list_excluded(_D)
@pytest.fixture
def pg_killswitch_store(pg_clean):
from trex_core.adapters.portfolio.killswitch_store import PostgreSQLKillSwitchStore
return PostgreSQLKillSwitchStore(pg_clean)
class TestPostgreSQLKillSwitchStore:
def test_schema_and_round_trip(self, pg_killswitch_store):
"""AC-1/3: table exists; exclude→list_excluded round-trips."""
pg_killswitch_store.exclude("pf-A", _D, actor="op")
assert pg_killswitch_store.list_excluded(_D) == {"pf-A"}
def test_day_scoped(self, pg_killswitch_store):
"""AC-4: list_excluded(next day) excludes today's row."""
pg_killswitch_store.exclude("pf-A", _D)
assert pg_killswitch_store.list_excluded(_D_NEXT) == set()
def test_idempotent(self, pg_killswitch_store):
"""AC-5: ON CONFLICT DO NOTHING — duplicate exclude is a no-op."""
pg_killswitch_store.exclude("pf-A", _D)
pg_killswitch_store.exclude("pf-A", _D)
assert pg_killswitch_store.list_excluded(_D) == {"pf-A"}
def test_clear(self, pg_killswitch_store):
"""AC-6: clear removes the row."""
pg_killswitch_store.exclude("pf-A", _D)
pg_killswitch_store.clear("pf-A", _D)
assert pg_killswitch_store.list_excluded(_D) == set()
def test_global_sentinel(self, pg_killswitch_store):
pg_killswitch_store.exclude(GLOBAL_KILLSWITCH, _D)
assert GLOBAL_KILLSWITCH in pg_killswitch_store.list_excluded(_D)
@@ -0,0 +1,87 @@
"""Control channel action dispatch tests (REQ-043 / BAT-012)."""
from __future__ import annotations
import pytest
from trex_core.application.messaging import RpcRequest
from trex_core.application.messaging_handlers import make_control_rpc_handler
from trex_core.application.portfolio import InMemoryPauseStore
class _FakeKillSwitch:
def __init__(self):
self.fired = []
self.resumed = []
def fire(self, *, scope, portfolio_id=None, actor="operator"):
self.fired.append((scope, portfolio_id, actor))
return 3
def resume(self, *, scope, portfolio_id=None):
self.resumed.append((scope, portfolio_id))
def _req(**body):
return RpcRequest(payload=body, correlation_id="c1")
@pytest.fixture
def ks():
return _FakeKillSwitch()
@pytest.fixture
def handler(ks):
return make_control_rpc_handler(InMemoryPauseStore(), kill_switch_service=ks)
def test_pause_action_delegates(handler):
"""AC-1: pause delegates to REQ-032 pause handler."""
resp = handler(_req(action="pause", portfolio_id="pf-A"))
assert resp.payload["status"] == "ok"
assert "pf-A" in resp.payload["paused"]
def test_resume_and_list_actions(handler):
"""AC-2: resume/list delegate to pause handler."""
handler(_req(action="pause", portfolio_id="pf-A"))
assert handler(_req(action="resume", portfolio_id="pf-A")).payload["status"] == "ok"
assert handler(_req(action="list")).payload["status"] == "ok"
def test_kill_switch_routed(handler, ks):
"""AC-3: kill_switch routes to the kill-switch service."""
resp = handler(_req(action="kill_switch", scope="global"))
assert resp.payload["status"] == "ok"
assert resp.payload["liquidated"] == 3
assert ks.fired == [("global", None, "operator")]
def test_resume_kill_switch_routed(handler, ks):
"""AC-4: resume_kill_switch routes to the kill-switch service."""
resp = handler(_req(action="resume_kill_switch", scope="portfolio", portfolio_id="pf-A"))
assert resp.payload["status"] == "ok"
assert ks.resumed == [("portfolio", "pf-A")]
def test_unknown_action(handler):
"""AC-5: unknown action → error."""
resp = handler(_req(action="bogus"))
assert resp.payload["status"] == "error"
assert resp.payload["reason"] == "unknown_action:bogus"
def test_kill_switch_portfolio_requires_id(handler):
resp = handler(_req(action="kill_switch", scope="portfolio"))
assert resp.payload == {"status": "error", "reason": "portfolio_id_required"}
def test_kill_switch_bad_scope(handler):
resp = handler(_req(action="kill_switch", scope="weird"))
assert resp.payload["reason"] == "bad_scope:weird"
def test_kill_switch_unsupported_without_service():
handler = make_control_rpc_handler(InMemoryPauseStore()) # no service
resp = handler(_req(action="kill_switch", scope="global"))
assert resp.payload == {"status": "error", "reason": "kill_switch_unsupported"}
@@ -0,0 +1,107 @@
"""Kill-switch execution service tests (REQ-045 / BAT-012)."""
from __future__ import annotations
from datetime import date, datetime, timezone
import pytest
from trex_core.application.killswitch import KillSwitchService
from trex_core.application.portfolio import GLOBAL_KILLSWITCH, InMemoryKillSwitchStore
from trex_core.domain.slot import SlotService, SlotState
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
_DAY = date(2026, 6, 7)
_NOW = datetime(2026, 6, 7, 6, 0, tzinfo=timezone.utc) # UTC date == _DAY
@pytest.fixture
def repo():
r = InMemorySlotRepository()
return r
@pytest.fixture
def service(repo):
svc = SlotService(repo)
svc.preallocate_daily_slots(_DAY, 10)
return svc
@pytest.fixture
def store():
return InMemoryKillSwitchStore()
@pytest.fixture
def ks(repo, service, store):
return KillSwitchService(slot_repo=repo, slot_service=service, killswitch_store=store)
def _holding(service, repo, *, portfolio_id, symbol="005930"):
idle = repo.find_idle_slot(_DAY)
pending = service.transit_slot(
idle.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
reason="buy", actor="test", symbol=symbol, portfolio_id=portfolio_id,
)
return service.transit_slot(
pending.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
reason="filled", actor="test", avg_price=50000.0, qty=1.0,
)
def test_global_liquidates_all(ks, repo, service, store):
"""AC-1: global scope sells all holding slots."""
a = _holding(service, repo, portfolio_id="pf-A")
b = _holding(service, repo, portfolio_id="pf-B")
n = ks.fire(scope="global", now=_NOW)
assert n == 2
assert repo.fetch_slot(a.slot_id).status is SlotState.PENDING_SELL
assert repo.fetch_slot(b.slot_id).status is SlotState.PENDING_SELL
assert GLOBAL_KILLSWITCH in store.list_excluded(_DAY)
def test_portfolio_scope_limits(ks, repo, service, store):
"""AC-2: portfolio scope only sells that portfolio's slots."""
a1 = _holding(service, repo, portfolio_id="pf-A", symbol="A1")
a2 = _holding(service, repo, portfolio_id="pf-A", symbol="A2")
b = _holding(service, repo, portfolio_id="pf-B")
n = ks.fire(scope="portfolio", portfolio_id="pf-A", now=_NOW)
assert n == 2
assert repo.fetch_slot(a1.slot_id).status is SlotState.PENDING_SELL
assert repo.fetch_slot(a2.slot_id).status is SlotState.PENDING_SELL
assert repo.fetch_slot(b.slot_id).status is SlotState.HOLDING
assert store.list_excluded(_DAY) == {"pf-A"}
def test_idempotent_second_fire(ks, repo, service):
"""AC-3: a second fire does not re-transition already pending_sell slots."""
a = _holding(service, repo, portfolio_id="pf-A")
ks.fire(scope="global", now=_NOW)
n2 = ks.fire(scope="global", now=_NOW)
assert n2 == 0 # nothing still holding
log = repo.transition_log_for(a.slot_id)
assert len([e for e in log if e.to_state is SlotState.PENDING_SELL]) == 1
def test_marks_day_exclusion(ks, repo, service, store):
"""AC-5: scope marked day-excluded in the store."""
_holding(service, repo, portfolio_id="pf-A")
ks.fire(scope="portfolio", portfolio_id="pf-A", now=_NOW)
assert "pf-A" in store.list_excluded(_DAY)
def test_resume_clears_exclusion_not_slots(ks, repo, service, store):
"""AC-8: resume clears day-exclusion but sold slots stay sold."""
a = _holding(service, repo, portfolio_id="pf-A")
ks.fire(scope="portfolio", portfolio_id="pf-A", now=_NOW)
assert repo.fetch_slot(a.slot_id).status is SlotState.PENDING_SELL
ks.resume(scope="portfolio", portfolio_id="pf-A", now=_NOW)
assert "pf-A" not in store.list_excluded(_DAY)
# sold slot is NOT reverted
assert repo.fetch_slot(a.slot_id).status is SlotState.PENDING_SELL
def test_no_holding_returns_zero(ks, store):
n = ks.fire(scope="global", now=_NOW)
assert n == 0
assert GLOBAL_KILLSWITCH in store.list_excluded(_DAY) # still marks exclusion
@@ -0,0 +1,107 @@
"""Kill-switch day-exclusion masking + resume tests (REQ-046 / BAT-012)."""
from __future__ import annotations
from datetime import date
import pytest
from trex_core.application.killswitch import KillSwitchService
from trex_core.application.portfolio import (
GLOBAL_KILLSWITCH,
InMemoryKillSwitchStore,
InMemoryPauseStore,
PortfolioItem,
SnapshotStore,
View,
build_snapshot_envelope,
)
from trex_core.domain.slot import SlotService
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
_DAY = date(2026, 6, 7)
_DAY_NEXT = date(2026, 6, 8)
@pytest.fixture
def store():
s = SnapshotStore()
s.swap([
PortfolioItem(portfolio_id="pf-A", symbol="005930", cash_balance=0, asset_quantity=0),
PortfolioItem(portfolio_id="pf-B", symbol="000660", cash_balance=0, asset_quantity=0),
])
return s
@pytest.fixture
def repo():
return InMemorySlotRepository()
@pytest.fixture
def ks_store():
return InMemoryKillSwitchStore()
def _asset_ids(envelope):
return {a["asset_id"] for a in envelope.to_dict()["assets"]}
def _envelope(store, repo, ks_store, *, view=View.ALL, day=_DAY):
return build_snapshot_envelope(
store=store, pause_store=InMemoryPauseStore(), slot_repo=repo,
available_slots=10, view=view, trading_date=day, killswitch_store=ks_store,
)
def test_portfolio_exclusion_masks_that_asset(store, repo, ks_store):
"""AC-1: an excluded portfolio is masked from the envelope."""
ks_store.exclude("pf-A", _DAY)
ids = _asset_ids(_envelope(store, repo, ks_store))
assert "pf-A" not in ids
assert "pf-B" in ids
def test_global_exclusion_masks_all(store, repo, ks_store):
"""AC-2: global ('*') exclusion masks the whole universe."""
ks_store.exclude(GLOBAL_KILLSWITCH, _DAY)
assert _asset_ids(_envelope(store, repo, ks_store)) == set()
def test_buyable_view_excludes(store, repo, ks_store):
"""AC-3: excluded asset is not buyable (blocks new buys)."""
ks_store.exclude("pf-A", _DAY)
ids = _asset_ids(_envelope(store, repo, ks_store, view=View.BUYABLE))
assert "pf-A" not in ids and "pf-B" in ids
def test_day_scoped_auto_recovery(store, repo, ks_store):
"""AC-4: next trading day, the exclusion is gone (auto recovery)."""
ks_store.exclude("pf-A", _DAY)
ids = _asset_ids(_envelope(store, repo, ks_store, day=_DAY_NEXT))
assert "pf-A" in ids # back on the next day
def test_persistence_same_day(store, repo, ks_store):
"""AC-5: a fresh envelope build same day still masks (store-backed persistence)."""
ks_store.exclude("pf-A", _DAY)
# simulate restart: new envelope build reading the same store
assert "pf-A" not in _asset_ids(_envelope(store, repo, ks_store))
def test_resume_reincludes(store, repo, ks_store):
"""AC-6: resume (clear) re-includes the asset from the next build."""
svc = KillSwitchService(slot_repo=repo, slot_service=SlotService(repo), killswitch_store=ks_store)
ks_store.exclude("pf-A", _DAY)
assert "pf-A" not in _asset_ids(_envelope(store, repo, ks_store))
svc.resume(scope="portfolio", portfolio_id="pf-A",
now=__import__("datetime").datetime(2026, 6, 7, 6, tzinfo=__import__("datetime").timezone.utc))
assert "pf-A" in _asset_ids(_envelope(store, repo, ks_store))
def test_no_killswitch_store_no_masking(store, repo):
"""Backward compat: without killswitch_store nothing extra is masked."""
env = build_snapshot_envelope(
store=store, pause_store=InMemoryPauseStore(), slot_repo=repo,
available_slots=10, view=View.ALL, trading_date=_DAY,
)
assert _asset_ids(env) == {"pf-A", "pf-B"}
+1
View File
@@ -15,6 +15,7 @@ import os
import pytest
_PG_TABLES = (
"killswitch_exclusion",
"snapshot_pause",
"ledger_outbox",
"intent_log",