Lesson 4.2.1 · 65 min

Risk per trade, stops and risk of ruin

Before entering a trade you decide how much you are willing to lose if it goes wrong: the risk per trade, R. Position sizing and the meaning of the stop follow from it. This lesson measures, on a trend system in 24 futures, what each type of stop does (fixed, ATR, trailing, time), what it costs and what its worst case is, and computes the risk of ruin with formulas and with simulation, to see where the formulas stop working.

Fig. — A trend system on 24 futures with five exit rules, net of costs (2001-2025): Sharpe ratio and 1st percentile of the per-trade result in multiples of R
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

The risk of a trade is decided before entering

A system with an edge can ruin its operator if the size of each trade is too large. The most widespread way to control it is to set in advance how much is lost if the trade goes wrong: the risk per trade, a fraction ff of the capital (textbooks usually speak of 0.5% to 2%). That planned loss is called R, and trade results are measured in multiples of R: a +3 R trade gains three times what was risked; a −1 R trade loses what was planned. For the loss to be the planned one you need a stop, a price at which the position is closed, and the size follows from the distance to it:

N=⌊K⋅fd⋅M⋅P⌋N = \left\lfloor \frac{K \cdot f}{d \cdot M \cdot P} \right\rfloor

with KK the capital, dd the distance to the stop as a fraction of the price, MM the contract multiplier and PP the price. The floor matters: futures only trade in whole contracts.

Entry signalDistance to the stopATRContractsOrder and stopResult in R
Mermaid source
flowchart TB
  S[Entry signal] --> D[Distance to the stop]
  V[ATR] --> D
  D --> N[Contracts]
  N --> O[Order and stop]
  O --> R[Result in R]

The data are the level 2 synthetic futures (24 markets from 2000 to 2025, with the trend planted as described in their README: 0.15·σ per year in the direction of the last 12 months). They only have closes, so volatility is measured with a “close-to-close ATR”, Wilder's average of the absolute close-to-close change (the next section compares it with the full ATR). With a stop at 3 close-to-close ATRs and 0.5% risk:

IN [01]python
import numpy as np
import pandas as pd

fut = pd.read_parquet("futuros2_continuos.parquet")
esp = pd.read_csv("futuros2_especificaciones.csv", index_col="mercado")
AJ = fut.pivot(index="fecha", columns="mercado",
               values="cierre_ajustado")
P = fut.pivot(index="fecha", columns="mercado", values="cierre")
CONTRATO = fut.pivot(index="fecha", columns="mercado",
                     values="contrato")
esp = esp.loc[AJ.columns]
# “close-to-close ATR”: Wilder average of |ΔC| as a % of price
atr = AJ.diff().abs().ewm(alpha=1 / 20, adjust=False).mean() / AJ
K_STOP = 3.0                     # stop distance: 3 close-to-close ATRs

def contratos(capital, f, dia):
    riesgo = (K_STOP * atr.loc[dia] * P.loc[dia]
              * esp["multiplicador"])         # USD per contract
    n = np.floor(capital * f / riesgo)
    return pd.DataFrame({"riesgo_contrato": riesgo,
                         "contratos": n,
                         "riesgo_real_%": 100 * n * riesgo / capital})

dia = "2025-12-31"
pd.concat({f"{c:,.0f}": contratos(c, 0.005, dia).loc[
    ["IDA", "BOA", "BOC", "ENA", "MEA", "AGA", "DIA"]]
    for c in (1_000_000, 100_000)}, axis=1).round(2)
OUT [01]Contracts to risk 0.5% per trade
              1,000,000                                 100,000
        riesgo_contrato contratos riesgo_real_% riesgo_contrato contratos riesgo_real_%
mercado
IDA             9371.01       0.0          0.00         9371.01       0.0          0.00
BOA              405.08      12.0          0.49          405.08       1.0          0.41
BOC              660.84       7.0          0.46          660.84       0.0          0.00
ENA             2716.93       1.0          0.27         2716.93       0.0          0.00
MEA              764.58       6.0          0.46          764.58       0.0          0.00
AGA              528.64       9.0          0.48          528.64       0.0          0.00
DIA              924.37       5.0          0.46          924.37       0.0          0.00

With one million USD, the budget per trade is 5,000 USD. The index IDA has a risk of 9,371 USD per contract down to the stop: not even one fits, and the system cannot trade it with that risk. Crude oil (ENA, 2,717 USD per contract) admits one, with an actual risk of 0.27% instead of 0.5%. The rest end up close to the target (from 0.46% to 0.49%). With 100,000 USD only the 2-year bond (BOA, 405 USD per contract) admits one contract. It is the granularity problem of lesson 3.1.4, seen from the risk per trade: with little capital, the “1% per trade” rule cannot be applied to large contracts, and the ways out are micros, fewer markets or accepting unequal risks. The lesson's system, with one million, skips the trades that do not admit one contract.

ATR: the distance that adapts

