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

python
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_onlist
Events that automatically trigger the kill switch. If any fires, the switch engages.
require_manual_resetbool
When True, the switch stays engaged until an operator explicitly resets it (typically with a reason). When False, the switch can clear on its own when the triggering condition resolves.
alert_channelslist[str]
Channels to alert on trigger. `"sms"` for critical production systems.
reason_requiredbool
When resetting, the operator must provide a reason. The reason is logged and persisted for audit.

Auto-trigger events

How it fires

Auto-trigger events are checked on every tick:

python
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:

  1. The run loop sets state.kill_switch_triggered = True
  2. Every subsequent check_order returns Decision.reject("kill switch engaged")
  3. A forced-flatten pass cancels all open orders and closes all positions
  4. Alerts fire on all channels
  5. The switch stays engaged until manual reset

Manual trigger

From Python:

python
import horizon as hz

hz.kill_switch.trigger(reason="investigating suspicious order flow")

From the CLI (a future release):

bash
horizon kill --reason "suspicious order flow"

Manual reset

python
hz.kill_switch.reset(reason="investigation complete, resuming trading")

Or:

bash
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:

python
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

python
# 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)

Design philosophy

Next