Skip to content
GetProfitable
Search

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.

An equity curve and its drawdownAn account balance rising over a year, falling from a peak to a trough, then climbing back to the old peak.ACCOUNT EQUITY$20k$12k$8k024681012TIME (MONTHS)PEAK $16,000TROUGH $12,000DRAWDOWN−25%RECOVERY
Equity curve and drawdown. An account balance plotted month by month. The fall from the $16,000 peak to the $12,000 trough is a 25% drawdown, and the shaded area lasts until the balance climbs back to the old peak.
The spread of outcomes behind an expectancyA histogram of forty trades: a tall block of small losses on the left, a low spread of larger wins on the right, and a line marking the average outcome.NUMBER OF TRADES051024 LOSSES, AVG −$20016 WINS, AVG +$600EXPECTANCY +$120−$400−$200$0+$200+$400+$600+$800PROFIT OR LOSS PER TRADEexpectancy = (40% × $600) − (60% × $200) = +$120 per trade
Expectancy: the average trade. Forty trades sorted by outcome: 24 small losses and 16 larger wins. Weighting each side by how often it happens gives the average result per trade, marked here by the dashed line at +$120.
A fast and a slow moving average crossingA jagged price line with two smoother average lines through it; the fast average dips below the slow one on the left and cuts back above it in the middle, where a circle marks the crossing.pricefast averageslow averagefast crosses belowfast crosses abovethe slow averageAverages of recent closes; the fast one reacts sooner than the slow one.
Fast and slow moving averages crossing. A moving average is the average of the last few closing prices, redrawn each period. An average over fewer periods turns sooner than one over many, so the two lines cross whenever the recent pace of the market changes.

Finished this module? Take the module quiz.