Files
trex-core/tests/application/test_killswitch_masking_resume.py
T

108 lines
3.6 KiB
Python

"""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"}