Circuit Breakers for Trading Bots
Circuit breakers stop a trading bot before a bad session compounds. See how often a daily loss limit trips and what volatility scaling does to position size.
Circuit breakers for trading bots are the rules that stop an automated strategy from sending orders once a stated limit is reached. They sit in a risk layer between the strategy and the broker, and they run on every order whether or not the strategy agrees. The strategy decides what to trade. The risk layer decides whether trading happens at all.
That split is the whole design. A strategy that polices itself has no independent check at the moment its own assumptions break, which is the moment a check is worth having.
What a circuit breaker does in a trading bot
A risk layer has four parts, and each one exists to prevent a specific, boring failure.
- Hard caps on position size, notional exposure per symbol, and order rate. They bound the damage from a bug that would otherwise be unbounded.
- A drawdown circuit breaker that halts new orders once the account is down a stated amount on the session, or down a stated amount from its equity peak.
- Scaled order sizing, set from recent volatility or from a fraction of the Kelly bet rather than a fixed share count, which holds risk per trade roughly steady while the market's range moves underneath it.
- An append-only audit log of every decision, including the orders the risk layer rejected. It is the only artifact that separates "the strategy was wrong" from "the check never ran".
Everything below is one of those four, made concrete.
What is a reasonable daily loss limit for a trading bot?
A daily loss limit halts new orders once the session's loss passes a threshold. Picking the number is calibration, not taste: set it inside ordinary market noise and the bot sits halted most weeks, set it far outside and it never fires. The starting point is how often the market itself delivers a down day of a given size.
The exact SQL behind every number
WITH daily AS (
SELECT toDate(toTimeZone(window_start, 'America/New_York')) AS d,
argMax(toFloat64(close), window_start) AS close_px
FROM global_markets.delayed_stocks_minute_aggs
WHERE ticker = 'SPY'
AND toDate(toTimeZone(window_start, 'America/New_York')) >= toDate('2017-12-01')
AND toDate(toTimeZone(window_start, 'America/New_York')) <= toDate('2026-07-31')
AND (toHour(toTimeZone(window_start, 'America/New_York')) * 60
+ toMinute(toTimeZone(window_start, 'America/New_York'))) BETWEEN 570 AND 959
GROUP BY d
),
with_prev AS (
SELECT d,
close_px,
any(close_px) OVER (ORDER BY d ASC ROWS BETWEEN 1 PRECEDING AND 1 PRECEDING) AS prev_close
FROM daily
)
SELECT toYear(d) AS year,
countIf(close_px / prev_close - 1 <= -0.01) AS down_1pct_days,
countIf(close_px / prev_close - 1 <= -0.02) AS down_2pct_days,
countIf(close_px / prev_close - 1 <= -0.03) AS down_3pct_days
FROM with_prev
WHERE prev_close > 0
AND d >= toDate('2018-01-01')
GROUP BY year
ORDER BY yearSessions at 1% down or worse numbered 15 in 2019 and 45 in 2020, out of roughly 250 trading days a year. The 3%-or-worse count is a different animal: 0 in 2019 against 16 in 2020. The last row of the 9 covers sessions through July 31, 2026 only.
The line is lumpy rather than steady, and the lumpiness is the design point. Hard days arrive in clusters. A bot that halts on the first day of a cluster and resumes on the second has not really halted.
Two thresholds do different jobs. A daily loss limit, commonly 2% of account equity, ends the session. A trailing drawdown limit measured from the equity high-water mark, commonly around 10%, ends the strategy pending human review. The first is routine, the second is meant to be rare, and a bot carrying only the first can grind an account down 2% at a time without ever tripping anything.
How does volatility scaling change position size?
Volatility targeting sizes a position inversely to recent realized volatility: when the daily range doubles, the position roughly halves, holding the dollar risk per trade near constant. Realized volatility here is the annualized standard deviation of daily returns, and it travels further than most people expect.
The exact SQL behind every number
WITH daily AS (
SELECT toDate(toTimeZone(window_start, 'America/New_York')) AS d,
argMax(toFloat64(close), window_start) AS close_px
FROM global_markets.delayed_stocks_minute_aggs
WHERE ticker = 'SPY'
AND toDate(toTimeZone(window_start, 'America/New_York')) >= toDate('2023-12-01')
AND toDate(toTimeZone(window_start, 'America/New_York')) <= toDate('2026-07-31')
AND (toHour(toTimeZone(window_start, 'America/New_York')) * 60
+ toMinute(toTimeZone(window_start, 'America/New_York'))) BETWEEN 570 AND 959
GROUP BY d
),
rets AS (
SELECT d,
close_px / any(close_px) OVER (ORDER BY d ASC ROWS BETWEEN 1 PRECEDING AND 1 PRECEDING) - 1 AS ret
FROM daily
)
SELECT formatDateTime(toStartOfMonth(d), '%Y-%m') AS month,
round(stddevSamp(ret) * sqrt(252) * 100, 1) AS realized_vol_pct,
round(least(100.0, 1200.0 / (stddevSamp(ret) * sqrt(252) * 100)), 1) AS vol_target_size_pct
FROM rets
WHERE d >= toDate('2024-01-01')
AND isFinite(ret)
GROUP BY toStartOfMonth(d)
HAVING count() >= 15
ORDER BY toStartOfMonth(d)Realized volatility measured 11.1% annualized in 2024-01 and 12% in 2026-07, across 31 months. The second column turns each reading into the position a 12% volatility target would carry, capped at the full line: 100% in 2024-01 against 99.7% in 2026-07. Same strategy, same conviction, a very different share count.
The Kelly criterion approaches the same problem from the other end, sizing from estimated edge and variance rather than from volatility alone. Most systematic operators run a fraction of it, half or quarter Kelly, given that both inputs are estimates off a finite sample. Kelly criterion position sizing works through that arithmetic.
Why does a trading bot keep re-entering after a stop?
A stop fires. The position closes. Ninety seconds later the entry condition is true again, the bot re-enters, takes the same loss, and repeats. No single component is broken. The strategy did what it was written to do, the stop did what it was written to do, and the account still bleeds out one round trip at a time.
The frequency comes straight from the price path. This panel counts, per session, how many times SPY moved from more than 0.1% above its opening price to more than 0.1% below it, or back again.
The exact SQL behind every number
WITH mins AS (
SELECT toDate(toTimeZone(window_start, 'America/New_York')) AS d,
window_start AS ts,
toFloat64(close) AS px
FROM global_markets.delayed_stocks_minute_aggs
WHERE ticker = 'SPY'
AND toDate(toTimeZone(window_start, 'America/New_York')) >= toDate('2025-08-01')
AND toDate(toTimeZone(window_start, 'America/New_York')) <= toDate('2026-07-31')
AND (toHour(toTimeZone(window_start, 'America/New_York')) * 60
+ toMinute(toTimeZone(window_start, 'America/New_York'))) BETWEEN 570 AND 959
),
opens AS (
SELECT d, argMin(px, ts) AS open_px
FROM mins
GROUP BY d
),
zoned AS (
SELECT m.d AS d,
m.ts AS ts,
multiIf(m.px >= o.open_px * 1.001, 1,
m.px <= o.open_px * 0.999, -1,
0) AS zone
FROM mins AS m
INNER JOIN opens AS o ON m.d = o.d
),
flips AS (
SELECT d,
zone,
any(zone) OVER (PARTITION BY d ORDER BY ts ASC ROWS BETWEEN 1 PRECEDING AND 1 PRECEDING) AS prev_zone
FROM zoned
WHERE zone != 0
),
per_day AS (
SELECT d, countIf(prev_zone != 0 AND zone != prev_zone) AS crossings
FROM flips
GROUP BY d
)
SELECT formatDateTime(toStartOfMonth(d), '%Y-%m') AS month,
round(avg(crossings), 1) AS avg_crossings_per_session,
max(crossings) AS max_crossings_in_a_session
FROM per_day
GROUP BY toStartOfMonth(d)
ORDER BY toStartOfMonth(d)SPY crossed that band an average of 0.7 times per session in 2025-08 and 1.5 times in 2026-07, with one session in 2026-07 recording 5 crossings. Any rule that opens on one side of a level and stops on the other has that many chances to fire in a single day.
Four mechanics contain it.
- A cooldown after every stop, measured in minutes or bars, during which no new order for that symbol clears the risk layer.
- A per-symbol daily trade count cap, which turns an unbounded loop into a bounded one.
- A halt flag that latches. Once the daily loss limit trips, it stays tripped until a person clears it.
- Persistence for that flag outside process memory. A supervisor that restarts a crashed bot hands it a clean slate, and a clean slate is precisely what the flag exists to prevent.
The last one catches people who did everything else right. Grid trading bots place ladders of orders by design, which makes the trade-count cap load-bearing rather than decorative.
What happens when a bot trades on a stale price feed?
A quote that stopped updating still looks like a number. The bot reads it, prices an order against it, and sends that order into a market that has moved. The failure is silent: nothing throws, nothing logs an error, and the fills look strange only afterwards.
The cleanly measurable version is the overnight gap, where a known price sits unchanged for hours while the tradable price moves.
The exact SQL behind every number
WITH daily AS (
SELECT ticker,
toDate(toTimeZone(window_start, 'America/New_York')) AS d,
argMin(toFloat64(open), window_start) AS open_px,
argMax(toFloat64(close), window_start) AS close_px
FROM global_markets.delayed_stocks_minute_aggs
WHERE ticker IN ('SPY', 'KO', 'MSFT', 'AAPL', 'NVDA', 'TSLA')
AND toDate(toTimeZone(window_start, 'America/New_York')) >= toDate('2024-01-01')
AND toDate(toTimeZone(window_start, 'America/New_York')) <= toDate('2026-07-31')
AND (toHour(toTimeZone(window_start, 'America/New_York')) * 60
+ toMinute(toTimeZone(window_start, 'America/New_York'))) BETWEEN 570 AND 959
GROUP BY ticker, d
),
gaps AS (
SELECT ticker,
d,
open_px,
any(close_px) OVER (PARTITION BY ticker ORDER BY d ASC ROWS BETWEEN 1 PRECEDING AND 1 PRECEDING) AS prev_close
FROM daily
)
SELECT ticker,
round(quantileDeterministic(0.5)(abs(open_px / prev_close - 1) * 100, cityHash64(ticker, d)), 2) AS median_gap_pct,
round(quantileDeterministic(0.95)(abs(open_px / prev_close - 1) * 100, cityHash64(ticker, d)), 2) AS p95_gap_pct,
round(max(abs(open_px / prev_close - 1) * 100), 2) AS max_gap_pct
FROM gaps
WHERE prev_close > 0
GROUP BY ticker
ORDER BY p95_gap_pct DESCThe widest name by 95th-percentile gap was TSLA at 4.41%, against 1% for KO. Typical nights were far quieter: median gaps of 1.01% and 0.24% respectively. The tails are what a risk layer is built for, and the largest single gap on TSLA in the window measured 14.57%. Those are the distances available to a bot acting on a price it read some time ago. Why stocks gap overnight covers the mechanics.
The defenses are cheap. Put a maximum age on every quote the risk layer will price against, commonly a few seconds for an intraday strategy. Take a heartbeat from the feed separately from the data, which distinguishes a silent socket from a quiet market. Treat missing data as a halt rather than a hold, since a bot with no prices cannot evaluate its exits either.
Which hard caps belong in the risk layer?
- Maximum notional per symbol as a share of account equity. A 10% ceiling keeps one bad symbol from being the whole account.
- Maximum gross notional across all open positions. Setting it at 100% of equity means no leverage, a decision worth making explicitly rather than inheriting from a broker default.
- Maximum order rate, per minute and per day. Ten orders a minute is generous for most retail strategies and still bounds a runaway loop inside a minute.
- Maximum order size as a share of the symbol's average daily volume. A 1% ceiling is the cap that keeps a bot from moving the price it is trying to trade at, and average daily volume is the denominator.
Every one of these belongs in the risk layer rather than the strategy, and every one runs the same code path in backtest, paper, and live. A limit that exists only in live is a limit nobody has tested.
What does a trading bot audit log need?
An append-only log writes one record per decision and never edits or deletes. Each record carries the timestamp, the quote used and its age, every limit check that ran with its verdict, the order sent, and the broker's reply. Rejected orders are written with the same weight as filled ones.
Reconstruction is the point. Six weeks after a bad session the question is never what the P&L was. It is which check passed, on what input. Without the recorded input you end up re-deriving the bot's state from the market's, which is the same mistake as look-ahead bias in backtesting: using information the system did not hold at the moment of the decision.
The riskguard project is one open-source implementation of this separation, with limit checks living in a component the strategy calls into rather than logic scattered through the strategy itself. It is one design among several, worth reading rather than adopting on sight. Whatever you depend on, pin a tagged release rather than a default branch. A branch can change between two runs of the same backtest, and a risk layer that changed quietly is worse than none.
Why a first deployment runs on a paper broker
The broker adapter defaults to paper, and live trading takes an explicit flag set on purpose. The failure that prevents is mundane: a copied config file, or an environment variable that never got overridden, sending real orders with real money.
A paper run also produces the artifact that matters, a decision log from the same risk layer on live prices, showing which limits fired and which did not. That is evidence about the risk layer, a separate question from whether the strategy makes money. Paper trading before real money covers what a paper record does and does not prove, and multi-agent AI trading systems shows why halt authority has to sit outside every agent once several of them can place orders.
Trading bot circuit breaker FAQ
What is a circuit breaker in a trading bot?
A rule in the risk layer that stops the bot from sending new orders once a stated limit is reached, most often a daily loss threshold or a drawdown from the account's equity peak. It runs independently of the strategy, on every order, and stays tripped until something clears it.
How often does the market deliver a 2% down day?
SPY posted 5 sessions at 2% down or worse in 2019 and 25 in 2020, out of roughly 250 trading days each year. That spread between a calm year and a stressed one is why a loss limit gets calibrated against history rather than intuition.
How do you keep a bot from re-entering after a stop?
A cooldown window after each stop and a per-symbol daily trade cap turn an unbounded loop into a bounded one. The halt flag also has to latch and persist outside process memory, since a supervisor restarting a crashed bot otherwise hands it a fresh, unhalted state.
How can a bot tell that a price feed has gone stale?
By checking the age of every quote before pricing an order against it, and by watching a heartbeat from the feed separately from the data. Overnight gaps show the scale of what a stale price hides: the 95th-percentile overnight move reached 4.41% on TSLA between January 2024 and July 2026.
Does a small trading bot really need an audit log?
A log of fills records what happened. A log of decisions records what the bot believed it was allowed to do, which is the only way to tell a wrong strategy apart from a risk check that never ran. Append-only, rejections included, is the minimum useful version.
Every figure above comes from a stored query over minute bars, and each panel opens to the SQL behind it. Point the same queries at your own symbol list on the Strasmore terminal.