Kill switches people actually re-arm
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.
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:
- It makes re-arming a decision. Nobody re-arms a halt by reflex if they have to type why.
- 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.
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.
The best of a hundred coin flips looks like talent
Try enough strategies and one of them will look brilliant, even when every one is a coin toss. The number of tries is part of the result, and it is the part nobody prints.
Slippage is a fee nobody prints
The exchange prints its fee. It does not print the spread, the thin book or the stop that fills worse than its trigger. A test that leaves them out is flattering itself.
Start from the task, not the strategy
A trading bot project begins with what the bot is for, what it may lose and what it must never do. The signal comes last, and it is the smallest part of the work.