The ATR (average true range, Wilder, 1978; lesson 1.7.1) is the average of the true range: the maximum of the day's range and the distance from the previous close to the high or the low, so that gaps are counted. With daily bars of KAPA (the ETF of the level 2 5-minute bars), TA-Lib's 14-session ATR and one of our own with Wilder's average:

IN [02]python
import talib

b = pd.read_parquet("indice2_5min.parquet")
b["dia"] = b["ts"].dt.tz_convert("America/New_York").dt.date
d = b.groupby("dia").agg(apertura=("apertura", "first"),
                         maximo=("maximo", "max"),
                         minimo=("minimo", "min"),
                         cierre=("cierre", "last"))
d.index = pd.to_datetime(d.index)
d["atr"] = talib.ATR(d["maximo"], d["minimo"], d["cierre"],
                     timeperiod=14)
previo = d["cierre"].shift(1)
rango = pd.concat([d["maximo"] - d["minimo"],
                   (d["maximo"] - previo).abs(),
                   (d["minimo"] - previo).abs()], axis=1).max(axis=1)
propio = pd.Series(np.nan, index=d.index)
propio.iloc[14] = rango.iloc[1:15].mean()        # Wilder seed
for i in range(15, len(d)):
    propio.iloc[i] = (13 * propio.iloc[i - 1] + rango.iloc[i]) / 14
print(f"max difference TA-Lib - own: "
      f"{(d['atr'] - propio).abs().max():.1e}")
print(f"ATR / mean |ΔC|: "
      f"{rango.mean() / d['cierre'].diff().abs().mean():.2f}")
(100 * d["atr"] / d["cierre"]).groupby(d.index.year).mean().round(2)
OUT [02]Wilder's ATR with TA-Lib and by hand, on KAPA daily bars
max difference TA-Lib - own: 5.3e-15
ATR / mean |ΔC|: 1.88
dia
2022    1.92
2023    1.34
2024    1.37
2025    1.58
dtype: float64

TA-Lib and our own implementation agree up to rounding (5e-15). Two useful facts. The mean true range is 1.88 times the mean absolute close-to-close change: the “close-to-close ATR” used by the futures system is smaller than the full ATR, and 3 close-to-close ATRs are equivalent to 1.6 full ATRs. And the ATR, as a percentage of price, varies from one year to the next: 1.92% on average in 2022, 1.34% in 2023, 1.37% in 2024 and 1.58% in 2025. That variation is what spoils a fixed stop:

IN [03]python
ayer = d.shift(1)
stops = pd.DataFrame({
    "fijo_2%": d["minimo"] <= ayer["cierre"] * 0.98,
    "1,3_ATR": d["minimo"] <= ayer["cierre"] - 1.3 * ayer["atr"]}
).loc[ayer["atr"].notna()]
distancia = (100 * 1.3 * ayer["atr"] / ayer["cierre"]).loc[stops.index]
pd.concat([stops.groupby(stops.index.year).mean().mul(100),
           distancia.groupby(distancia.index.year).mean()
           .rename("dist_ATR_%")], axis=1).round(2)
OUT [03]A fixed 2% stop and a 1.3 ATR stop, year by year
      fijo_2%  1,3_ATR  dist_ATR_%
dia
2022     8.47     4.24        2.50
2023     4.00     7.20        1.74
2024     2.78     7.54        1.79
2025     5.20     4.80        2.05

For a position bought at the previous close, a 2% stop is hit on 8.47% of the days of 2022 and only 2.78% of those of 2024: the same stop is three times more “nervous” in a turbulent year. A stop at 1.3 ATR (about 2% on average) is hit on between 4.24% and 7.54% of the days, because its distance widens to 2.50% in 2022 and narrows to 1.74% in 2023. It is not constant: the ATR is computed with the previous 14 sessions and reaches regime changes late. But the difference in meaning between markets, which is the big one, disappears.

A trend system and five exits

Measuring stops requires a system. The lesson's is deliberately simple: in each of the 24 futures, when the 20-session exponential moving average crosses the 100-session one, you enter in the direction of the cross and exit at the opposite cross (one entry per signal run). All decisions are taken with yesterday's close and executed at today's close; the size is the one for 0.5% risk with the stop at 3 close-to-close ATRs, in whole contracts; each position change pays the slippage ticks and the commission in futuros2_especificaciones.csv, and each roll, two sides. The five variants only change the exit:

  • no stop: only the opposite cross;
  • fixed 5%: in addition, exit if the trade loses 5% of the entry price;
  • ATR 3: exit if it loses 3 close-to-close ATRs (the stop used for sizing: −1 R);
  • trailing 10 ATR: exit if the price pulls back 10 ATRs from the best close of the trade;
  • time 20: exit if the trade is not in profit after 20 sessions.
IN [04]python
r = AJ.pct_change()
senal = np.sign(AJ.ewm(span=20).mean() - AJ.ewm(span=100).mean())
CAPITAL, F = 1_000_000, 0.005
mult = esp["multiplicador"].to_numpy()
coste_lado = (esp["coste_tipico_ticks"] * esp["valor_tick"]
              + esp["comision_usd"]).to_numpy()
