---
title: "4.2.1 Risk per trade, stops and risk of ruin"
description: "Risk-based sizing with whole contracts, fixed, ATR, trailing and time stops measured on 24 futures, and risk of ruin by formula and simulation."
url: "https://pythonparatrading.com/en/python-trading-course/level-4/risk-per-trade-stops-and-risk-of-ruin/"
lang: "en"
image: "https://pythonparatrading.com/og/en/python-trading-course/level-4/risk-per-trade-stops-and-risk-of-ruin.png"
---

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

- 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.2.2 · Universe, signal, filter, entry, exit, sizing and risk management](https://pythonparatrading.com/en/python-trading-course/level-2/universe-signal-entry-exit-position-sizing/), [2.4.8 · Fill modeling: limit orders, queue, partial fills, gaps and stops](https://pythonparatrading.com/en/python-trading-course/level-2/backtest-order-fill-modeling/), [2.5.5 · Monte Carlo: trade resampling, block bootstrap and signal permutations](https://pythonparatrading.com/en/python-trading-course/level-2/monte-carlo-bootstrap-permutation-tests/)

## By the end of this lesson

- Size a trade by its risk (capital × f / distance to the stop) with whole contracts, and measure how far the actual risk drifts from the planned one.
- Compute Wilder's ATR, compare it with TA-Lib's and understand why a fixed percentage stop changes meaning with volatility.
- Measure in a backtest with costs what fixed, ATR, trailing and time stops add and what they cost, with a control of random entries and data without trend.
- Compute the risk of ruin with the gambler's formula, the diffusion formula and Lundberg's, compare them with a simulation and explain why risk per trade is not portfolio risk.

## 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 $f$ 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 = \left\lfloor \frac{K \cdot f}{d \cdot M \cdot P} \right\rfloor
$$

with $K$ the capital, $d$ the distance to the stop as a fraction of the price, $M$ the contract multiplier and $P$ the
price. The floor matters: futures only trade in whole contracts.

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

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

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

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

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

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

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

```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)
```
```output
                   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.* Two histograms with half-R bars from −5 R to 10 R. Top, no stop: losses spread between 0 and −4.6 R and gains have a long tail, with 67 trades above 10 R. Bottom, with a 3 ATR stop marked at −1 R (highlighted line): losses pile up between 0 and −1.5 R, but some trades reach −3.7 R, and the gain tail remains, with 49 trades above 10 R.

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:

```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%}")
```
```output
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 > \tfrac12$ of winning one unit and $1-p$ of losing it, and a capital of $U$ units, the
probability of ever losing everything is

$$
P_{\text{ruin}} = \left(\frac{1-p}{p}\right)^{U}
$$

```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}")
```
```output
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 $f$ of the **current** capital, and then the capital
is multiplied by $(1 + f\,R_i)$ on each trade. In logarithms it is a random walk with steps $x_i = \ln(1 + f R_i)$,
and losing half the capital means falling $D = \ln 2$. Two formulas:

- **Diffusion**: if the steps were normal, with mean $\mu$ and variance $\sigma^2$,
  $P_{\text{ruin}} = e^{-2\mu D/\sigma^2}$.
- **Lundberg** (Cramér-Lundberg): with $s > 0$ the root of $\mathbb{E}[e^{-s\,x}] = 1$, $P_{\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):

```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)
```
```output
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).* Three lines against risk per trade (0.5%, 1%, 2%, 3% and 5% of capital). Simulated probability of losing half the initial capital: 0.02%, 2.7%, 19.6%, 41.3% and 70.4%. The Lundberg formula gives almost the same: 0.04%, 2.6%, 21.1%, 42.7% and 75.4%. Probability of a 50% fall from a peak (highlighted): 0.15%, 22.2%, 94.5% and 100% from 3% on.

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:

```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}%")
```
```output
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.

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

The futures are synthetic and the trend is planted: the fact that the system wins validates the code, not the
strategy. What does carry over to real markets is the mechanics: stops change the shape of the distribution and cost
return, gaps and volatility changes run through them, and the risk of ruin depends on the definition of ruin (from
the start or from a peak) and on how many trades are open at the same time.

</aside>

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

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

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

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

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

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

   - A. One: you round up
   - B. 0.76 contracts
   - 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
   - D. It depends on the margin

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

   - A. Because 2022 had more sessions
   - 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
   - C. Because the stop is miscalculated
   - D. Because of opening gaps

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

   - 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%
   - B. Nothing: it is worse in every way
   - C. Higher mean return
   - D. Fewer trades

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

   - A. Because of a backtest bug
   - B. Because costs are 2.7 R
   - C. Because the stop only applies to long trades
   - 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

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

   - A. Because the formulas are useless
   - B. Because 1% is too little
   - C. Because the simulation has too few paths
   - 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%

   *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

### Research

- [Stochastic Processes - Random Walks](https://pythonparatrading.com/en/stochastic-processes/) · Research-0005 · 11 min
- [Volatility Targeting in Python](https://pythonparatrading.com/en/volatility-targeting-2/) · Research-0004 · 7 min

### Documentation

- [TA-Lib for Python: volatility indicators (ATR, NATR, TRANGE)](https://ta-lib.github.io/ta-lib-python/func_groups/volatility_indicators.html) · ta-lib.github.io
- [SciPy: scipy.optimize.brentq](https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.brentq.html) · docs.scipy.org

### Data

- [Level 2 data: continuous futures (Parquet)](https://pythonparatrading.com/curso/datos/nivel-2/futuros2_continuos.parquet) · Download · futuros2_continuos.parquet
- [Level 2 data: KAPA 5-minute bars (Parquet)](https://pythonparatrading.com/curso/datos/nivel-2/indice2_5min.parquet) · Download · indice2_5min.parquet

Previous lesson: [4.1.8 · Portfolio tools: Riskfolio-Lib, PyPortfolioOpt, skfolio and bt](https://pythonparatrading.com/en/python-trading-course/level-4/riskfolio-pyportfolioopt-skfolio-and-bt/)

Next lesson: [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/)
