← blog  /  bots

Kill switches people actually re-arm

2026-10-20  ·  3 min read  ·  bots

Soft pauses and hard halts: which conditions may clear themselves, which need a person, and why a re-arm should leave a written reason behind.

A circuit breaker that nobody trusts gets turned off. One that trips on every small wobble does the same, and one that never trips is decoration. The design question is which conditions can safely clear themselves.

Two levels#

  • A soft pause is for conditions that pass: a bad day, a losing streak, a burst of API errors. It lifts itself after a cooldown, once the condition is gone.
  • A hard halt is for conditions that mean something is wrong with the bot or the account: a large drawdown, an error storm, a position nobody opened. It never clears itself. A person has to re-arm it.

Closing positions is never blocked by either. That is the first line of the risk gate.

Illustrative: a breaker with a soft pause and a hard haltpython
import logging, time
from enum import Enum

class State(Enum):
    RUNNING, PAUSED, HALTED = "running", "paused", "halted"

class CircuitBreaker:
    def __init__(self, daily_loss=0.02, drawdown=0.10, streak=6, api_errors=20, cooldown_s=3600):
        self.lim = dict(daily_loss=daily_loss, drawdown=drawdown, streak=streak, api_errors=api_errors)
        self.cooldown_s, self.state, self.reason, self.until = cooldown_s, State.RUNNING, "", 0.0

    def check(self, day_pnl: float, drawdown: float, streak: int, api_errors: int) -> State:
        if self.state is State.HALTED:
            return self.state                                     # never clears itself
        if drawdown >= self.lim["drawdown"] or api_errors >= self.lim["api_errors"]:
            return self._trip(State.HALTED, f"drawdown={drawdown:.3f} api_errors={api_errors}")
        if -day_pnl >= self.lim["daily_loss"] or streak >= self.lim["streak"]:
            return self._trip(State.PAUSED, f"day_pnl={day_pnl:.3f} streak={streak}")
        if self.state is State.PAUSED and time.monotonic() >= self.until:
            self.state, self.reason = State.RUNNING, ""           # condition gone + cooled down
        return self.state

    def _trip(self, state: State, reason: str) -> State:
        if self.state is not state:
            logging.warning("breaker -> %s: %s", state.value, reason)
            self.state, self.reason = state, reason
            self.until = time.monotonic() + self.cooldown_s
        return self.state

    def rearm(self, operator: str, note: str) -> None:
        if not operator or not note:
            raise ValueError("re-arming needs a named operator and a written reason")
        logging.warning("re-armed by %s (%s); was: %s", operator, note, self.reason)
        self.state, self.reason = State.RUNNING, ""

The re-arm is the interesting part#

rearm refuses to work without a named operator and a written reason. That is not bureaucracy. It does two things:

  1. It makes re-arming a decision. Nobody re-arms a halt by reflex if they have to type why.
  2. It leaves a trail. Weeks later, when the same halt trips again, the log says what was believed last time and who believed it.

Plus a watchdog#

The breaker lives inside the bot, so a separate small process watches the bot's heartbeat and raises an alert if it stalls. Exchange-side stops keep protecting open positions while a person looks. The watchdog does not repair the bot. It only tells somebody.

not advice

This is how trading software can be built. It is not investment advice. Trading crypto assets, especially with leverage, carries a risk of losing money, and results from tests or simulations do not guarantee anything in live markets. The studio builds trading software and tooling. It does not manage anyone's money, hold client funds or trade on anyone's behalf.