cambio_ctr = (CONTRATO.ne(CONTRATO.shift(1))
              & CONTRATO.shift(1).notna())

def sistema(salida="ninguna", k=3.0, fijo=0.05, n=20, azar=False,
            datos=None, semilla=1, plazo=60):
    """Trend (20/100 moving-average cross) on 24 futures. Decides with
    yesterday's close and trades at today's close. Size: 0.5% of
    initial capital down to a stop at K_STOP ATRs (whole contracts).
    salida: 'ninguna' (opposite signal only), 'fijo' (loss of
    `fijo` %), 'atr' (k ATRs from entry), 'trailing' (k ATRs from
    the best close) or 'tiempo' (n sessions without a profit).
    azar=True: random entries and sides, exit after `plazo`."""
    Aj, Sg, At = datos if datos is not None else (AJ, senal, atr)
    rng = np.random.default_rng(semilla)
    a, s, v, p = (Aj.to_numpy(), Sg.to_numpy(), At.to_numpy(),
                  P.to_numpy())
    T, M = a.shape
    N, ops = np.zeros((T, M)), []
    for j in range(M):
        lado = 0
        hecho = 0
        for t in range(120, T):
            if lado:
                N[t, j] = lado * nc          # held during session t
                mov = (a[t - 1, j] / e - 1) * lado
                dias = t - 1 - t0
                motivo = None
                if azar and dias >= plazo:
                    motivo = "plazo"
                elif not azar and s[t - 1, j] != lado:
                    motivo = "señal"
                if salida == "fijo" and mov <= -fijo:
                    motivo = "stop"
                if salida == "atr" and mov <= -k * a0:
                    motivo = "stop"
                if salida == "trailing":
                    mejor = max(mejor, mov)
                    if mov <= mejor - k * a0:
                        motivo = "stop"
                if salida == "tiempo" and dias >= n and mov <= 0:
                    motivo = "tiempo"
                if motivo is None:
                    continue
                ops.append((j, t0, t, lado, nc, motivo))  # exits at t
                lado = 0
            if np.isnan(s[t - 1, j]) or np.isnan(v[t - 1, j]):
                continue
            if azar:
                entra = rng.random() < 1 / 40
            else:                    # one entry per signal run
                entra = s[t - 1, j] != 0 and s[t - 1, j] != hecho
            if not entra:
                continue
            a0 = v[t - 1, j]
            nc = np.floor(CAPITAL * F
                          / (K_STOP * a0 * p[t - 1, j] * mult[j]))
            if nc < 1:
                continue
            lado = (int(rng.choice([-1, 1])) if azar
                    else int(s[t - 1, j]))
            hecho = s[t - 1, j]
            e, t0, mejor = a[t, j], t, 0.0     # enters at the close of t
    N = pd.DataFrame(N, index=AJ.index, columns=AJ.columns)
    rr = Aj.pct_change()
    pyg = (N * mult * P.shift(1) * rr).fillna(0)
    operado = N.diff().abs().shift(-1).fillna(0)  # at the close of t
    coste = operado * coste_lado + 2 * N.abs() * cambio_ctr * coste_lado
    neto = (pyg.sum(axis=1) - coste.sum(axis=1)) / CAPITAL
    ops = pd.DataFrame(ops, columns=["j", "t0", "t", "lado", "nc",
                                     "motivo"])
    ops["mercado"] = AJ.columns[ops["j"]]
    ops["R"] = [pyg.iloc[i + 1:k_ + 1, j].sum() / (CAPITAL * F)
                for j, i, k_ in zip(ops["j"], ops["t0"], ops["t"])]
    ops["dias"] = ops["t"] - ops["t0"]
    return neto.loc["2001":], ops

def resumen(neto, ops):
    eq = (1 + neto).cumprod()
    dd = eq / eq.cummax() - 1
    R = ops["R"]
    return {"ops": len(ops), "gan_%": 100 * (R > 0).mean(),
            "E[R]": R.mean(), "peor_R": R.min(),
            "p1_R": R.quantile(0.01),
            "stop_%": 100 * (ops["motivo"] != "señal").mean(),
            "sharpe": neto.mean() / neto.std() * np.sqrt(252),
            "caida_%": 100 * dd.min(),
            "peor_mes_%": 100 * neto.resample("ME").sum().min()}

The P&L is computed contract by contract in dollars (contracts × multiplier × yesterday's price × today's return of the adjusted series) and the R of each trade is its gross result divided by the 5,000 USD of planned risk. The five exits:

IN [05]python
SALIDAS = {"no stop": {},
           "fixed 5%": {"salida": "fijo"},
           "ATR 3": {"salida": "atr", "k": 3},
           "trailing 10 ATR": {"salida": "trailing", "k": 10},
           "time 20": {"salida": "tiempo", "n": 20}}
RES = {k: sistema(**kw) for k, kw in SALIDAS.items()}
pd.DataFrame({k: resumen(*v) for k, v in RES.items()}).T.round(2)
OUT [05]The five exits, with costs
                    ops  gan_%  E[R]  peor_R  p1_R  stop_%  sharpe  caida_%  peor_mes_%
