feat: add kill-switch control flow
This commit is contained in:
@@ -216,6 +216,45 @@ class InMemoryPauseStore:
|
||||
self._paused.discard(portfolio_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- kill-switch
|
||||
# Day-scoped exclusion overlay (REQ-044/046 / BAT-012). Distinct from pause:
|
||||
# keyed by (portfolio_id, trading_date) so it auto-clears next trading day.
|
||||
GLOBAL_KILLSWITCH = "*" # sentinel: whole-universe exclusion for the day
|
||||
|
||||
|
||||
class KillSwitchStore(Protocol):
|
||||
"""Day-scoped kill-switch exclusion overlay (REQ-044)."""
|
||||
|
||||
def list_excluded(self, trading_date) -> set[str]:
|
||||
...
|
||||
|
||||
def exclude(self, portfolio_id: str, trading_date, *, actor: str = "kill-switch") -> None:
|
||||
...
|
||||
|
||||
def clear(self, portfolio_id: str, trading_date) -> None:
|
||||
...
|
||||
|
||||
|
||||
class InMemoryKillSwitchStore:
|
||||
"""Process-local day-scoped kill-switch store for tests + CI."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
self._excluded: set[tuple[str, object]] = set() # (portfolio_id, trading_date)
|
||||
|
||||
def list_excluded(self, trading_date) -> set[str]:
|
||||
with self._lock:
|
||||
return {pid for pid, d in self._excluded if d == trading_date}
|
||||
|
||||
def exclude(self, portfolio_id: str, trading_date, *, actor: str = "kill-switch") -> None:
|
||||
with self._lock:
|
||||
self._excluded.add((portfolio_id, trading_date))
|
||||
|
||||
def clear(self, portfolio_id: str, trading_date) -> None:
|
||||
with self._lock:
|
||||
self._excluded.discard((portfolio_id, trading_date))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- selection
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -274,11 +313,14 @@ def build_snapshot_envelope(
|
||||
view: View = View.ALL,
|
||||
strategy_key: Optional[str] = None,
|
||||
trading_date=None,
|
||||
killswitch_store: "Optional[KillSwitchStore]" = None,
|
||||
) -> SnapshotEnvelope:
|
||||
"""Compose base(view) − mask(pending·warning·pause) − strategy_key (REQ-029).
|
||||
"""Compose base(view) − mask(pending·warning·pause·killswitch) − strategy_key.
|
||||
|
||||
`slot_repo` is the SlotRepository used to read holding/pending state.
|
||||
`trading_date` defaults to UTC today (matches the daemon's preallocate).
|
||||
`killswitch_store` (REQ-046) adds the day-scoped kill-switch exclusion to
|
||||
the mask: a `GLOBAL_KILLSWITCH` ("*") entry masks the whole universe.
|
||||
"""
|
||||
# Read core authoritative slot state once.
|
||||
day = trading_date or datetime.now(tz=timezone.utc).date()
|
||||
@@ -298,6 +340,9 @@ def build_snapshot_envelope(
|
||||
assets = store.list_assets()
|
||||
paused = pause_store.list_paused()
|
||||
warned = {a.item.portfolio_id for a in assets if a.warning}
|
||||
# REQ-046: day-scoped kill-switch exclusion. '*' sentinel = whole universe.
|
||||
excluded = killswitch_store.list_excluded(day) if killswitch_store is not None else set()
|
||||
killswitch_global = GLOBAL_KILLSWITCH in excluded
|
||||
|
||||
# Build the base set (view).
|
||||
by_pid = {a.item.portfolio_id: a for a in assets}
|
||||
@@ -316,13 +361,13 @@ def build_snapshot_envelope(
|
||||
base_pids = list({a.item.portfolio_id for a in assets} | holding_pids)
|
||||
base_pids.sort()
|
||||
|
||||
# Apply masks: pending OR warning OR pause.
|
||||
masked = pending_pids | warned | paused
|
||||
# Apply masks: pending OR warning OR pause OR kill-switch day-exclusion.
|
||||
masked = pending_pids | warned | paused | excluded
|
||||
|
||||
# Filter and shape.
|
||||
out: list[dict] = []
|
||||
for pid in base_pids:
|
||||
if pid in masked:
|
||||
if killswitch_global or pid in masked:
|
||||
continue
|
||||
if pid in by_pid:
|
||||
item = by_pid[pid].item
|
||||
|
||||
Reference in New Issue
Block a user