108 lines
3.6 KiB
Python
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"}
|