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