Monte Carlo bootstrap
Lesson 18 · about 13 min
A backtest shows one path: the one history happened to take. The trades could have arrived in a different order, a few of the best could have been missed, or the losses could have clustered. The strategy's true drawdown risk is a distribution, and the historical curve is one draw from it. Bootstrapping resamples what you have to sketch that distribution. It is cheap, it is honest about its own limits, and it changes how you read a single max-drawdown number.
Reshuffling trades
The simplest version: take the list of trade returns, shuffle their order many times, and compound each ordering into an equity curve. The final equity is the same every time (the product does not care about order), but the drawdowns differ, and their spread is what you want.
import numpy as np
import pandas as pd
from src.data import synthetic_ohlcv
from src.indicators import sma
def ma_crossover_signal(close, fast=20, slow=50):
f, s = sma(close, fast), sma(close, slow)
signal = (f > s).astype(float)
signal[s.isna()] = np.nan
return signal
def trade_returns(position: pd.Series, net: pd.Series) -> np.ndarray:
side = np.sign(position)
new_trade = (side != side.shift(1)) & (side != 0)
ids = new_trade.cumsum().where(side != 0, 0)
in_trade = ids > 0
return (1 + net[in_trade]).groupby(ids[in_trade]).prod().to_numpy() - 1
def max_dd_from_returns(r: np.ndarray) -> float:
eq = np.cumprod(1 + r)
peak = np.maximum.accumulate(eq)
return float((eq / peak - 1).min())
bars = synthetic_ohlcv(2000, seed=42)
asset_ret = bars["close"].pct_change()
position = ma_crossover_signal(bars["close"]).shift(1).fillna(0.0)
net = (position * asset_ret).fillna(0.0) - position.diff().abs().fillna(0.0) * 5 / 10_000
trades = trade_returns(position, net)
print(f"{len(trades)} trades, historical max DD (trade level): {max_dd_from_returns(trades):.1%}")
rng = np.random.default_rng(0)
shuffled_dd = np.array([max_dd_from_returns(rng.permutation(trades)) for _ in range(2000)])
print(f"shuffled: median DD {np.median(shuffled_dd):.1%}, 5th pct {np.percentile(shuffled_dd, 5):.1%}, "
f"95th pct {np.percentile(shuffled_dd, 95):.1%}")
Read the 5th percentile as "one time in twenty, the same trades in a different order produced a drawdown this deep or deeper". If your risk plan says you would stop trading at a 20% drawdown and the 5th percentile is 25%, the plan and the strategy are incompatible even though the historical curve looked fine.
Resampling with replacement
Shuffling keeps exactly the same trades. Sampling with replacement lets some trades appear twice and others not at all, which also varies the final equity and captures "what if the two best trades had not happened".
def bootstrap_paths(r: np.ndarray, n_paths: int, seed: int = 0) -> np.ndarray:
rng = np.random.default_rng(seed)
idx = rng.integers(0, len(r), size=(n_paths, len(r)))
return r[idx] # shape (n_paths, n_trades)
paths = bootstrap_paths(trades, 2000)
finals = np.prod(1 + paths, axis=1)
dds = np.array([max_dd_from_returns(p) for p in paths])
print(f"final equity: median {np.median(finals):.2f}, 5th pct {np.percentile(finals, 5):.2f}, "
f"95th pct {np.percentile(finals, 95):.2f}")
print(f"P(final equity < 1): {(finals < 1).mean():.1%}")
print(f"max DD: median {np.median(dds):.1%}, 5th pct {np.percentile(dds, 5):.1%}")
P(final equity < 1) is a useful single number: across resampled histories, how often would the strategy have lost money? For a real edge over enough trades it should be small. For the synthetic random walk it is around a half, as it should be, because there is no edge.
Bootstrapping daily returns and the block problem
You can bootstrap the daily return series instead of trades, which gives more samples but has a flaw: daily returns are not independent. Volatility clusters, trends persist for a few days, and an IID resample destroys that structure, understating drawdowns. The fix is a block bootstrap: resample contiguous blocks of, say, 20 days.
def block_bootstrap(r: np.ndarray, block: int, n_paths: int, seed: int = 0) -> np.ndarray:
rng = np.random.default_rng(seed)
n_blocks = int(np.ceil(len(r) / block))
starts = rng.integers(0, len(r) - block + 1, size=(n_paths, n_blocks))
offsets = np.arange(block)
idx = (starts[:, :, None] + offsets[None, None, :]).reshape(n_paths, -1)[:, : len(r)]
return r[idx]
daily = net.to_numpy()
iid_dd = np.array([max_dd_from_returns(p) for p in bootstrap_paths(daily, 1000)])
blk_dd = np.array([max_dd_from_returns(p) for p in block_bootstrap(daily, 20, 1000)])
print(f"IID daily bootstrap 5th pct DD: {np.percentile(iid_dd, 5):.1%}")
print(f"block(20) bootstrap 5th pct DD: {np.percentile(blk_dd, 5):.1%}")
The block version usually shows deeper tail drawdowns, because it lets bad stretches stay together. Neither is truth; the block version is less wrong.
Key idea: The historical drawdown is one draw. Resample trades (shuffle, or with replacement) to see the distribution, and read the 5th percentile as the drawdown you should plan to survive. Use block bootstraps on daily returns so that clustering is not destroyed.
Plotting the fan
import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(10, 5))
for p in paths[:200]:
ax.plot(np.cumprod(1 + p), color="grey", alpha=0.15, linewidth=0.8)
ax.plot(np.cumprod(1 + trades), color="black", linewidth=2, label="historical order")
ax.set_title("Bootstrapped trade-level equity paths")
ax.set_xlabel("trade number")
ax.legend()
plt.tight_layout()
plt.show()
If the historical black line runs along the top edge of the grey fan, history was kind and you should expect worse. If it runs through the middle, the historical result is representative of what the resampled trades produce.
What the bootstrap cannot tell you
- It only resamples what happened. A regime that never occurred in the sample is not in any resample.
- It assumes the trades are exchangeable, which is false if the strategy's edge came from one period.
- It says nothing about whether the edge is real; it describes variance given the sample. Module 8 addresses whether to believe the sample at all.
Used for what it is, a picture of the variance hiding behind one curve, it is the cheapest improvement you can make to how you read a backtest.
Try it: Run the trade-shuffle bootstrap on the crossover and record the 5th-percentile drawdown. Now remove the single best trade from the list and repeat. Then remove the best three. A strategy whose 5th percentile collapses after removing three trades is a strategy that made its money in three trades, and the bootstrap has just told you so.
Recap
- Shuffling trade order keeps final equity fixed and reveals the drawdown distribution.
- Resampling with replacement also varies final equity and answers "what if the best trades were missed".
- Read the 5th percentile of max drawdown as the number to plan around, not the historical figure.
- Bootstrap daily returns in blocks so that volatility clustering survives.
- The bootstrap describes variance inside the sample; it cannot validate the edge itself.
See it drawn
Original diagrams for the ideas on this page. Illustrative, not real market data.