---
title: "4.2.5 Drawdown control and kill switch"
description: "Cutting exposure with drawdown, circuit breakers and kill switches tested on a strategy that stops working: delay, false alarms and cost."
url: "https://pythonparatrading.com/en/python-trading-course/level-4/drawdown-control-and-kill-switch/"
lang: "en"
image: "https://pythonparatrading.com/og/en/python-trading-course/level-4/drawdown-control-and-kill-switch.png"
---

# 4.2.5 · 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.

- Course: [Level 4: Portfolios, risk, execution and live trading](https://pythonparatrading.com/en/python-trading-course/level-4/)
- Module: 4.2 · Risk management and position sizing
- Duration: 65 min
- Prerequisites: [2.6.1 · CAGR, Sharpe, Sortino, Calmar, Omega, drawdown and Ulcer Index](https://pythonparatrading.com/en/python-trading-course/level-2/return-and-risk-metrics/), [2.1.7 · Regime detection: hidden Markov models and structural breaks](https://pythonparatrading.com/en/python-trading-course/level-2/regime-detection-hmm-structural-breaks/), [2.5.4 · Deflated Sharpe Ratio and Probability of Backtest Overfitting (PBO)](https://pythonparatrading.com/en/python-trading-course/level-2/deflated-sharpe-ratio-and-pbo/), [4.2.1 · Risk per trade, stops and risk of ruin](https://pythonparatrading.com/en/python-trading-course/level-4/risk-per-trade-stops-and-risk-of-ruin/), [4.2.2 · Kelly criterion and volatility targeting: how much to risk](https://pythonparatrading.com/en/python-trading-course/level-4/kelly-criterion-and-volatility-targeting/)

## By the end of this lesson

- Distinguish the four levels of control (operational, circuit breaker, drawdown-based exposure and statistical kill switch) by their horizon and by what they detect.
- Calculate the distribution of the maximum drawdown of a healthy strategy and not mistake the backtest drawdown for the worst case.
- Code an exposure rule proportional to the drawdown cushion and measure its cost, its trap (exposure that never recovers) and the effect of a floor.
- Build a CUSUM on returns, calibrate its threshold with the trade-off between false alarms and delay, and measure both on data with a known answer.
- Write an operational kill switch with an injected clock, manual re-arming and scripted tests.

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

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

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

$$
e_{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%:

```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)
```
```output
         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\%$, −13.5% with 15% and −16.1% with 20%, below $D$ 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:

```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)
```
```output
                    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.* Two panels, 2006-2025. Top, equity on a log scale: without the rule it ends at 1.74 times the start with drawdowns of almost half; with the rule (highlighted) the maximum drawdown stays at 20% but it ends at 0.82. Bottom, the rule's exposure: it falls below 5% in the 2008 crisis, slowly climbs back to 43% in January 2018, the February 2018 crash leaves it at 6% and from mid-2020 on it never exceeds 12%.

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:

```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)
```
```output
           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 $\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.

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

```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%}")
```
```output
['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, $z_t = r_t / \sigma$, an expected mean $\mu_0$ (the backtest's) and an alternative mean $\mu_1 = 0$ (the
dead strategy):

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

```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)
```
```output
    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 = 10$ a false alarm in 10 years is
certain and detection comes in 0.3 years in the median; with $h = 50$, the false alarm drops to 18% and detection
takes 3.9 years in the median (8.5 at the 90th percentile); with $h = 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 known[^2]: it is that the signal is small.

[^2]: 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.

*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.* Curve with six CUSUM thresholds. h = 10: false alarm almost certain within 10 years and detection in 0.3 years. h = 20: 98% and 1 year. h = 30: 69% and 1.8 years. h = 40 (highlighted): 37% and 2.8 years. h = 50: 18% and 3.9 years. h = 60: 9% and 5 years.

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

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

```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})")
```
```output
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 = 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:

```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
```
```output
                    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:

```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}")
```
```output
                       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 million[^1]. 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.

[^1]: 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.

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

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

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

```python raises
ks.enviar(t + 300, "O31")
```
```output
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.

<aside class="callout" data-kind="riesgo">

A kill switch does not protect against what it does not watch. Orders already in the market stay alive until they are
canceled; an open position keeps losing even if no new orders are sent, and closing it in a broken market can cost
more than waiting. Decide in advance what the system does when it trips: cancel, close or just block.

</aside>

## 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 = 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.**


```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)
```
```output
                   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 $k$ 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.

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


```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)
```
```output
                              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 $D$,
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.

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

## 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?

   - A. That the strategy has stopped working: it has exceeded its worst drawdown
   - B. Little: with its Sharpe and its volatility, a healthy strategy exceeds that drawdown in 10 years one time in five
   - C. That the backtest had a code bug
   - D. That exposure must be doubled to recover

   *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?

   - 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
   - B. That it does not limit the drawdown
   - C. That it increases volatility
   - D. That it needs intraday data

   *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?

   - A. Detecting the death of the strategy in a few weeks with no false alarms
   - B. No false alarms ever
   - C. A 37% probability of a false alarm in 10 years and a detection that takes about 2.8 years in the median
   - D. Immediate detection if the strategy loses one day

   *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?

   - A. Because they stopped working in 2020
   - B. Because of an error in the data
   - C. Because the CUSUM only works with weekly data
   - D. Because premium-selling strategies lose a lot in crises: the CUSUM accumulates those losses and fires right at the bottom, before the recovery

   *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?

   - A. Wait a minute and re-enable itself
   - B. Double the position to compensate
   - C. Cancel unexecuted orders and block sending until a designated person re-enables it
   - D. Send an email and keep trading

   *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

### Research

- [Volatility Targeting in Python](https://pythonparatrading.com/en/volatility-targeting-2/) · Research-0004 · 7 min

### Documentation

- [Delegated Regulation (EU) 2017/589 (RTS 6): requirements for algorithmic trading, kill functionality (Art. 12) and pre-trade controls (Art. 15)](https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:32017R0589) · eur-lex.europa.eu
- [SEC, Release No. 70694 (2013): the Knight Capital case of August 1, 2012](https://www.sec.gov/litigation/admin/2013/34-70694.pdf) · sec.gov

### Books

- [E. S. Page, “Continuous inspection schemes”, Biometrika 41 (1954), 100-115 (DOI)](https://doi.org/10.1093/biomet/41.1-2.100) · doi.org
- [S. J. Grossman and Z. Zhou, “Optimal investment strategies for controlling drawdowns”, Mathematical Finance 3 (1993), 241-276 (DOI)](https://doi.org/10.1111/j.1467-9965.1993.tb00044.x) · doi.org

### Data

- [Level 4 data: daily returns of ten strategies (Parquet)](https://pythonparatrading.com/curso/datos/nivel-4/estrategias4_diarias.parquet) · Download · estrategias4_diarias.parquet
- [Level 4 data: true parameters of the strategies (CSV)](https://pythonparatrading.com/curso/datos/nivel-4/estrategias4_verdad.csv) · Download · estrategias4_verdad.csv

Previous lesson: [4.2.4 · Stress testing and correlation, liquidity, gap and counterparty risk](https://pythonparatrading.com/en/python-trading-course/level-4/stress-testing-correlation-liquidity-counterparty-risk/)

Next lesson: [4.2.6 · Derivatives risk: portfolio Greeks, margin and liquidations](https://pythonparatrading.com/en/python-trading-course/level-4/derivatives-risk-greeks-margin-and-liquidations/)