no stop          1841.0  31.29  0.43   -4.63 -3.56    0.00    0.67   -42.73      -19.69
fixed 5%         1846.0  28.01  0.38   -4.51 -3.16   31.53    0.63   -32.44      -18.06
ATR 3            1849.0  24.07  0.29   -3.70 -2.44   62.57    0.54   -31.59      -16.21
trailing 10 ATR  1842.0  34.09  0.28   -4.25 -3.42   45.82    0.56   -43.00      -14.05
time 20          1851.0  29.23  0.26   -4.25 -3.36   50.08    0.48   -30.27      -13.79

The header figure summarizes the table. Without a stop, the system makes 1,841 trades in 25 years; only 31.3% win, but the expectancy is +0.43 R per trade (the winners are large: it is a trend system), with a net Sharpe of 0.67, a maximum drawdown of 42.7% and a worst month of −19.7%. Every stop costs Sharpe: 0.63 for the fixed 5%, 0.54 for 3 ATR, 0.56 for the trailing stop and 0.48 for the time stop, because each one cuts trades that, held on, would have ended well. What they buy is a shorter tail: with the 3 ATR stop, the 1st percentile per trade goes from −3.56 R to −2.44 R, the maximum drawdown from 42.7% to 31.6% and the worst month from −19.7% to −16.2%. The time stop gives the smallest drawdown (30.3%) and the mildest worst month (−13.8%), with the worst Sharpe.

There is no “best” exit: there is a trade-off between return and the shape of the losses. What there is, though, is a rule worth not following, the fixed stop as a percentage of price, and it shows by sector:

IN [06]python
o = RES["fixed 5%"][1]
o["sector"] = esp.loc[o["mercado"], "sector"].to_numpy()
o.groupby("sector").agg(
    operaciones=("R", "size"),
    stop_pct=("motivo", lambda m: 100 * (m == "stop").mean()),
    E_R=("R", "mean")).round(2)
OUT [06]The fixed 5% stop by sector
                operaciones  stop_pct   E_R
sector
agricolas               313     50.80  0.42
bonos                   401      0.25  0.38
divisas                 268      7.46  0.00
energia                 277     66.43 -0.01
metales                 338     39.35  0.31
renta_variable          249     34.14  1.25

The same 5% is practically never hit in bonds (0.25% of trades, with volatilities from 2% to 12% per year) and cuts two out of every three trades in energy (66.4%, with volatilities from 33% to 50%). In bonds it is like having no stop; in energy it is an almost random exit, and the expectancy of those trades ends up at −0.01 R. A stop has to be measured in units of the market's volatility, which is what the ATR and trailing stops do.

The control: does a stop create an edge?

A much-repeated idea is that a good stop turns any entry into a profitable one (“cut your losses and let your profits run”). It can be checked with two controls: random entries (each day, a 1/40 probability of entering each market, on a random side, and exit after 60 sessions if the stop does not trigger first) and the same futures with daily returns permuted within each market, which preserve each day's distribution and destroy the trend (as in lesson 2.5.5):

IN [07]python
rng = np.random.default_rng(11)
perm = r.copy()
for m in perm.columns:
    v = perm[m].dropna()
    perm.loc[v.index, m] = rng.permutation(v.to_numpy())
AJp = ((1 + perm.fillna(0)).cumprod() * AJ.bfill().iloc[0]).where(
    AJ.notna())
sin_tend = (AJp,
            np.sign(AJp.ewm(span=20).mean()
                    - AJp.ewm(span=100).mean()),
            AJp.diff().abs().ewm(alpha=1 / 20, adjust=False).mean()
            / AJp)
filas = {}
for nombre, kw in [("signal", {}), ("signal + ATR 3", {"salida": "atr"}),
                   ("random", {"azar": True}),
                   ("random + ATR 3", {"azar": True, "salida": "atr"}),
                   ("random + trailing", {"azar": True, "k": 10,
                                          "salida": "trailing"})]:
    m_real = resumen(*sistema(**kw))
    m_perm = resumen(*sistema(datos=sin_tend, **kw))
    filas[nombre] = {"E[R]": m_real["E[R]"], "sharpe": m_real["sharpe"],
                     "peor_R": m_real["peor_R"],
                     "E[R]_sin_tend": m_perm["E[R]"],
                     "sharpe_sin_tend": m_perm["sharpe"]}
pd.DataFrame(filas).T.round(2)
OUT [07]The same stops with random entries and without trend
                   E[R]  sharpe  peor_R  E[R]_sin_tend  sharpe_sin_tend
signal             0.43    0.67   -4.63           0.00            -0.13
signal + ATR 3     0.29    0.54   -3.70          -0.00            -0.14
random            -0.02   -0.15  -16.80          -0.02            -0.25
random + ATR 3     0.10    0.31   -3.65          -0.04            -0.37
random + trailing  0.21    0.47   -6.26          -0.06            -0.36

