Lesson 4.2.5 · 65 min
Drawdown control and kill switch
Four defenses against losses, from seconds to years: an operational kill switch, a circuit breaker, exposure reduction with drawdown and a statistical kill switch. They are tested on ten synthetic strategies, one of them dead since July 2019, and each one comes with how long it takes, how often it gets it wrong and what it costs when the strategy was healthy.
- Module
- 4.2 · Risk management and position sizing
- Duration
- 65 min
- Prerequisites
- 2.6.1 · CAGR, Sharpe, Sortino, Calmar, Omega, drawdown and Ulcer Index, 2.1.7 · Regime detection: hidden Markov models and structural breaks, 2.5.4 · Deflated Sharpe Ratio and Probability of Backtest Overfitting (PBO), 4.2.1 · Risk per trade, stops and risk of ruin, 4.2.2 · Kelly criterion and volatility targeting: how much to risk
- Author
- Jesús Cuesta
- Updated
Level 4 lessons
4.1 · Portfolio theory and optimisation
- 4.1.1 Markowitz: efficient frontier and real constraints
- 4.1.2 Covariance estimation: shrinkage, factor models and denoising
- 4.1.3 Black-Litterman: from market equilibrium to views
- 4.1.4 Risk parity, minimum variance, HRP and HERC
- 4.1.5 Optimization with CVaR, drawdown, turnover and costs
- 4.1.6 Factor models: CAPM, Fama-French, PCA and attribution
- 4.1.7 Rebalancing and strategy portfolios
- 4.1.8 Portfolio tools: Riskfolio-Lib, PyPortfolioOpt, skfolio and bt
4.2 · Risk management and position sizing
- 4.2.1 Risk per trade, stops and risk of ruin
- 4.2.2 Kelly criterion and volatility targeting: how much to risk
- 4.2.3 VaR, Expected Shortfall and VaR backtesting
- 4.2.4 Stress testing and correlation, liquidity, gap and counterparty risk
- 4.2.5 Drawdown control and kill switch
- 4.2.6 Derivatives risk: portfolio Greeks, margin and liquidations
4.3 · Microstructure and execution
4.4 · Brokers and APIs
- 4.4.1 Choosing a broker from Spain or the EU: regulation, assets, costs and API
- 4.4.2 Interactive Brokers: TWS and IB Gateway, ib_async, contracts, data and orders
- 4.4.3 Alpaca, Tradier, MetaTrader 5 with Python and OANDA v20
- 4.4.4 Crypto exchange APIs: ccxt, Binance, Bybit, Kraken and Coinbase
- 4.4.5 Secure credentials: environment variables, secret managers and least privilege
4.5 · Live trading system
- 4.5.1 Live system architecture: from data to reconciliation
- 4.5.2 Order management: states, partial fills, cancellations and reconnections
- 4.5.3 Paper trading, shadow trading and slippage monitoring
- 4.5.4 Task scheduling and alerts: cron, APScheduler, Prefect, Telegram, Slack and email
- 4.5.5 Fault tolerance: idempotency, persistent state and reconciliation at startup
- 4.5.6 NautilusTrader or LEAN live: from backtest to production with the same code
Four defenses with different horizons
A system that loses money can be in four very different situations: a software bug sending orders out of control, a bad day of a healthy strategy, a bad streak of a healthy strategy and a strategy that has stopped working. Each one calls for a different defense, which acts on a different horizon and gets it wrong in a different way:
| Defense | Horizon | What it detects | How it gets it wrong |
|---|---|---|---|
| Operational kill switch | Seconds | Orders that should not exist, stale data, losses out of scale | Stops the system because of a bad data point from the broker |
| Circuit breaker | Days | A day with an abnormal loss | Misses the next day's rebound |
| Drawdown-based exposure | Weeks to months | A losing streak | Trades little right when the strategy recovers |
| Statistical kill switch | Months to years | An edge that has disappeared | Switches off healthy strategies in their bad streaks |
Day to day, a signal goes through the last three before reaching the broker, and the operational kill switch watches the whole path:
Mermaid source
flowchart TB
S[Signal] --> E[Scale by drawdown]
E --> C{Breaker paused?}
C -- no --> K{Kill switch}
C -- yes --> N[No trading]
K -- active --> O[Order to broker]
K -- tripped --> NThe lesson uses estrategias4_diarias.parquet: simple daily returns, in excess of the risk-free rate, of ten
synthetic strategies similar to those of levels 2 and 3, from 2006 to 2025. Nine have a constant edge (their true
parameters are in estrategias4_verdad.csv: target Sharpe between 0.25 and 0.50) and suffer the crises of the level's
universe. The tenth, degradada, has a true Sharpe of 1 until June 30, 2019 and a mean of zero from July 1. With the
answer known, you can measure what is never known live: when an alarm is false and how long a true one takes. This is
synthetic data: nothing these strategies earn is evidence that they work in real markets.
The drawdown of a healthy strategy
Before deciding which drawdown is too much, it is worth looking at the ones suffered by strategies with nothing broken:
import numpy as np
import pandas as pd
r = pd.read_parquet("estrategias4_diarias.parquet").set_index("fecha")
verdad = pd.read_csv("estrategias4_verdad.csv",
index_col="estrategia")
def caidas(x):
"""Drawdown series of simple daily returns."""
eq = (1 + x).cumprod()
return eq / eq.cummax() - 1
def tabla_caidas(r):
filas = {}
for c in r.columns:
dd = caidas(r[c])
bajo = (dd < 0).astype(int)
# longest stretch below the peak, in sessions
racha = bajo.groupby((bajo == 0).cumsum()).sum().max()
filas[c] = {"sharpe": r[c].mean() / r[c].std() * 252**0.5,
"max_caida": dd.min(), "fecha": dd.idxmin().date(),
"anos_bajo_max": racha / 252}
return pd.DataFrame.from_dict(filas, orient="index")
tabla_caidas(r).round(2) sharpe max_caida fecha anos_bajo_max
tendencia_futuros 0.53 -0.36 2024-04-29 9.04
carry_futuros 0.38 -0.27 2011-01-13 8.12
carry_divisas 0.53 -0.35 2011-11-03 9.50
venta_puts 0.31 -0.49 2009-02-24 8.41
pares 0.40 -0.22 2007-08-09 7.04
momentum_acciones 0.39 -0.28 2009-06-10 6.08
valor_acciones 0.27 -0.24 2013-04-03 10.67
venta_volatilidad 0.25 -0.49 2023-02-22 9.31
reversion_corto 0.45 -0.24 2008-12-04 9.69
degradada 0.70 -0.43 2024-02-06 4.55The nine healthy strategies have maximum drawdowns from 22% to 49% and spend between 6 and almost 11 years below a
previous peak. Put selling and volatility selling fall almost by half (−49.0% and −48.9%). None of them is broken: it
is what a strategy with Sharpe 0.3-0.5 does over 20 years with two big crises. degradada has the largest drawdown
after the two premium sellers (−43.4%), all of it after dying.
The most common mistake is to take the backtest maximum drawdown as the worst case. It is a single sample from a
distribution. A simulation of healthy strategies with the parameters of degradada before dying (Sharpe 1, 8%
volatility, Student's t innovations) gives that distribution for 3.5 and 10 years:
rng = np.random.default_rng(7)
def max_caidas(sharpe, vol, anos, n=4000):
"""Maximum drawdown of n healthy paths: constant daily mean,
Student's t innovations (nu = 5) with variance 1."""
T = int(252 * anos)
z = rng.standard_t(5, (T, n)) / np.sqrt(5 / 3)
x = sharpe * vol / 252 + vol / np.sqrt(252) * z
eq = np.cumprod(1 + x, axis=0)
return (eq / np.maximum.accumulate(eq, axis=0) - 1).min(axis=0)
bt = r.loc[:"2015-12-31", "degradada"]
sharpe_bt = bt.mean() / bt.std() * 252**0.5
print(f"Backtest 2006-2015: Sharpe {sharpe_bt:.2f},"
f" maximum drawdown {caidas(bt).min():.1%}")
filas = {}
for anos in (3.5, 10):
m = max_caidas(1.0, 0.08, anos)
filas[f"{anos} years"] = {
"mediana": np.median(m), "p90": np.quantile(m, 0.1),
"p99": np.quantile(m, 0.01),
"P(peor que -16,4 %)": (m < -0.164).mean()}
pd.DataFrame(filas).T.round(3)Backtest 2006-2015: Sharpe 1.14, maximum drawdown -16.4%
mediana p90 p99 P(peor que -16,4 %)
3.5 years -0.094 -0.147 -0.214 0.059
10 years -0.127 -0.188 -0.262 0.200In the 2006-2015 backtest, degradada has a Sharpe of 1.14 and a maximum drawdown of 16.4%. For a healthy strategy
with those parameters, the median of the worst drawdown in 3.5 years is 9.4% and in 10 years 12.7%, but in 10 years
20% of the paths exceed it, and the 1st percentile reaches −26.2%. Setting the kill switch at “the backtest drawdown”
switches off one healthy strategy in five within 10 years. And that is with independent returns: with volatility
clustering, as in the data, the tail is fatter.
Cutting exposure with drawdown
The most widespread rule switches nothing off: it reduces size as the account falls from its peak. The lesson's version makes exposure proportional to the cushion left until a maximum tolerated drawdown , in the spirit of Grossman and Zhou (1993), who proved that investing in the risky asset an amount proportional to , with the capital's running peak, guarantees in continuous time that the drawdown never exceeds :
Tomorrow's exposure is decided with today's close (no look-ahead) and the drawdown is that of the account itself managed with the rule, not that of the strategy without the rule. First on the equal-weighted portfolio of the nine healthy strategies, with double notional so that it has a volatility of 9%:
def regla_caida(x, D=0.20, piso=0.0):
"""Tomorrow's exposure = 1 - today's drawdown / D, between floor and 1.
The drawdown is that of the account itself managed with the rule."""
w = np.empty(len(x)); res = np.empty(len(x))
eq, maximo, expo = 1.0, 1.0, 1.0
for i, ri in enumerate(x.to_numpy()):
w[i] = expo
res[i] = expo * ri
eq *= 1 + res[i]
maximo = max(maximo, eq)
expo = float(np.clip(1 - (1 - eq / maximo) / D, piso, 1.0))
return pd.Series(res, x.index), pd.Series(w, x.index)
def metricas(x):
anos = len(x) / 252
return {"sharpe": x.mean() / x.std() * 252**0.5,
"cagr": (1 + x).prod() ** (1 / anos) - 1,
"max_caida": caidas(x).min(),
"peor_mes": (1 + x).resample("ME").prod().min() - 1}
nueve = list(r.columns[:9])
cartera = 2 * r[nueve].mean(axis=1) # equal-weighted, doubled
filas = {"no rule": metricas(cartera)}
for D in (0.10, 0.15, 0.20):
res, w = regla_caida(cartera, D)
filas[f"D = {D:.0%}"] = metricas(res) | {"expo_media": w.mean()}
pd.DataFrame(filas).T.round(3) sharpe cagr max_caida peor_mes expo_media
no rule 0.841 0.074 -0.274 -0.115 NaN
D = 10% 0.777 0.037 -0.098 -0.064 0.455
D = 15% 0.843 0.050 -0.135 -0.070 0.630
D = 20% 0.854 0.056 -0.161 -0.074 0.723The doubled portfolio has a Sharpe of 0.84, a CAGR of 7.4% and a maximum drawdown of 27.4%. The rule delivers what it promises on the drawdown: −9.8% with , −13.5% with 15% and −16.1% with 20%, below in all three cases (in discrete time it is not guaranteed: a one-day jump can go past the cushion). The price is return: average exposure drops to 46%, 63% and 72% and the CAGR to 3.7%, 5.0% and 5.6%. The Sharpe barely changes (0.78, 0.84 and 0.85): the rule does not improve the strategy, it makes it smaller when it goes badly. On a diversified portfolio that is almost the same as having less notional. Strategy by strategy, the result is less benign:
filas = {}
for c in r.columns:
base = metricas(r[c])
res, w = regla_caida(r[c], 0.20)
con = metricas(res)
filas[c] = {"cagr": base["cagr"], "cagr_regla": con["cagr"],
"caida": base["max_caida"],
"caida_regla": con["max_caida"],
"expo_media": w.mean(),
"expo_2025": w["2025"].mean()}
pd.DataFrame(filas).T.round(3) cagr cagr_regla caida caida_regla expo_media expo_2025
tendencia_futuros 0.053 0.037 -0.356 -0.177 0.491 0.212
carry_futuros 0.026 0.014 -0.275 -0.162 0.613 0.900
carry_divisas 0.045 0.028 -0.352 -0.182 0.564 0.898
venta_puts 0.030 0.006 -0.490 -0.192 0.363 0.275
pares 0.022 0.015 -0.224 -0.144 0.652 0.853
momentum_acciones 0.041 0.011 -0.277 -0.175 0.416 0.462
valor_acciones 0.021 0.005 -0.244 -0.165 0.530 0.711
venta_volatilidad 0.028 -0.010 -0.489 -0.198 0.132 0.041
reversion_corto 0.030 0.020 -0.243 -0.149 0.637 0.758
degradada 0.054 0.059 -0.434 -0.187 0.736 0.077The drawdown stays between 14% and 20% in all of them, but the CAGR falls in nine out of ten. In put selling it goes from 3.0% to 0.6% and in momentum from 4.1% to 1.1%. The worst case is volatility selling: with an average exposure of 13% (4% in 2025), it ends up losing 1.0% a year. This is the drawdown trap: after the 2008 drawdown exposure stays close to zero, the managed account barely moves and never recovers its peak, so exposure never comes back. The figure shows it: the strategy without the rule ends with 1.74 times its capital; with the rule, with 0.82.
The only strategy that improves is degradada (CAGR from 5.4% to 5.9%, drawdown from 43.4% to 18.7%): the rule cuts
it to 8% exposure in 2025 without knowing that it is dead. That is the advantage of a drawdown rule over a kill switch:
it acts without needing a diagnosis. There are two remedies for the trap: an exposure floor or resetting the peak
after some time (exercise 2). With a floor:
x = r["venta_volatilidad"]
filas = {"no rule": metricas(x)}
for piso in (0.0, 0.25, 0.5):
res, w = regla_caida(x, 0.20, piso)
filas[f"floor {piso:.0%}"] = metricas(res) | {
"expo_media": w.mean()}
pd.DataFrame(filas).T.round(3) sharpe cagr max_caida peor_mes expo_media
no rule 0.253 0.028 -0.489 -0.244 NaN
floor 0% -0.266 -0.010 -0.198 -0.088 0.132
floor 25% 0.004 -0.002 -0.240 -0.196 0.329
floor 50% 0.194 0.014 -0.310 -0.203 0.557A 25% floor brings back a Sharpe of 0.00 with a drawdown of 24.0%; a 50% floor, a Sharpe of 0.19 and a drawdown of 31.0%, still below the 0.25 of the strategy without the rule. Each point of floor buys return with drawdown: there is no value that gives both, and choosing it with this same data would be fitting the rule to the sample (lesson 2.5.1). The rule also turns over the position every day; with costs (lesson 2.4.7) the result gets somewhat worse.
Circuit breakers: pausing after a bad day
A circuit breaker does not look at the streak but at the day: if the loss exceeds a threshold, the strategy stops for a few sessions. Exchanges use them for the whole market; a system uses them for itself. The lesson's version measures the threshold in units of recent volatility (EWMA with , computed with the previous day's data): a loss of more than 3 sigmas stops the strategy for 5 sessions. With daily data the breaker can only act after the bad day: what it avoids are the following days.
def breaker(x, k=3.0, pausa=5, lam=0.94):
"""If the day's loss exceeds k times the previous day's EWMA
volatility, the strategy does not trade the next `pausa` sessions."""
sig = np.sqrt(x.pow(2).ewm(alpha=1 - lam).mean().shift(1))
sig = sig.bfill().to_numpy()
xv = x.to_numpy(); res = np.zeros(len(x)); w = np.zeros(len(x))
parada, disparos = 0, []
for i in range(len(x)):
if parada == 0:
w[i] = 1.0
res[i] = xv[i]
if xv[i] < -k * sig[i]:
parada = pausa
disparos.append(x.index[i])
else:
parada -= 1
return pd.Series(res, x.index), pd.Series(w, x.index), disparos
filas = {}
for c in r.columns:
res, w, disp = breaker(r[c])
a, b = metricas(r[c]), metricas(res)
filas[c] = {"disparos": len(disp), "sharpe": a["sharpe"],
"sharpe_breaker": b["sharpe"],
"caida": a["max_caida"],
"caida_breaker": b["max_caida"]}
pd.DataFrame.from_dict(filas, orient="index").round(3) disparos sharpe sharpe_breaker caida caida_breaker
tendencia_futuros 32 0.533 0.519 -0.356 -0.359
carry_futuros 34 0.381 0.365 -0.275 -0.255
carry_divisas 36 0.529 0.576 -0.352 -0.314
venta_puts 31 0.305 0.402 -0.490 -0.497
pares 37 0.401 0.537 -0.224 -0.166
momentum_acciones 36 0.388 0.389 -0.277 -0.293
valor_acciones 41 0.272 0.226 -0.244 -0.251
venta_volatilidad 38 0.253 0.351 -0.489 -0.477
reversion_corto 36 0.453 0.407 -0.243 -0.246
degradada 35 0.697 0.679 -0.434 -0.410The breaker trips between 31 and 41 times in 20 years per strategy, about twice a year: a 3-sigma day is not rare with fat tails. The effect is small and of varying sign. It improves the Sharpe in strategies with clustered losses and chained events (put selling from 0.31 to 0.40, pairs from 0.40 to 0.54, volatility selling from 0.25 to 0.35) and worsens it in those that rebound after a bad day (reversion from 0.45 to 0.41, value from 0.27 to 0.23). The maximum drawdown barely changes except in pairs (from 22.4% to 16.6%), because big drawdowns are slow and a 5-session breaker does not see them. The episode that explains it best is March 2020 in volatility selling:
x = r["venta_volatilidad"]
res, w, disp = breaker(x)
tramo = slice("2020-02-20", "2020-04-30")
print([d.date().isoformat() for d in disp if d.year == 2020])
print(f"Without breaker: {(1 + x[tramo]).prod() - 1:.1%}")
print(f"With breaker: {(1 + res[tramo]).prod() - 1:.1%}")
dias_fuera = x[tramo][w[tramo] == 0]
print(f"Days out: {len(dias_fuera)}, what they did:"
f" {(1 + dias_fuera).prod() - 1:.1%}")['2020-02-24', '2020-03-09', '2020-07-29']
Without breaker: -35.7%
With breaker: -17.5%
Days out: 10, what they did: -22.0%In March 2020 the volatility-selling breaker trips on February 24 and March 9. It spends 10 sessions out, which would have lost 22.0%, and leaves the loss for the period at 17.5% against 35.7%. It is its best case: two bad days that announce worse ones. In markets that rebound after the panic, the same breaker misses the rally, and that is why it makes reversion and value worse. A daily breaker does not replace correct sizing; it reduces the damage of episodes that chain together.
Statistical kill switch: has the strategy died?
The previous rules protect capital; they do not say whether the strategy still has an edge. That takes a sequential test: every day a new return arrives and the question is whether the mean has dropped. Page's CUSUM (1954) accumulates the evidence of a drop and forgets it when returns are good again. With returns in units of their volatility, , an expected mean (the backtest's) and an alternative mean (the dead strategy):
While the strategy is alive, has mean and returns to zero again and again; when it dies, the mean increment becomes and grows until it crosses . Everything depends on how small is: with Sharpe 1, standard deviations per day. The threshold sets the trade-off between false alarms and delay, and that trade-off is measured by simulation.
The simulation uses 2,000 paths of 30 years of a Sharpe 1 strategy with Student's t innovations: in some the mean stays the same (to count false alarms) and in others it drops to zero on the first day (to measure the delay).
def cusum(z, k):
"""Page's CUSUM to detect a drop in the mean of z.
z: matrix (sessions, paths) in units of sigma."""
c = np.zeros(z.shape[1]); out = np.empty_like(z)
for t in range(z.shape[0]):
c = np.maximum(0.0, c + k - z[t])
out[t] = c
return out
def primera_alarma(c, h):
hit = c > h
return np.where(hit.any(axis=0), hit.argmax(axis=0), c.shape[0])
rng = np.random.default_rng(11)
S0, T, n = 1.0, 252 * 30, 2000
mu0 = S0 / np.sqrt(252)
e = rng.standard_t(5, (T, n)) / np.sqrt(5 / 3)
viva = cusum(mu0 + e, mu0 / 2) # the mean stays at mu0
muerta = cusum(e, mu0 / 2) # the mean drops to zero at t = 0
filas = {}
for h in (10, 20, 30, 40, 50, 60):
fa, det = primera_alarma(viva, h), primera_alarma(muerta, h)
filas[h] = {"P(falsa alarma en 10 años)": (fa < 2520).mean(),
"retraso_mediano_anos": np.median(det) / 252,
"retraso_p90_anos": np.quantile(det, 0.9) / 252}
compromiso = pd.DataFrame(filas).T
compromiso.round(2) P(falsa alarma en 10 años) retraso_mediano_anos retraso_p90_anos
10 1.00 0.31 0.84
20 0.98 0.95 2.47
30 0.69 1.80 4.40
40 0.37 2.81 6.47
50 0.18 3.88 8.46
60 0.09 5.02 10.89The simulation table is the core of the lesson and is in the figure below. With a false alarm in 10 years is certain and detection comes in 0.3 years in the median; with , the false alarm drops to 18% and detection takes 3.9 years in the median (8.5 at the 90th percentile); with , 9% and 5 years. A threshold with few false alarms needs years to see a dead strategy, and the fault is not the CUSUM's, which has the smallest worst-case delay for a given mean time between false alarms when the size of the change is known1Note 1 · G. Lorden, “Procedures for reacting to a change in distribution”, Annals of Mathematical Statistics 42 (1971), 1897-1908, DOI 10.1214/aoms/1177693055, proved asymptotic optimality; G. V. Moustakides, “Optimal stopping times for detecting changes in distributions”, Annals of Statistics 14 (1986), 1379-1387, DOI 10.1214/aos/1176350164, exact optimality under the same criterion. The lesson does not prove it. What it does measure is that with fat tails and volatility clustering, as in the data, false alarms are more frequent than in the simulation.: it is that the signal is small.
And with another Sharpe? For Sharpe 0.5, 1 and 2, find the lowest threshold that keeps false alarms in 10 years at 20% at most, and measure the delay with that threshold (with 1,000 of the paths, to keep the cell short):
filas = {}
for S0 in (0.5, 1.0, 2.0):
mu0 = S0 / np.sqrt(252)
hs = np.arange(5, 400, 5)
viva = cusum(mu0 + e[:, :1000], mu0 / 2)
muerta = cusum(e[:, :1000], mu0 / 2)
# the lowest threshold with P(false alarm in 10 years) <= 20%
h = next(h for h in hs
if (primera_alarma(viva, h) < 2520).mean() <= 0.20)
det = primera_alarma(muerta, h)
filas[S0] = {"h": h, "retraso_mediano_anos": np.median(det) / 252,
"sin_detectar_en_30": (det == T).mean()}
pd.DataFrame(filas).T.round(2) h retraso_mediano_anos sin_detectar_en_30
0.5 65.0 7.96 0.02
1.0 50.0 3.89 0.00
2.0 35.0 1.56 0.00The Sharpe rules. With the same tolerance (at most 20% false alarms in 10 years), a Sharpe 2 strategy is detected in 1.6 years in the median, a Sharpe 1 one in 3.9 and a Sharpe 0.5 one in 8, and 2% of the Sharpe 0.5 ones are not detected in 30 years. For the typical strategies of this level, with Sharpe between 0.3 and 0.5, a performance-based kill switch is not a tool: there is not enough sample in the useful life of the strategy.
Now, the data. Suppose degradada was researched on 2006-2015 and started trading on January 4, 2016. It dies on
July 1, 2019 without anyone knowing:
eq = (1 + r["degradada"]).cumprod()
CORTE = pd.Timestamp("2019-07-01")
cima = eq[CORTE:].idxmax()
print(f"From the break to its peak ({cima.date()}): "
f"{eq[cima] / eq[:CORTE].iloc[-2] - 1:+.1%}")
print(f"From the peak to the end of 2025: {eq.iloc[-1] / eq[cima] - 1:+.1%}")
media_verdad = verdad.loc["degradada", "media_diaria_desde_corte"]
print(f"Annual mean after the break: "
f"{r.loc[CORTE:, 'degradada'].mean() * 252:+.1%}"
f" (truth: {media_verdad})")From the break to its peak (2021-06-08): +26.4%
From the peak to the end of 2025: -38.6%
Annual mean after the break: -3.5% (truth: 0.0)From the day of its death to June 2021 it gains 26.4%: two years of luck with a zero mean. Then it loses 38.6%. The mean of the whole dead stretch is −3.5% a year, below the truth (zero). No rule can tell the first two years apart from a live strategy, because there is nothing to tell apart. Four rules are compared from 2016: the CUSUM with calibrated with the true Sharpe and volatility of each strategy (live you would use the backtest's: exercise 1), a drawdown of more than 1.5 times the backtest's, a negative 12-month Sharpe and a negative 36-month Sharpe:
VIVO = "2016-01-04"
def reglas(c, h=40):
x = r[c]
vivo = x[VIVO:]
S0 = verdad.loc[c, "sharpe_objetivo"]
sd = verdad.loc[c, "vol_objetivo"] / np.sqrt(252)
k = S0 / np.sqrt(252) / 2
cs = pd.Series(cusum((vivo / sd).to_numpy()[:, None], k)[:, 0],
vivo.index)
dd_bt = caidas(x[:"2015-12-31"]).min()
s12 = (x.rolling(252).mean() / x.rolling(252).std())[VIVO:]
s36 = (x.rolling(756).mean() / x.rolling(756).std())[VIVO:]
alarmas = {f"cusum_h{h}": cs > h,
"caida>1,5xbt": caidas(vivo) < 1.5 * dd_bt,
"sharpe12m<0": s12 < 0, "sharpe36m<0": s36 < 0}
return {k: (a.idxmax().date() if a.any() else None)
for k, a in alarmas.items()}
alarmas = pd.DataFrame({c: reglas(c) for c in r.columns}).T
alarmas cusum_h40 caida>1,5xbt sharpe12m<0 sharpe36m<0
tendencia_futuros 2018-09-28 None 2017-04-27 2016-02-24
carry_futuros None None 2016-06-03 2018-06-05
carry_divisas None None 2016-01-04 None
venta_puts 2020-03-16 None 2016-01-04 2021-09-01
pares None None 2017-01-25 2016-01-25
momentum_acciones 2022-05-27 None 2016-08-10 2019-02-01
valor_acciones None None 2016-01-04 2017-07-18
venta_volatilidad 2020-03-13 None 2016-04-04 2020-03-23
reversion_corto None None 2017-05-04 None
degradada 2022-10-03 2022-10-24 2022-01-11 2022-10-11The four rules detect degradada in 2022: the 12-month Sharpe on January 11, the CUSUM on October 3, the 36-month
Sharpe on October 11 and the drawdown of 1.5 times the backtest's on October 24. Between two and a half years and
three years and four months after its death, as the simulation predicted. Their false alarms are very different:
- Negative 12-month Sharpe: fires in all nine healthy strategies, most of them in 2016. A Sharpe 0.4 strategy has a negative year with a probability of one third: the rule is noise.
- Negative 36-month Sharpe: fires in seven of nine.
- CUSUM with h = 40: fires in four of nine: trend (September 2018), volatility selling (March 13, 2020), put selling (March 16, 2020) and momentum (May 2022).
- Drawdown of 1.5 times the backtest's: does not fire in any healthy strategy, because their backtests include 2008 and their drawdowns were already huge. For the same reason, it only really watches a strategy with a calm backtest.
If the kill switch is final, its cost is measured with the cumulative return of 2016-2025 with and without it:
def apagada(c, fecha):
x = r[c][VIVO:]
return x if fecha is None else x.where(x.index <= str(fecha), 0.0)
filas = {}
for c in r.columns:
f = alarmas.loc[c, "cusum_h40"]
base, con = r[c][VIVO:], apagada(c, f)
filas[c] = {"alarma": f, "total_2016_25": (1 + base).prod() - 1,
"con_kill_switch": (1 + con).prod() - 1}
coste = pd.DataFrame.from_dict(filas, orient="index")
coste["diferencia"] = coste["con_kill_switch"] - coste["total_2016_25"]
print(coste.round(3))
print(f"Sum of differences, nine healthy: "
f"{coste['diferencia'].iloc[:9].sum():+.3f}") alarma total_2016_25 con_kill_switch diferencia
tendencia_futuros 2018-09-28 0.293 -0.082 -0.375
carry_futuros None 0.403 0.403 0.000
carry_divisas None 1.348 1.348 0.000
venta_puts 2020-03-16 0.816 0.460 -0.356
pares None 0.564 0.564 0.000
momentum_acciones 2022-05-27 0.352 -0.148 -0.501
valor_acciones None 0.285 0.285 0.000
venta_volatilidad 2020-03-13 0.901 0.440 -0.462
reversion_corto None 0.770 0.770 0.000
degradada 2022-10-03 0.199 0.673 0.474
Sum of differences, nine healthy: -1.694The kill switch saves 47 points of cumulative return in degradada (from 19.9% to 67.3%) and costs 169 points in
the healthy ones: trend goes from +29.3% to −8.2%, momentum from +35.2% to −14.8%, put selling from +81.6% to +46.0%
and volatility selling from +90.1% to +44.0%. The two premium sellers are switched off at the bottom of March 2020:
the kill switch realizes the loss and gives up the rebound. With one dead strategy out of ten, the performance kill
switch loses money; with more dead strategies in the portfolio the account would change, and in reality the
proportion is unknown.
What comes out of this is not “don't use a kill switch”, but three rules of use. A performance kill switch only makes sense for high-Sharpe strategies, where detection fits within their useful life. For the rest, the defense is sizing (lesson 4.2.2), drawdown-based exposure and monitoring the strategy's mechanism (the IC of the signal, slippage, the funding a carry collects), which changes before the P&L does. And an alarm should not switch off, but put the strategy under review with less size: the decision to retire it is human.
The operational kill switch
On August 1, 2012, Knight Capital's routing system processed 212 small client orders with old code that had been left active on one of its eight servers. In about 45 minutes it sent millions of child orders, obtained more than 4 million executions in 154 stocks for more than 397 million shares and ended up with a long position of about 3.5 billion dollars and a short one of about 3.15 billion. It lost more than 460 million2Note 2 · SEC, Release No. 70694, of October 16, 2013, paragraphs 1 and 15-17. The SEC sanctioned Knight for not having reasonable controls and procedures to limit the risks of its market access.. The server kept sending child orders without taking into account the executions it had already received. No statistical rule would have helped: it took a control that compared what was executed with what was requested and stopped it within seconds.
In the EU, Delegated Regulation (EU) 2017/589 (the MiFID II regulatory technical standards on algorithmic trading) requires investment firms engaged in algorithmic trading to be able to cancel immediately, as an emergency measure, any or all of their unexecuted orders (Article 12, “kill functionality”); to have pre-trade controls on price collars, maximum order value and volume and message limits (Article 15(1)); and that, after a predetermined number of repeated executions, the trading system be disabled until a designated staff member re-enables it (Article 15(3)). A retail trader is not subject to that regulation, but the design is the one worth copying. As a state machine:
Mermaid source
stateDiagram-v2 [*] --> ACTIVE ACTIVE --> TRIPPED: limit breached TRIPPED --> REVIEW: orders canceled REVIEW --> ACTIVE: manual re-arm REVIEW --> [*]: retired
Three design decisions. The time is injected: the kill switch does not read the system clock, it receives the time with each event, so its tests are deterministic and can be replayed with the messages of a real day. The limits are independent of the strategy: they do not ask whether the signal is good, only whether what is happening is possible. And re-arming is manual: what trips the control is usually something nobody understands yet.
from dataclasses import dataclass, field
@dataclass
class Limites:
perdida_diaria: float = 0.03 # fraction of equity
ordenes_por_minuto: int = 30
datos_viejos_s: float = 10.0
exposicion_bruta: float = 1.5 # times equity
@dataclass
class KillSwitch:
patrimonio_inicio_dia: float
lim: Limites = field(default_factory=Limites)
estado: str = "ACTIVE"
motivo: str = ""
envios: list = field(default_factory=list)
def _disparar(self, motivo):
if self.estado == "ACTIVE":
self.estado, self.motivo = "TRIPPED", motivo
def revisar(self, ahora, patrimonio, exposicion, ultimo_dato,
pedido, ejecutado):
"""Checks every limit with the time it is given."""
perdida = 1 - patrimonio / self.patrimonio_inicio_dia
if perdida > self.lim.perdida_diaria:
self._disparar(f"daily loss {perdida:.1%}")
if exposicion / patrimonio > self.lim.exposicion_bruta:
self._disparar("gross exposure")
if ahora - ultimo_dato > self.lim.datos_viejos_s:
self._disparar("stale data")
for orden, q in ejecutado.items():
if q > pedido.get(orden, 0):
self._disparar(f"{orden}: executed {q} > requested")
return self.estado
def enviar(self, ahora, orden):
if self.estado != "ACTIVE":
raise RuntimeError(f"blocked ({self.motivo})")
self.envios = [t for t in self.envios if ahora - t < 60]
if len(self.envios) >= self.lim.ordenes_por_minuto:
self._disparar("too many orders per minute")
raise RuntimeError(f"blocked ({self.motivo})")
self.envios.append(ahora)
return orden
def rearmar(self, quien):
if self.estado == "TRIPPED" and quien:
self.estado, self.motivo = "ACTIVE", ""
return self.estadorevisar is called with every event (quote, execution, change in equity) and enviar before every order. The tests
do not use the system clock: the time is a number of seconds of a logical clock that the script advances.
ks = KillSwitch(patrimonio_inicio_dia=100_000)
t = 1_000.0 # seconds of a logical clock, not the system's
print(ks.revisar(t, 99_000, 120_000, t - 1, {"A1": 100},
{"A1": 100}))
print(ks.revisar(t + 5, 99_000, 120_000, t + 4, {"A1": 100},
{"A1": 100, "A2": 300}), "-", ks.motivo)
print(ks.rearmar("operator"))
for i in range(30):
ks.enviar(t + 10 + i, f"O{i}")
try:
ks.enviar(t + 45, "O30")
except RuntimeError as err:
print(err)
print(ks.rearmar("operator"))
print(ks.revisar(t + 200, 96_500, 50_000, t + 199, {}, {}),
"-", ks.motivo)ACTIVE
TRIPPED - A2: executed 300 > requested
ACTIVE
blocked (too many orders per minute)
ACTIVE
TRIPPED - daily loss 3.5%First, everything in order. Then an execution arrives for an order A2 that the system never requested: it is the
Knight case, and the control trips. Re-armed by hand, the system sends 30 orders in 30 seconds and the 31st, six
seconds later, exceeds the orders-per-minute limit. Re-armed again, equity drops 3.5% in the day and it trips again.
While it is tripped, any send raises an exception:
ks.enviar(t + 300, "O31")RuntimeError: blocked (daily loss 3.5%)In a real system (lessons 4.5.1 and 4.5.5) this object lives in a process separate from the strategy's, receives the broker's messages directly and, when it trips, sends the cancellation of all open orders. If it lives in the same process, a failure of the strategy can take it down with it.
Common mistakes
- The backtest drawdown as a limit. It is a sample; a healthy strategy often exceeds it.
- The backtest Sharpe as the null hypothesis. The backtest is usually inflated by selection (lesson 2.5.4): a CUSUM calibrated with it fires on strategies that work as they truly worked.
- Cutting without a way back. A drawdown rule without a floor or a reset leaves the account in the trap.
- Look-ahead in the control. A breaker applied on the same day that trips it, or a drawdown computed with the close of the day being traded, gives a backtest the real system cannot reproduce.
- Performance kill switch on tail strategies. Premium sellers lose in crises by design: a performance control switches them off at the bottom.
- Automatic re-arming and the control in the same process. The control has to survive the failure it watches and not undo itself.
- Testing with the system clock. The tests of a timing control have to give the same result on a loaded machine;
with
time.time()inside, they do not.
Exercises
- Calibrate the CUSUM () with the Sharpe and volatility of each strategy's 2006-2015 backtest, instead of the true ones, and compare the alarms from 2016 with those of the lesson. What happens with the strategies whose backtest came out worse than their truth?
Solution
def alarma_bt(c, h=40):
bt, vivo = r[c][:"2015-12-31"], r[c][VIVO:]
k = bt.mean() / bt.std() / 2
cs = cusum((vivo / bt.std()).to_numpy()[:, None], k)[:, 0]
fila = {"sharpe_bt": bt.mean() / bt.std() * 252**0.5,
"sharpe_verdad": verdad.loc[c, "sharpe_objetivo"]}
hit = np.flatnonzero(cs > h)
fila["alarma_bt"] = vivo.index[hit[0]].date() if len(hit) else None
fila["alarma_verdad"] = alarmas.loc[c, "cusum_h40"]
return fila
pd.DataFrame.from_dict({c: alarma_bt(c) for c in r.columns},
orient="index").round(2) sharpe_bt sharpe_verdad alarma_bt alarma_verdad
tendencia_futuros 0.76 0.45 2018-09-06 2018-09-28
carry_futuros 0.29 0.40 None None
carry_divisas 0.07 0.35 None None
venta_puts 0.05 0.40 2020-03-18 2020-03-16
pares 0.02 0.50 None None
momentum_acciones 0.50 0.35 2022-03-30 2022-05-27
valor_acciones 0.22 0.25 None None
venta_volatilidad 0.02 0.50 2020-03-16 2020-03-13
reversion_corto 0.06 0.40 None None
degradada 1.14 1.00 2022-09-23 2022-10-03The alarms barely change date, but for reasons worth seeing. The 2007-2009 crises are inside the backtest, so six of
the nine healthy strategies have a backtest Sharpe well below the true one (put selling, 0.05 against 0.40; pairs,
0.02 against 0.50). With almost zero, the mean increment of the CUSUM of a live strategy is , more negative
than with the truth: the control becomes more tolerant, and with a dead strategy (zero mean) the mean increment would
be zero, so it would only fire by chance. A pessimistic backtest leaves the kill switch almost blind. With an
optimistic backtest the opposite happens: trend (0.76 against 0.45) and momentum (0.50 against 0.35) fire earlier
(September 6, 2018 and March 30, 2022), and degradada (1.14 against 1), ten days earlier. The kill switch parameter
that matters most is the one that is estimated worst.
- Modify
regla_caidaso that, if exposure has been below 25% for 126 sessions, the peak is reset to that day's capital (the loss is accepted and it starts again). Compare it with the rule without reset in volatility selling and indegradada.
Solution
def regla_reinicio(x, D=0.20, umbral=0.25, sesiones=126):
w = np.empty(len(x)); res = np.empty(len(x))
eq, maximo, expo, bajas = 1.0, 1.0, 1.0, 0
for i, ri in enumerate(x.to_numpy()):
w[i] = expo
res[i] = expo * ri
eq *= 1 + res[i]
maximo = max(maximo, eq)
bajas = bajas + 1 if expo < umbral else 0
if bajas >= sesiones: # the loss is accepted
maximo, bajas = eq, 0
expo = float(np.clip(1 - (1 - eq / maximo) / D, 0.0, 1.0))
return pd.Series(res, x.index), pd.Series(w, x.index)
filas = {}
for c in ("venta_volatilidad", "degradada"):
for nombre, f in (("no reset", regla_caida),
("with reset", regla_reinicio)):
res, w = f(r[c])
filas[(c, nombre)] = metricas(res) | {"expo_media": w.mean()}
pd.DataFrame(filas).T.round(3) sharpe cagr max_caida peor_mes expo_media
venta_volatilidad no reset -0.266 -0.010 -0.198 -0.088 0.132
with reset 0.278 0.025 -0.270 -0.190 0.597
degradada no reset 0.974 0.059 -0.187 -0.082 0.736
with reset 0.887 0.055 -0.264 -0.082 0.792The reset gets volatility selling out of the trap: average exposure rises from 13% to 60% and the Sharpe from −0.27
to 0.28 (slightly better than the 0.25 without the rule), at the price of a drawdown of 27.0%, larger than ,
because each reset starts counting from a lower capital. In degradada, which was already dead, the reset gives back
exposure at the worst moment: the drawdown goes from 18.7% to 26.4% and the Sharpe from 0.97 to 0.89. Neither version
is better in both: the drawdown rule protects against streaks, it does not tell a streak from a death.
- Design the limits of the operational kill switch for a 50,000 EUR account that trades three daily strategies with market-on-close orders (about 10 orders a day). Which limits would you set and what would the system do when it trips?
Solution
A reasonable proposal, to be adjusted with the data of each system: a daily loss of 3% of equity (about 3 times the daily volatility of a portfolio with 15% annual volatility, a day that should happen very few times a year); gross exposure of 1.5 times equity if the strategies do not use more; a maximum of 30 orders per minute and 50 per day (three times the normal, which for such a system is already an anomaly); data older than 60 seconds in the sending window; executed greater than requested in any order; and differences between your own position and the broker's (lesson 4.5.5). When it trips: cancel all open orders, send no more, alert through a channel that does not depend on the system itself (lesson 4.5.4) and wait for the manual re-arm. Close positions only if the trip is due to exposure or to executions that were not requested; if it is due to stale data, closing blindly can be worse than waiting.
Footnotes
-
G. Lorden, “Procedures for reacting to a change in distribution”, Annals of Mathematical Statistics 42 (1971), 1897-1908, DOI 10.1214/aoms/1177693055, proved asymptotic optimality; G. V. Moustakides, “Optimal stopping times for detecting changes in distributions”, Annals of Statistics 14 (1986), 1379-1387, DOI 10.1214/aos/1176350164, exact optimality under the same criterion. The lesson does not prove it. What it does measure is that with fat tails and volatility clustering, as in the data, false alarms are more frequent than in the simulation. ↩
-
SEC, Release No. 70694, of October 16, 2013, paragraphs 1 and 15-17. The SEC sanctioned Knight for not having reasonable controls and procedures to limit the risks of its market access. ↩
Each answer is folded under its question
Self-assessment
The backtest of a strategy (2006-2015) has a maximum drawdown of 16.4%. Live, eight years later, it falls 18%. What does that indicate on its own?
- AThat the strategy has stopped working: it has exceeded its worst drawdown
- BLittle: with its Sharpe and its volatility, a healthy strategy exceeds that drawdown in 10 years one time in five
- CThat the backtest had a code bug
- DThat exposure must be doubled to recover
Show answer
Answer B. Little: with its Sharpe and its volatility, a healthy strategy exceeds that drawdown in 10 years one time in five
The backtest drawdown is one sample from a distribution. In the lesson's simulation, a healthy strategy with Sharpe 1 and 8% volatility falls more than 16.4% in 10 years in 20% of the paths, and the median of its worst drawdown in 10 years is 12.7%.
A rule cuts exposure to 1 − drawdown / 20%. What is its main risk?
- AThat after a deep drawdown exposure stays close to zero and the account can no longer recover: in volatility selling, an average exposure of 13% and a negative Sharpe
- BThat it does not limit the drawdown
- CThat it increases volatility
- DThat it needs intraday data
Show answer
Answer A. That after a deep drawdown exposure stays close to zero and the account can no longer recover: in volatility selling, an average exposure of 13% and a negative Sharpe
Exposure is proportional to the cushion left until the tolerated drawdown. If the account stays near the floor, it trades so little that only a very long winning streak gets it out. An exposure floor or a reset of the peak avoids it, at the price of allowing larger drawdowns.
A CUSUM with h = 40 monitors a Sharpe 1 strategy. According to the simulation, what can be expected?
- ADetecting the death of the strategy in a few weeks with no false alarms
- BNo false alarms ever
- CA 37% probability of a false alarm in 10 years and a detection that takes about 2.8 years in the median
- DImmediate detection if the strategy loses one day
Show answer
Answer C. A 37% probability of a false alarm in 10 years and a detection that takes about 2.8 years in the median
It is the trade-off in the figure: the threshold decides how many false alarms are accepted and how long detection takes. With Sharpe 1 the difference between a live strategy and a dead one is 0.06 standard deviations per day: it takes years to see it.
In the data, the CUSUM with h = 40 switched off four of the nine healthy strategies between 2016 and 2025. Why two of them in March 2020?
- ABecause they stopped working in 2020
- BBecause of an error in the data
- CBecause the CUSUM only works with weekly data
- DBecause premium-selling strategies lose a lot in crises: the CUSUM accumulates those losses and fires right at the bottom, before the recovery
Show answer
Answer D. Because premium-selling strategies lose a lot in crises: the CUSUM accumulates those losses and fires right at the bottom, before the recovery
Put selling and volatility selling have a fat left tail by construction. A performance kill switch does not distinguish an expected crisis from a broken strategy, and switching off at the bottom realizes the loss and gives up the rebound: in the data, 36 and 46 points of cumulative return.
What should an operational kill switch do when it trips, according to the lesson and Delegated Regulation (EU) 2017/589?
- AWait a minute and re-enable itself
- BDouble the position to compensate
- CCancel unexecuted orders and block sending until a designated person re-enables it
- DSend an email and keep trading
Show answer
Answer C. Cancel unexecuted orders and block sending until a designated person re-enables it
Article 12 requires being able to cancel unexecuted orders immediately (“kill functionality”) and Article 15(3), that the system be disabled after a number of repeated executions until a designated staff member re-enables it. Automatic re-arming undoes the control.
Key points
- Loss control has four levels with different horizons: the operational kill switch (seconds, system errors), the circuit breaker (days), drawdown-based exposure (weeks) and the statistical kill switch (months or years, the edge that disappears).
- The backtest maximum drawdown is a sample, not the worst case: a healthy Sharpe 1 strategy exceeds it within 10 years in one path out of five.
- Cutting exposure with drawdown limits the drawdown at the cost of return, and it has a trap: after a deep drawdown exposure does not recover. In volatility selling, the drawdown goes from 48.9% to 19.8% and the Sharpe from 0.25 to −0.27.
- Detecting that a strategy has died is slow: with Sharpe 1 and 37% false alarms in 10 years, the CUSUM takes 2.8 years in the median; with Sharpe 0.5, about 8. The dead strategy in the data gained 26% after dying and the four rules detected it in 2022.
- A performance kill switch has a cost in healthy strategies: in the data it switched off four of nine, two of them at the bottom of March 2020, and the sum of what was lost (−169 points) exceeds what it saved in the dead one (+47).
- The operational kill switch is independent software, with an injected clock, limits on loss, exposure, orders per minute, stale data and executed quantity, and manual re-arming.