Why Consecutive Losses Need Their Own Guard
Drawdown limits answer "how much have I lost?" Consecutive-loss breakers answer a different question: "is the process still behaving the way I validated it?" A strategy can be down 4% over a month through a slow bleed of small wins and losses — normal. Or it can lose five trades in a row in two days — also possible under a valid edge, but far less likely, and much more informative. The breaker treats the streak as a signal about regime change, broken data feeds, or an implementation bug, not as a verdict on your equity curve.
The rule is deliberately blunt: three consecutive losing trades trips the strategy into cooldown. No auto-restart. A human reviews the trade history, checks whether the edge is still there, and re-arms the breaker explicitly.
The State Machine
Four states, three transitions, one manual gate:
ACTIVE ──(3 losses)──> TRIPPED ──(notify + persist)──> COOLDOWN
^ │
└──────────(manual review / resume)────────────────────┘
- ACTIVE: strategy trades normally, counter increments on each closed loss and resets on each closed win.
- TRIPPED: transient state entered the moment the third loss closes. Its only job is to fire the notification and persist the trip record. Execution must already be blocked here.
- COOLDOWN: strategy is paused. No orders, no sizing, no "just one small trade to test."
- Resume: only via an explicit call after review. Never on a timer, never on a new day, never on "market looks better now."
The important design decision is that TRIPPED blocks execution before the notification is sent. If the notification service is down, the strategy still doesn't trade. Failure to notify must never mean failure to stop.
Where the Counter Lives
An in-memory counter is worthless. Strategies get restarted by deploys, crashes, and Windows Task Scheduler reboots. If the counter resets on restart, a strategy that lost two trades before a restart gets a free pass on the third. Persist the counter and the state together, atomically, on every closed trade.
SQLite is enough for a single-strategy setup and survives process death:
import sqlite3
from contextlib import contextmanager
from dataclasses import dataclass
from datetime import datetime, timezone
from enum import Enum
class State(str, Enum):
ACTIVE = "active"
TRIPPED = "tripped"
COOLDOWN = "cooldown"
@dataclass
class BreakerRecord:
strategy_id: str
state: State
consecutive_losses: int
updated_at: str
class BreakerStore:
def __init__(self, path: str = "breaker.db"):
self.path = path
with self._conn() as c:
c.execute("""
CREATE TABLE IF NOT EXISTS breaker (
strategy_id TEXT PRIMARY KEY,
state TEXT NOT NULL,
consecutive_losses INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL
)
""")
@contextmanager
def _conn(self):
conn = sqlite3.connect(self.path, isolation_level=None)
try:
conn.execute("PRAGMA journal_mode=WAL")
yield conn
finally:
conn.close()
def load(self, strategy_id: str) -> BreakerRecord:
with self._conn() as c:
row = c.execute(
"SELECT state, consecutive_losses, updated_at FROM breaker WHERE strategy_id=?",
(strategy_id,),
).fetchone()
if row is None:
return BreakerRecord(strategy_id, State.ACTIVE, 0, _now())
return BreakerRecord(strategy_id, State(row[0]), row[1], row[2])
def save(self, rec: BreakerRecord) -> None:
with self._conn() as c:
c.execute("""
INSERT INTO breaker (strategy_id, state, consecutive_losses, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(strategy_id) DO UPDATE SET
state=excluded.state,
consecutive_losses=excluded.consecutive_losses,
updated_at=excluded.updated_at
""", (rec.strategy_id, rec.state.value, rec.consecutive_losses, rec.updated_at))
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
Two things matter here beyond persistence. First, isolation_level=None with WAL gives you autocommit plus concurrent readers, which is fine for this write pattern. Second, the state and the counter are one row — they can never disagree after a crash.
The Breaker Logic
The transition function is small and should stay that way. Every closed trade goes through it; nothing else mutates the counter.
from dataclasses import replace
MAX_CONSECUTIVE_LOSSES = 3
class LossBreaker:
def __init__(self, store: BreakerStore, strategy_id: str,
threshold: int = MAX_CONSECUTIVE_LOSSES):
self.store = store
self.strategy_id = strategy_id
self.threshold = threshold
def can_trade(self) -> bool:
return self.store.load(self.strategy_id).state is State.ACTIVE
def on_trade_closed(self, pnl: float, trade_meta: dict) -> BreakerRecord:
rec = self.store.load(self.strategy_id)
if rec.state is not State.ACTIVE:
# Breaker is latched; closed PnL from any straggler orders
# must not move the counter.
return rec
losses = rec.consecutive_losses + 1 if pnl < 0 else 0
new_state = State.TRIPPED if losses >= self.threshold else State.ACTIVE
rec = replace(rec, state=new_state, consecutive_losses=losses,
updated_at=_now())
self.store.save(rec)
if new_state is State.TRIPPED:
# Execution is already blocked by the persisted state.
self._handle_trip(rec, trade_meta)
return rec
def _handle_trip(self, rec: BreakerRecord, trade_meta: dict) -> None:
history = self._recent_trades()
# Move to COOLDOWN before notifying so a crash mid-notification
# still leaves the strategy paused.
self.store.save(replace(rec, state=State.COOLDOWN, updated_at=_now()))
notify_trip(self.strategy_id, history, trade_meta)
def _recent_trades(self) -> list[dict]:
# Pull the last N closed trades from your trade log.
return load_recent_closed_trades(self.strategy_id, limit=10)
def resume(self, reviewer: str, note: str) -> BreakerRecord:
rec = self.store.load(self.strategy_id)
if rec.state is not State.COOLDOWN:
raise RuntimeError(f"cannot resume from state {rec.state}")
log_review(self.strategy_id, reviewer, note, rec.consecutive_losses)
rec = replace(rec, state=State.ACTIVE, consecutive_losses=0,
updated_at=_now())
self.store.save(rec)
return rec
Note the ordering in _handle_trip: persist COOLDOWN first, then notify. If notify_trip throws, the strategy is already stopped. The notification is a side effect, not part of the safety mechanism.
Also note that on_trade_closed ignores PnL while the breaker is latched. This handles the real-world case where a stop order fills after the trip — you don't want that fill to silently reset a loss counter or, worse, re-trip an already-tripped breaker and re-fire notifications.
Wiring It Into the Execution Loop
The check has to sit in front of order submission, not inside the strategy logic. Strategy code is where you're most tempted to add exceptions.
def run_bar(breaker: LossBreaker, strategy, market_data) -> None:
if not breaker.can_trade():
return # paused: no signals, no orders, no sizing
signal = strategy.evaluate(market_data)
if signal is None:
return
fill = execute_order(signal)
if fill is not None and fill.closed:
breaker.on_trade_closed(fill.pnl, fill.meta)
If your strategy emits orders from multiple places, wrap execute_order itself with the can_trade() guard as a second line of defense. Belt and suspenders is cheap here.
Notifications That Are Actually Useful
A push that says "strategy tripped" wastes the meeting. The notification should carry the evidence: the losing trades, their entry/exit, size, PnL, and the timestamps. That's what makes the review a review instead of a shrug.
import json
import requests
def notify_trip(strategy_id: str, history: list[dict], trigger: dict) -> None:
lines = [f"Circuit breaker tripped: {strategy_id}", ""]
for t in history:
lines.append(
f"{t['closed_at']} {t['side']:<4} size={t['size']:<8} "
f"pnl={t['pnl']:+.2f} {t.get('exit_reason', '')}"
)
lines.append("")
lines.append(f"Triggering trade: {json.dumps(trigger, default=str)}")
body = "\n".join(lines)
requests.post(
"https://ntfy.sh/your-private-topic",
data=body.encode("utf-8"),
headers={"Title": f"BREAKER: {strategy_id}", "Priority": "high"},
timeout=10,
)
Keep the payload small and text-based. It should be readable on a phone lock screen. If you use a different channel — email, Telegram, a local webhook — the shape stays the same: strategy id, the streak, the trades.
Consecutive Losses vs. Drawdown Limits
These are not substitutes, and running only one is a common gap.
| Drawdown limit | Consecutive-loss breaker | |
|---|---|---|
| Measures | Cumulative capital loss | Process deviation |
| Typical trigger | Slow bleed over weeks | Fast streak over days |
| Reset | New equity high | A single win |
| Failure mode it catches | Sizing too large for the edge | Edge decay, feed corruption, bug |
| Response | Reduce size / stop | Pause and review |
A drawdown limit won't fire on three losses if the prior month was strong. A consecutive-loss breaker will. Conversely, a breaker won't catch a strategy that loses 55% of trades in a steady grind — you need the drawdown limit for that. Run both. They answer different questions and they fail in different directions.
One more distinction worth stating: the breaker is per strategy, not per account. If you run five strategies and one trips, the other four should keep running. Coupling them turns a single-strategy problem into a portfolio-wide outage.
The Psychology Part
The breaker is not punishment. It's a forced meeting with your own statistics.
Three losses in a row is not rare. For a strategy with a 45% win rate, the probability of any given three-trade window being all losses is about 16.6%. Over a few hundred trades you'll see it constantly. That's precisely why the rule is mechanical: if you decide case-by-case whether a streak is "just variance," you will decide it's variance every time, because that's the answer that lets you keep trading.
The manual resume step is the actual feature. It forces you to write down what you saw — the trades, the market context, whether the fills looked normal — before the strategy goes live again. Sometimes the review takes two minutes and you're back. Sometimes it takes a week and the strategy gets retired. Both are correct outcomes. What's not correct is a timer that restarts everything at midnight and calls it risk management.
Two practical habits make reviews fast:
-
Log the review. The
log_reviewcall inresume()writes who resumed, when, and why. After a few months you have a record of which strategies trip often and whether those trips preceded real problems. That's data about your breaker, not just your strategy. - Tune the threshold per strategy, not globally. Three is a reasonable default for strategies trading a few times a day. For a high-frequency strategy, three losses is noise; use a larger threshold or a percentage-based variant. For a low-frequency strategy, three losses might span months — there, a drawdown limit does more work.
Adapting It
The same structure generalizes cheaply:
- Percentage-based streak: trip when cumulative loss over the current streak exceeds x% of equity, instead of a fixed count.
- Time-bounded streak: only count losses within a rolling window, so a loss in January and a loss in June aren't "consecutive."
-
Per-instrument breakers: key the store on
strategy_id + symbolif your strategy trades several instruments with independent behavior.
In all cases, keep the same invariants: persist state and counter together, block execution before notifying, and require an explicit, logged resume.
More notes like this ship every week on this site.
Daily Picks
The following pairs are selected from the multi-timeframe trend scanner (Gate.io futures) and are for technical-analysis study only — not investment advice.
Data updated: 2026-10-30 08:46:53
Long
| Pair | Signal | Price | Take Profit | Stop Loss | R/R |
|---|---|---|---|---|---|
| TRX | 高质量标的做多 | $0.3521 | $0.3768 | $0.3398 | 1:2.0 |
Short
| Pair | Signal | Price | Take Profit | Stop Loss | R/R |
|---|---|---|---|---|---|
| SNXX | 庄家做空 | $8.496 | $8.0967 | $8.7934 | 1:1.3 |
2 picks selected. Scanner runs every 15 minutes.
Top comments (0)