With the course's real data, random entries without a stop give −0.02 R per trade and a Sharpe of −0.15 (the costs), with a worst trade of −16.8 R: without a stop and with a 60-session horizon, a random trade can go very wrong. With a 3 ATR stop, the same random entries make +0.10 R and a Sharpe of 0.31; with a 10 ATR trailing stop, +0.21 R and 0.47. Do stops create an edge? With the permuted returns, without trend, the answer is no: every variant loses (from −0.00 to −0.06 R, Sharpe from −0.13 to −0.37), and stops do not fix random entries. What happens in the real data is that a stop is a trend rule: closing what goes wrong and keeping what goes right is betting that moves continue, and on data with a planted trend that wins. In a market without trend (or with mean reversion, lesson 2.3.2), the same stop loses. Before attributing a system's result to its stop, it pays to run this control.

The worst episode: more than 1 R

The 3 ATR stop defines −1 R. The figure shows what the distribution of results looks like with and without it:

Fig. — Distribution of the per-trade result, in multiples of the planned risk R, of the trend system without a stop and with a 3 ATR (1 R) stop on 24 futures (2001-2025); trades above 10 R are in the last bar

Without a stop, losses spread between 0 and −4.6 R; with it, they pile up between 0 and −1.5 R. The right tail, the one that pays for the system (49 trades of more than 10 R with a stop, 67 without), barely changes. But the stop does not cut the left tail at −1 R:

IN [08]python
o = RES["ATR 3"][1]
fechas = AJ.index
peores = o.nsmallest(5, "R").assign(
    entrada=lambda x: fechas[x["t0"]].date,
    salida=lambda x: fechas[x["t"]].date)
print(peores[["mercado", "lado", "entrada", "salida", "motivo",
              "R"]].round(2).to_string(index=False))
stop = o[o["motivo"] == "stop"]["R"]
print(f"stop exits: {len(stop)}, mean R {stop.mean():.2f}, "
      f"worse than -1.5 R: {(stop < -1.5).mean():.1%}")
OUT [08]The worst trades with a stop: more than 1 R
mercado  lado    entrada     salida motivo     R
    MEC    -1 2002-02-06 2002-05-13   stop -3.70
    BOA     1 2023-10-27 2023-10-31   stop -3.62
    AGD    -1 2024-07-16 2024-08-13   stop -3.13
    MEC     1 2024-07-19 2024-11-01   stop -3.09
    BOD     1 2023-10-05 2023-10-31   stop -2.90
stop exits: 1157, mean R -1.07, worse than -1.5 R: 16.8%

Of 1,157 stop exits, the mean result is −1.07 R, but 16.8% lose more than 1.5 R and the worst, −3.70 R (a short in copper in 2002). The second one is instructive: a long in the 2-year bond on October 27, 2023, with the ATR at its lows (0.075% per day), which in two sessions falls about 6 and 5 ATRs as measured at entry; the stop is detected at the close and executed at the next one, and the loss reaches 3.62 R. It is the rule's worst episode: an ATR stop is tighter precisely when volatility has been low, and a regime change runs through it. With intraday stops the same happens with opening gaps (lesson 2.4.8). A stop limits the typical loss, not the maximum; sizing has to allow for losses of several R.

Risk of ruin: formulas

The risk of ruin is the probability of losing a part of the capital that forces you to stop: all of it, half of it or the drawdown that the trader or their clients cannot stand. The classic formula is the gambler's: with one-unit bets, probability p>12p > \tfrac12 of winning one unit and 1−p1-p of losing it, and a capital of UU units, the probability of ever losing everything is

Pruin=(1−pp)UP_{\text{ruin}} = \left(\frac{1-p}{p}\right)^{U}
IN [09]python
def ruina_jugador(p, unidades):
    """P(losing everything) betting 1 unit on heads or tails with
    P(win) = p > 0.5 against an infinitely rich opponent."""
    return ((1 - p) / p) ** unidades

rng = np.random.default_rng(5)
p, U, caminos, pasos = 0.55, 10, 20_000, 2_000
x = np.where(rng.random((caminos, pasos)) < p, 1, -1)
minimo = U + np.minimum.accumulate(x.cumsum(axis=1), axis=1)[:, -1]
print(f"formula {ruina_jugador(p, U):.4f}, "
      f"simulation {(minimo <= 0).mean():.4f}")
OUT [09]Gambler's ruin: the formula and a simulation
formula 0.1344, simulation 0.1333

With a 55% hit rate and ten units, the probability of ruin is 13.4%, and 20,000 simulated paths of 2,000 bets give 13.3%. A trader does not bet fixed units: they risk a fraction ff of the current capital, and then the capital is multiplied by (1+f Ri)(1 + f\,R_i) on each trade. In logarithms it is a random walk with steps xi=ln⁡(1+fRi)x_i = \ln(1 + f R_i), and losing half the capital means falling D=ln⁡2D = \ln 2. Two formulas:

  • Diffusion: if the steps were normal, with mean μ\mu and variance σ2\sigma^2, Pruin=e−2μD/σ2P_{\text{ruin}} = e^{-2\mu D/\sigma^2}.
  • Lundberg (Cramér-Lundberg): with s>0s > 0 the root of E[e−s x]=1\mathbb{E}[e^{-s\,x}] = 1, Pruin≤e−sDP_{\text{ruin}} \le e^{-s D}. It does not assume normality: it uses the whole distribution of the steps. It is an upper bound, which is reached when the drop does not overshoot when crossing the threshold.

