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.

Fig. — The «degradada» strategy since 2016: it stops working on 1 July 2019, keeps gaining for two years by chance and the kill switch rules only detect it in 2022
Level 4 lessons
  1. 4.1 · Portfolio theory and optimisation

    1. 4.1.1 Markowitz: efficient frontier and real constraints
    2. 4.1.2 Covariance estimation: shrinkage, factor models and denoising
    3. 4.1.3 Black-Litterman: from market equilibrium to views
    4. 4.1.4 Risk parity, minimum variance, HRP and HERC
    5. 4.1.5 Optimization with CVaR, drawdown, turnover and costs
    6. 4.1.6 Factor models: CAPM, Fama-French, PCA and attribution
    7. 4.1.7 Rebalancing and strategy portfolios
    8. 4.1.8 Portfolio tools: Riskfolio-Lib, PyPortfolioOpt, skfolio and bt
  2. 4.2 · Risk management and position sizing

    1. 4.2.1 Risk per trade, stops and risk of ruin
    2. 4.2.2 Kelly criterion and volatility targeting: how much to risk
    3. 4.2.3 VaR, Expected Shortfall and VaR backtesting
    4. 4.2.4 Stress testing and correlation, liquidity, gap and counterparty risk
    5. 4.2.5 Drawdown control and kill switch
    6. 4.2.6 Derivatives risk: portfolio Greeks, margin and liquidations
  3. 4.3 · Microstructure and execution

    1. 4.3.1 Implementation shortfall, VWAP, TWAP and Almgren-Chriss
    2. 4.3.2 Order flow imbalance, microstructure signals and latency
  4. 4.4 · Brokers and APIs

    1. 4.4.1 Choosing a broker from Spain or the EU: regulation, assets, costs and API
    2. 4.4.2 Interactive Brokers: TWS and IB Gateway, ib_async, contracts, data and orders
    3. 4.4.3 Alpaca, Tradier, MetaTrader 5 with Python and OANDA v20
    4. 4.4.4 Crypto exchange APIs: ccxt, Binance, Bybit, Kraken and Coinbase
    5. 4.4.5 Secure credentials: environment variables, secret managers and least privilege
  5. 4.5 · Live trading system

    1. 4.5.1 Live system architecture: from data to reconciliation
    2. 4.5.2 Order management: states, partial fills, cancellations and reconnections
    3. 4.5.3 Paper trading, shadow trading and slippage monitoring
    4. 4.5.4 Task scheduling and alerts: cron, APScheduler, Prefect, Telegram, Slack and email
    5. 4.5.5 Fault tolerance: idempotency, persistent state and reconciliation at startup
    6. 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:

DefenseHorizonWhat it detectsHow it gets it wrong
Operational kill switchSecondsOrders that should not exist, stale data, losses out of scaleStops the system because of a bad data point from the broker
Circuit breakerDaysA day with an abnormal lossMisses the next day's rebound
Drawdown-based exposureWeeks to monthsA losing streakTrades little right when the strategy recovers
Statistical kill switchMonths to yearsAn edge that has disappearedSwitches 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:

noyesactivetrippedSignalScale by drawdownBreaker paused?Kill switchNo tradingOrder to broker
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 --> N

The 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:

IN [01]python
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)
OUT [01]Ten strategies and their drawdowns
                   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.55

The 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:

IN [02]python
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)
OUT [02]The drawdown of a healthy strategy: simulation
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.200

In 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 DD, in the spirit of Grossman and Zhou (1993), who proved that investing in the risky asset an amount proportional to Wt−(1−D) MtW_t - (1 - D)\,M_t, with MtM_t the capital's running peak, guarantees in continuous time that the drawdown never exceeds DD:

et+1=min⁡(1, max⁡(floor, 1−DDtD)),DDt=1−WtMte_{t+1} = \min\left(1,\ \max\left(\text{floor},\ 1 - \frac{DD_t}{D}\right)\right), \qquad DD_t = 1 - \frac{W_t}{M_t}

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%:

IN [03]python
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)
OUT [03]Exposure based on drawdown
         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.723

The 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 D=10%D = 10\%, −13.5% with 15% and −16.1% with 20%, below DD 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:

IN [04]python
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)
OUT [04]The same rule, strategy by strategy
                    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.077

The 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.

Fig. — Volatility selling with and without a rule that cuts exposure with the drawdown (D = 20%): the rule caps the fall, but exposure never recovers

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:

IN [05]python
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)
OUT [05]An exposure floor
           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.557

A 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 λ=0.94\lambda = 0.94, 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.

IN [06]python
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)
OUT [06]Circuit breaker: pause after a 3-sigma loss
                   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.410

The 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:

IN [07]python
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%}")
OUT [07]March 2020 with and without the breaker
['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, zt=rt/σz_t = r_t / \sigma, an expected mean μ0\mu_0 (the backtest's) and an alternative mean μ1=0\mu_1 = 0 (the dead strategy):

Ct=max⁡(0, Ct−1+k−zt),k=μ0+μ12=μ02,alarm if Ct>hC_t = \max\left(0,\ C_{t-1} + k - z_t\right), \qquad k = \frac{\mu_0 + \mu_1}{2} = \frac{\mu_0}{2}, \qquad \text{alarm if } C_t > h

While the strategy is alive, ztz_t has mean μ0>k\mu_0 > k and CtC_t returns to zero again and again; when it dies, the mean increment becomes +k+k and CtC_t grows until it crosses hh. Everything depends on how small μ0\mu_0 is: with Sharpe 1, μ0=1/252=0.063\mu_0 = 1/\sqrt{252} = 0.063 standard deviations per day. The threshold hh 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).

IN [08]python
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)
OUT [08]CUSUM: delay against false alarms
    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.89

The simulation table is the core of the lesson and is in the figure below. With h=10h = 10 a false alarm in 10 years is certain and detection comes in 0.3 years in the median; with h=50h = 50, the false alarm drops to 18% and detection takes 3.9 years in the median (8.5 at the 90th percentile); with h=60h = 60, 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.

Fig. — CUSUM for a Sharpe 1 strategy that drops to Sharpe 0: raising the threshold h cuts false alarms and lengthens detection. No threshold gives both

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):

IN [09]python
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)
OUT [09]Less Sharpe, more delay
        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.00

The 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:

IN [10]python
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})")
OUT [10]What degradada did after dying
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 h=40h = 40 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:

IN [11]python
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
OUT [11]Four rules on the data, from 2016
                    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-11

The 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:

IN [12]python
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}")
OUT [12]What the kill switch costs
                       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.694

The 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:

limit breachedorders canceledmanual re-armretiredACTIVETRIPPEDREVIEW
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.

IN [13]python
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.estado

revisar 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.

IN [14]python
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)
OUT [14]Tests with scripted events
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:

IN [15]python
ks.enviar(t + 300, "O31")
OUT [15]Blocked until someone re-arms it
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

  1. Calibrate the CUSUM (h=40h = 40) 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
IN [16]python
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)
OUT [16]CUSUM with the backtest parameters
                   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-03

The 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 kk almost zero, the mean increment of the CUSUM of a live strategy is −μ-\mu, 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.

  1. Modify regla_caida so 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 in degradada.
Solution
IN [17]python
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)
OUT [17]Drawdown rule with a peak reset
                              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.792

The 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 DD, 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.

  1. 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

  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. ↩

  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. ↩

Each answer is folded under its question

Self-assessment

  1. 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%.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

Resources

Data