Kill Switch
Emergency stop: flatten everything, halt all strategies, require manual reset
The kill switch is the nuclear option. When it fires, every open order is canceled, every position is flattened at market, all strategies are halted, alerts go out on every configured channel, and the system requires a manual reset before trading can resume.
It should fire almost never. When it fires, it has to work.
Signature
from horizon.risk import (
KillSwitch,
PortfolioDrawdownEvent,
MarginCallEvent,
DataFeedCorruptionEvent,
CompliancePauseEvent,
)
kill = KillSwitch(
auto_trigger_on=[
PortfolioDrawdownEvent(threshold=0.25),
MarginCallEvent(),
],
require_manual_reset=True,
alert_channels=["slack", "email", "sms"],
reason_required=True,
)
auto_trigger_onlistrequire_manual_resetboolalert_channelslist[str]reason_requiredboolAuto-trigger events
How it fires
Auto-trigger events are checked on every tick:
def _check_kill_switch(self, state: RiskEngineState) -> list[LifecycleEvent]:
ks = self.config.kill_switch
if ks is None:
return []
for trigger in getattr(ks, "auto_trigger_on", []) or []:
threshold = getattr(trigger, "threshold", None)
if threshold is not None and state.drawdown_pct >= threshold:
return [LifecycleEvent(
kind=LifecycleEventKind.KillSwitchFire,
payload={"trigger": type(trigger).__name__, "drawdown": state.drawdown_pct},
reason=f"kill switch fired: drawdown {state.drawdown_pct:.1%} >= {threshold:.1%}",
)]
return []
When an event fires:
- The run loop sets
state.kill_switch_triggered = True - Every subsequent
check_orderreturnsDecision.reject("kill switch engaged") - A forced-flatten pass cancels all open orders and closes all positions
- Alerts fire on all channels
- The switch stays engaged until manual reset
Manual trigger
From Python:
import horizon as hz
hz.kill_switch.trigger(reason="investigating suspicious order flow")
From the CLI (a future release):
horizon kill --reason "suspicious order flow"
Manual reset
hz.kill_switch.reset(reason="investigation complete, resuming trading")
Or:
horizon kill --reset --reason "investigation complete"
With reason_required=True, both methods require a non-empty reason.
Alert channels
Alerts on kill-switch fire go to all configured channels: this is the “everything is on fire” moment. Typical configuration:
kill = KillSwitch(
alert_channels=["slack", "email", "sms", "pagerduty"],
)
Each channel is wired via the Alerts system (see alerts configuration). Kill-switch alerts bypass any per-event rate limiting: they always go through.
Tests
# tests/test_risk_engine.py::TestKillSwitch
def test_auto_trigger_on_drawdown(self) -> None:
cfg = RiskConfig(
kill_switch=KillSwitch(
auto_trigger_on=[PortfolioDrawdownEvent(threshold=0.25)]
)
)
engine = RiskEngine(cfg)
state = _state(equity=70_000, peak=100_000) # 30% drawdown
events = engine.watchdog(state)
assert any(e.kind == LifecycleEventKind.KillSwitchFire for e in events)
def test_no_trigger_below_threshold(self) -> None:
cfg = RiskConfig(
kill_switch=KillSwitch(
auto_trigger_on=[PortfolioDrawdownEvent(threshold=0.25)]
)
)
engine = RiskEngine(cfg)
state = _state(equity=80_000, peak=100_000) # 20%
events = engine.watchdog(state)
assert not any(e.kind == LifecycleEventKind.KillSwitchFire for e in events)