With the R results of the 1,849 trades of the system with a 3 ATR stop, and a simulation that resamples them as if they were independent (1,000 trades, 4,000 paths):

IN [10]python
from scipy.optimize import brentq

R = RES["ATR 3"][1]["R"].to_numpy()
L = 0.5                        # “ruin”: losing half the capital
D = -np.log(L)

def ruina_formulas(f):
    x = np.log1p(f * R)        # log growth per trade
    difusion = np.exp(-2 * x.mean() * D / x.var())
    s = brentq(lambda s: np.mean(np.exp(-s * x)) - 1, 1e-6, 500)
    return difusion, np.exp(-s * D)

def ruina_simulada(f, n=1000, caminos=4000, semilla=3, nivel=L):
    """Resampled (independent) trades with risk f."""
    rng = np.random.default_rng(semilla)
    capital = np.cumprod(1 + f * rng.choice(R, (caminos, n)), axis=1)
    desde_inicio = (capital.min(axis=1) <= nivel).mean()
    pico = np.maximum.accumulate(np.maximum(capital, 1), axis=1)
    desde_pico = ((capital / pico).min(axis=1) <= nivel).mean()
    return desde_inicio, desde_pico

filas = {}
for f in (0.005, 0.01, 0.02, 0.03, 0.05):
    dif, lund = ruina_formulas(f)
    ini, pico = ruina_simulada(f)
    filas[f"{100 * f:g}%"] = {"difusion": dif, "lundberg": lund,
                               "simulada": ini, "caida_50%_pico": pico}
print(f"{len(R)} trades: E[R] {R.mean():.3f}, "
      f"std {R.std():.2f}, winners {(R > 0).mean():.1%}")
pd.DataFrame(filas).T.round(4)
OUT [10]Ruin with fixed risk per trade: formula and simulation
1849 trades: E[R] 0.292, std 3.60, winners 24.1%
      difusion  lundberg  simulada  caida_50%_pico
0.5%    0.0022    0.0004    0.0002          0.0015
1%      0.0531    0.0256    0.0272          0.2218
2%      0.2682    0.2106    0.1955          0.9445
3%      0.4719    0.4267    0.4132          1.0000
5%      0.7660    0.7536    0.7040          1.0000
Fig. — Probability of ruin over 1,000 trades by risk per trade, resampling the R results of the 3 ATR stop system (independent trades)

The trades have an expectancy of 0.292 R with a standard deviation of 3.60 R and only 24.1% winners: a very skewed distribution. Lundberg's formula and the simulation agree within the simulation error (about ±0.3 points with 4,000 paths at 1%): 2.6% and 2.7% probability of losing half the capital risking 1% per trade; 21% and 20% with 2%; 75% and 70% with 5%. The diffusion formula exaggerates (5.3% at 1%): it treats the distribution as normal, and the large tail of gains, which pushes ruin away, enters only through the variance, as if it were risk.

The last column is the one that matters most in practice: the probability that at some point the capital falls 50% from a peak, not from the start. At 1% it is 22%; at 2%, 94%. Ruin from the initial capital moves further away as you make money; the drawdown from a peak does not, and it is the one suffered by whoever started trading at a good moment. The figures depend on 1,000 trades: with more trades, the probability from the peak goes up.

Trades do not come one at a time

The formulas and the previous simulation treat trades in a row: one ends and the next one begins. In the lesson's system there are many open at the same time:

IN [11]python
o = RES["ATR 3"][1]
abiertas = np.zeros(len(AJ))
for i, k_ in zip(o["t0"], o["t"]):
    abiertas[i + 1:k_ + 1] += 1
abiertas = pd.Series(abiertas, AJ.index).loc["2001":]
print(f"open trades: mean {abiertas.mean():.1f}, "
      f"max {abiertas.max():.0f}")
print(f"mean open risk {0.5 * abiertas.mean():.1f}% of "
      f"capital, max {0.5 * abiertas.max():.0f}%")
_, p30 = ruina_simulada(0.005, n=len(R), nivel=0.7)
eq = (1 + RES["ATR 3"][0]).cumprod()
print(f"in series: P(30% drawdown from the peak) = {p30:.3f}; "
      f"actual: {100 * (eq / eq.cummax() - 1).min():.1f}%")
OUT [11]Trades do not come one at a time
open trades: mean 16.7, max 24
mean open risk 8.4% of capital, max 12%
in series: P(30% drawdown from the peak) = 0.308; actual: -31.6%

