feat: add kill-switch control flow
This commit is contained in:
@@ -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"}
|
||||
@@ -15,6 +15,7 @@ import os
|
||||
import pytest
|
||||
|
||||
_PG_TABLES = (
|
||||
"killswitch_exclusion",
|
||||
"snapshot_pause",
|
||||
"ledger_outbox",
|
||||
"intent_log",
|
||||
|
||||
Reference in New Issue
Block a user