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.
- Module
- 4.2 · Risk management and position sizing
- Duration
- 65 min
- Prerequisites
- 2.2.2 · Universe, signal, filter, entry, exit, sizing and risk management, 2.4.8 · Fill modeling: limit orders, queue, partial fills, gaps and stops, 2.5.5 · Monte Carlo: trade resampling, block bootstrap and signal permutations
- Author
- Jesús Cuesta
- Updated
Level 4 lessons
4.1 · Portfolio theory and optimisation
- 4.1.1 Markowitz: efficient frontier and real constraints
- 4.1.2 Covariance estimation: shrinkage, factor models and denoising
- 4.1.3 Black-Litterman: from market equilibrium to views
- 4.1.4 Risk parity, minimum variance, HRP and HERC
- 4.1.5 Optimization with CVaR, drawdown, turnover and costs
- 4.1.6 Factor models: CAPM, Fama-French, PCA and attribution
- 4.1.7 Rebalancing and strategy portfolios
- 4.1.8 Portfolio tools: Riskfolio-Lib, PyPortfolioOpt, skfolio and bt
4.2 · Risk management and position sizing
- 4.2.1 Risk per trade, stops and risk of ruin
- 4.2.2 Kelly criterion and volatility targeting: how much to risk
- 4.2.3 VaR, Expected Shortfall and VaR backtesting
- 4.2.4 Stress testing and correlation, liquidity, gap and counterparty risk
- 4.2.5 Drawdown control and kill switch
- 4.2.6 Derivatives risk: portfolio Greeks, margin and liquidations
4.3 · Microstructure and execution
4.4 · Brokers and APIs
- 4.4.1 Choosing a broker from Spain or the EU: regulation, assets, costs and API
- 4.4.2 Interactive Brokers: TWS and IB Gateway, ib_async, contracts, data and orders
- 4.4.3 Alpaca, Tradier, MetaTrader 5 with Python and OANDA v20
- 4.4.4 Crypto exchange APIs: ccxt, Binance, Bybit, Kraken and Coinbase
- 4.4.5 Secure credentials: environment variables, secret managers and least privilege
4.5 · Live trading system
- 4.5.1 Live system architecture: from data to reconciliation
- 4.5.2 Order management: states, partial fills, cancellations and reconnections
- 4.5.3 Paper trading, shadow trading and slippage monitoring
- 4.5.4 Task scheduling and alerts: cron, APScheduler, Prefect, Telegram, Slack and email
- 4.5.5 Fault tolerance: idempotency, persistent state and reconciliation at startup
- 4.5.6 NautilusTrader or LEAN live: from backtest to production with the same code
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 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:
with the capital, the distance to the stop as a fraction of the price, the contract multiplier and the price. The floor matters: futures only trade in whole contracts.
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:
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) 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.00With 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:
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)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: float64TA-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:
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) 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.05For 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.
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:
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) 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.79The 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:
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) 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.25The 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):
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) 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.36With 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:
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:
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%}")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 of winning one unit and of losing it, and a capital of units, the probability of ever losing everything is
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}")formula 0.1344, simulation 0.1333With 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 of the current capital, and then the capital is multiplied by on each trade. In logarithms it is a random walk with steps , and losing half the capital means falling . Two formulas:
- Diffusion: if the steps were normal, with mean and variance , .
- Lundberg (Cramér-Lundberg): with the root of , . 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):
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)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.0000The 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:
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}%")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
- 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
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) 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.67With 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).
- 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
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}")diffusion: f = 0.66%, simulated 0.0027
Lundberg : f = 0.82%, simulated 0.0118For 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.
- 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
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.
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.
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).
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).
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%.