On average there are 16.7 open trades, and up to 24 (one per market). The open risk (the sum of the R of the live positions) is 8.4% of capital on average and reaches 12%: the “0.5% per trade” is in fact a portfolio risk that depends on how many positions there are and how they move together. In this system, the actual drawdown of 31.6% fits within what the in-series simulation with the same 1,849 trades gives (a 30.8% probability of a 30% drawdown from a peak), unlike lesson 2.5.5, where the actual sequence was worse than any reordering. But the timing is different: in series, those 1,849 trades follow one another; in the account, they pile up in the same months, and the system's worst month loses 16.2%. That is why a portfolio is sized with the joint volatility of its positions (volatility targeting, lesson 4.2.2) and monitored with drawdown limits (4.2.5), not only with the risk of each trade.

Common mistakes

  • Sizing by the stop and forgetting the rounding. With whole contracts, the actual risk can be half of the planned one or, if you round up, double.
  • Using the same percentage stop for every market. It measures volatility, not the idea.
  • Attributing the system's edge to the stop. Run the control with random entries and with data without trend.
  • Assuming that −1 R is the maximum loss. Gaps and execution at the next close exceed it.
  • Computing ruin from the initial capital and with trades in a row. The drawdown from a peak and simultaneous positions give much higher probabilities.

Exercises

  1. Rerun the system with a 2 ATR stop and a 5 ATR stop. How do the Sharpe, the 1st percentile and the maximum drawdown change? Is there a point where the stop stops costing?
Solution
IN [12]python
pd.DataFrame({f"ATR {k}": resumen(*sistema(salida="atr", k=k))
              for k in (2, 3, 5)}).T[
    ["gan_%", "E[R]", "p1_R", "stop_%", "sharpe", "caida_%"]].round(2)
OUT [12]Tighter and wider stops
       gan_%  E[R]  p1_R  stop_%  sharpe  caida_%
ATR 2  21.36  0.23 -1.97   73.95    0.47   -34.09
ATR 3  24.07  0.29 -2.44   62.57    0.54   -31.59
ATR 5  29.27  0.36 -2.82   37.45    0.60   -34.67

With 2 ATR, the stop triggers on 74% of the trades, the 1st percentile goes to −1.97 R and the Sharpe drops to 0.47; with 5 ATR it triggers on 37%, the Sharpe rises to 0.60 and the 1st percentile worsens to −2.82 R. The maximum drawdown does not follow the width: it is 34.1% with 2 ATR, 31.6% with 3 and 34.7% with 5, because the system's worst run depends on many simultaneous trades and not only on each stop. In this system the stop does not stop costing at any width: the wider it is, the more it resembles the system without a stop (Sharpe 0.67) and the less it trims the tail. The width is a choice between return and the shape of the losses, and choosing it by looking at the backtest is another source of overfitting (lesson 2.5.6).

  1. Using Lundberg's and the diffusion formulas, compute the risk per trade that gives a 1% probability of losing half the capital with the trades of the system with a 3 ATR stop.
Solution
IN [13]python
for i, nombre in enumerate(["diffusion", "Lundberg"]):
    f_max = brentq(lambda f: ruina_formulas(f)[i] - 0.01, 0.004, 0.05)
    print(f"{nombre:9s}: f = {100 * f_max:.2f}%, simulated "
          f"{ruina_simulada(f_max)[0]:.4f}")
OUT [13]The risk per trade for a 1% probability of ruin
diffusion: f = 0.66%, simulated 0.0027
Lundberg : f = 0.82%, simulated 0.0118

For a 1% probability of losing half the capital (over 1,000 independent trades), Lundberg's formula allows risking 0.82% per trade and the diffusion formula, more conservative because of its error, 0.66%. The simulation with 0.82% gives 1.18%, close to the target (the difference is the simulation error and the finite horizon); with 0.66%, 0.27%. These are figures for trades in a row: with 17 positions open at the same time, portfolio risk dominates, and that is why this calculation is a ceiling for the risk per trade, not a recipe.

  1. An online course promises that “with a stop always at 1:3 (risk 1 to make 3) and a 40% hit rate you always win”. What does it assume and what does it leave out?
Solution

It assumes that each trade ends exactly at −1 R or at +3 R, that the hits are independent and that their probability is 40%. With those numbers the expectancy is 0.4·3 − 0.6·1 = +0.6 R per trade, and ruin with a small fixed risk is unlikely. It leaves out almost everything this lesson measures: that losses go beyond −1 R (gaps, execution at the next close: here, 16.8% of the stop exits go beyond −1.5 R); that the 40% hit rate with a +3 R target is not given by the exit rule but by the entry (with random entries and without trend, the expectancy is negative because of costs); that trades overlap and are correlated; and that “always” does not exist: even with a positive expectancy, a 50% drawdown from a peak has a high probability if the risk per trade is 2% or more.

Each answer is folded under its question

Self-assessment

  1. With 100,000 USD and 0.5% risk per trade (500 USD), the risk of one contract down to the stop is 660 USD. How many contracts do you buy?

    • AOne: you round up
    • B0.76 contracts
    • CNone: with whole contracts rounded down, the risk of one contract already exceeds the budget; you either pass on the trade or accept more risk than planned
    • DIt depends on the margin
    Show answer

    Answer C. None: with whole contracts rounded down, the risk of one contract already exceeds the budget; you either pass on the trade or accept more risk than planned

    floor(500 / 660) = 0. With 100,000 USD, only one of the seven markets in the lesson's table admits one contract. With little capital, the textbook risk per trade cannot be applied to large futures: you have to use micros or accept positions of unequal risk.

  2. On KAPA, a fixed stop 2% below the previous close is hit on 8.5% of the days of 2022 and 2.8% of those of 2024. Why?

    • ABecause 2022 had more sessions
    • BBecause daily volatility (mean ATR of 1.92% in 2022 and 1.37% in 2024) changes, and a fixed percentage stop ends up relatively closer in turbulent years
    • CBecause the stop is miscalculated
    • DBecause of opening gaps
    Show answer

    Answer B. Because daily volatility (mean ATR of 1.92% in 2022 and 1.37% in 2024) changes, and a fixed percentage stop ends up relatively closer in turbulent years

    A 2% move is little more than one ATR in 2022 and almost one and a half ATRs in 2024. A stop at 1.3 ATR keeps a distance proportional to recent volatility, and its trigger frequency varies less between years (from 4.2% to 7.5%), although it is not constant because the ATR moves with a lag.

  3. In the trend system, the 3 ATR stop lowers the Sharpe from 0.67 to 0.54. What does it buy in exchange?

    • AA shorter loss tail: the 1st percentile per trade goes from −3.56 R to −2.44 R, the maximum drawdown from 42.7% to 31.6% and the worst month from −19.7% to −16.2%
    • BNothing: it is worse in every way
    • CHigher mean return
    • DFewer trades
    Show answer

    Answer A. A shorter loss tail: the 1st percentile per trade goes from −3.56 R to −2.44 R, the maximum drawdown from 42.7% to 31.6% and the worst month from −19.7% to −16.2%

    The stop cuts the trades that turn bad, and with them some that would have ended well (the trend needs to sit through pullbacks): the expectancy per trade drops from 0.43 R to 0.29 R. In exchange, the worst case is less bad. Whether that is worth it depends on what a 40% drawdown costs the trader (or their clients).

  4. With a 3 ATR stop, why does the worst trade lose 3.7 R and not 1 R?

    • ABecause of a backtest bug
    • BBecause costs are 2.7 R
    • CBecause the stop only applies to long trades
    • DBecause the stop is checked at the close and executed at the next close, and in that time the price can keep moving many ATRs, especially if volatility was low at entry
    Show answer

    Answer D. Because the stop is checked at the close and executed at the next close, and in that time the price can keep moving many ATRs, especially if volatility was low at entry

    A stop limits the expected loss, not the maximum loss. In the lesson, 16.8% of the stop exits lose more than 1.5 R; in the 2-year bond, with a very low ATR at entry, two sessions of about 6 and 5 ATRs took the loss to 3.6 R. With intraday stops the same happens with gaps (lesson 2.4.8).

  5. Lundberg's formula and the simulation agree: risking 1% per trade, the probability of losing half the capital is 2.6–2.7%. Why is that not enough for sizing?

    • ABecause the formulas are useless
    • BBecause 1% is too little
    • CBecause the simulation has too few paths
    • DBecause they assume independent trades taken one at a time; the system has 16.7 open on average, and a 50% drawdown from a peak (not from the start) has a 22% probability with that same 1%
    Show answer

    Answer D. Because they assume independent trades taken one at a time; the system has 16.7 open on average, and a 50% drawdown from a peak (not from the start) has a 22% probability with that same 1%

    Ruin “from the initial capital” moves further away as you make money; the drawdown from a peak does not. And the risk per trade multiplied by the trades open at the same time (8.4% on average with 0.5%) is what the account suffers in a bad month. Lesson 4.2.2 sizes with portfolio volatility.

Key points

  • Risk-based size is capital × f / (distance to the stop × multiplier × price), in whole contracts: with one million USD and 0.5%, the lesson's index does not admit a single contract; with 100,000 USD, only the 2-year bond does.
  • A fixed percentage stop changes meaning with volatility (it triggers on 0.25% of trades in bonds and 66% in energy); a stop in multiples of ATR adapts to each market and each regime.
  • Stops do not create an edge: in the trend system they lower the Sharpe (0.67 without a stop, 0.54 with 3 ATR) and shorten the tail (1st percentile from −3.56 R to −2.44 R, maximum drawdown from 42.7% to 31.6%). With random entries, a stop seems to win only if the data have a trend; without a trend, it loses the costs.
  • A stop limits the typical loss, not the worst: with a 3 ATR stop at the close, the worst trade loses 3.7 R and 16.8% of the stop exits go beyond 1.5 R.
  • The ruin formulas (gambler, diffusion, Lundberg) agree with the simulation if their assumptions hold; the diffusion formula exaggerates with fat tails. With 1% per trade, losing half the initial capital has a 2.7% probability, but a 50% drawdown from a peak, 22%.

Resources

Data