Monte Carlo Simulation for Trading Strategies in Python

A backtest equity curve is a single line on a chart, but it represents only one realization out of an enormous space of possible outcomes. Monte Carlo simulation is the standard technique quantitative researchers use to stress-test that single path and estimate the range of drawdowns a strategy could plausibly produce, even when the underlying edge is genuine and stable.
Why one backtest path is not enough
When you run a backtest, you get one specific sequence of trades in one specific order. That sequence is shaped by the exact historical path the market happened to take. If the same set of trades — same win rate, same average win, same average loss — had occurred in a different order, the equity curve would look completely different, and critically, the maximum drawdown would be different too.
This is known as sequence risk. Consider a strategy with 100 trades: 60 winners of +1% and 40 losers of -1.2%. The final cumulative return is fixed regardless of order, but if the 40 losers cluster together near the start of the sequence, the drawdown experienced along the way can be several times larger than if those same losers were spread evenly across the sample. A single historical backtest shows you exactly one of these orderings — the one that happened to occur — and gives no direct information about how unlucky (or lucky) that ordering was relative to the full distribution of possible orderings.
This matters enormously for risk management. Position sizing, leverage decisions, and capital allocation should not be based on the drawdown of one arbitrary path. They should be based on a distribution of plausible drawdowns, which is exactly what Monte Carlo resampling provides.

Photo: SHVETS production (Pexels)
The bootstrap methodology
The core idea, formalized in the statistical bootstrap literature and widely used in quantitative finance, is to resample the historical returns with replacement to generate many synthetic alternative return sequences. Each synthetic sequence uses the exact same pool of historical returns — so it preserves the empirical distribution's mean, variance, skewness, and fat tails — but shuffles the order (and, in the naive version, the multiplicity) of those returns.
This is fundamentally different from assuming returns follow a Gaussian distribution and simulating from a normal random number generator. Financial returns are well known to exhibit fat tails and volatility clustering — a point central to Nassim Taleb's writing on the fragility of models that treat rare, extreme moves as if they were negligible-probability normal-distribution events. A bootstrap resampling approach sidesteps this problem entirely: it never assumes a parametric distribution. It only assumes that the past pool of realized returns is a reasonable sample of the return-generating process, and it lets the empirical data speak for itself, tails included.
Concretely, the procedure is:
- Take the historical returns series of a strategy (daily or per-trade returns).
- Draw N values from that series with replacement, N being the number of periods you want to simulate.
- Compound those draws into a synthetic equity curve.
- Repeat this thousands of times.
- For each simulated equity curve, compute the maximum drawdown.
- Look at the distribution of these maximum drawdowns across all simulations to estimate a realistic worst-case range.
A complete, vectorized Python implementation
The following script performs the bootstrap simulation using NumPy's vectorized np.random.choice, which is dramatically faster than looping through resamples one at a time in pure Python. On a modern machine, 1000+ resamples of a few thousand periods each complete in well under a second this way.
import numpy as np
import pandas as pd
def run_monte_carlo_bootstrap(returns: pd.Series, n_simulations: int = 1000, seed: int = 42):
"""
Bootstrap Monte Carlo simulation of a strategy's return series.
Parameters
----------
returns : pd.Series
Historical periodic returns of the strategy (e.g. daily returns, as decimals).
n_simulations : int
Number of synthetic equity paths to generate.
seed : int
Random seed for reproducibility.
Returns
-------
dict with:
'equity_curves': ndarray of shape (n_simulations, n_periods) of synthetic equity paths
'max_drawdowns': ndarray of shape (n_simulations,) of each path's max drawdown (negative values)
'historical_equity': the actual historical equity curve for comparison
"""
rng = np.random.default_rng(seed)
returns_array = returns.dropna().to_numpy()
n_periods = len(returns_array)
# Vectorized resampling: draw (n_simulations x n_periods) indices at once,
# with replacement, instead of looping n_simulations times in Python.
sampled_indices = rng.choice(n_periods, size=(n_simulations, n_periods), replace=True)
sampled_returns = returns_array[sampled_indices] # shape: (n_simulations, n_periods)
# Build equity curves: cumulative product of (1 + return) along each row, starting at 1.0
equity_curves = np.cumprod(1 + sampled_returns, axis=1)
# Running maximum along each simulated path (the "high water mark")
running_max = np.maximum.accumulate(equity_curves, axis=1)
# Drawdown at each point in time = (equity - running_max) / running_max
drawdown_series = (equity_curves - running_max) / running_max
# Maximum drawdown per simulated path (most negative value along the row)
max_drawdowns = drawdown_series.min(axis=1)
# Build the actual historical equity curve for comparison
historical_equity = np.cumprod(1 + returns_array)
return {
"equity_curves": equity_curves,
"max_drawdowns": max_drawdowns,
"historical_equity": historical_equity,
}
def summarize_drawdown_risk(max_drawdowns: np.ndarray):
"""
Extract confidence-interval drawdown levels from the simulated distribution.
"""
# np.percentile on negative drawdown values: the 95th percentile of the
# *distribution of max drawdowns* corresponds to a "1-in-20" bad outcome.
dd_95 = np.percentile(max_drawdowns, 5) # 5th percentile = worse 5% of outcomes
dd_99 = np.percentile(max_drawdowns, 1) # 1st percentile = worse 1% of outcomes
dd_median = np.percentile(max_drawdowns, 50)
return {
"median_max_drawdown": dd_median,
"drawdown_95pct_confidence": dd_95,
"drawdown_99pct_confidence": dd_99,
}
if __name__ == "__main__":
# Example: synthetic daily returns series standing in for a real backtest output
np.random.seed(0)
example_returns = pd.Series(np.random.normal(loc=0.0006, scale=0.011, size=1500))
results = run_monte_carlo_bootstrap(example_returns, n_simulations=1000)
stats = summarize_drawdown_risk(results["max_drawdowns"])
print(f"Median simulated max drawdown: {stats['median_max_drawdown']:.2%}")
print(f"95% confidence worst drawdown: {stats['drawdown_95pct_confidence']:.2%}")
print(f"99% confidence worst drawdown: {stats['drawdown_99pct_confidence']:.2%}")
The key numbers to report to a risk committee, or to yourself before sizing a strategy, are drawdown_95pct_confidence and drawdown_99pct_confidence. These tell you: "in 95% of plausible reorderings of my historical edge, the drawdown stayed shallower than X" — a far more honest risk statement than "my backtest's max drawdown was Y."
Visualizing the fan chart
A fan chart overlays every simulated equity path alongside the actual historical path, making the dispersion of outcomes immediately visible.
import matplotlib.pyplot as plt
def plot_fan_chart(results: dict, n_paths_to_plot: int = 200):
equity_curves = results["equity_curves"]
historical_equity = results["historical_equity"]
fig, ax = plt.subplots(figsize=(11, 6))
# Plot a subsample of simulated paths with low opacity to show the "fan" of outcomes
n_to_plot = min(n_paths_to_plot, equity_curves.shape[0])
for i in range(n_to_plot):
ax.plot(equity_curves[i], color="steelblue", alpha=0.05, linewidth=0.8)
# Overlay the median simulated path
median_path = np.median(equity_curves, axis=0)
ax.plot(median_path, color="steelblue", linewidth=2, label="Median simulated path")
# Overlay the actual historical equity curve
ax.plot(historical_equity, color="black", linewidth=2, label="Actual historical path")
ax.set_title("Monte Carlo Bootstrap: Simulated Equity Paths vs Historical Path")
ax.set_xlabel("Period")
ax.set_ylabel("Equity (normalized, start = 1.0)")
ax.legend(loc="upper left")
ax.grid(alpha=0.3)
fig.tight_layout()
return fig
fig = plot_fan_chart(results)
plt.savefig("monte_carlo_fan_chart.png", dpi=150)
If the actual historical path sits near the upper edge of the fan rather than in its middle, that alone is worth investigating: it may suggest the specific historical ordering was more favorable than a "typical" draw from the same underlying return distribution.
Monte Carlo fan chart: 5th, 50th and 95th percentiles
Limitations: naive i.i.d. bootstrap vs block bootstrap
The implementation above uses an independent and identically distributed (i.i.d.) bootstrap — each period is resampled independently, with no regard for its neighbors. This is fast and simple, but it has an important weakness: it destroys any autocorrelation and volatility clustering present in the original returns series.
Real financial return series are rarely i.i.d. Periods of high volatility tend to cluster together, and returns often exhibit short-term autocorrelation (whether from momentum, mean reversion, or market microstructure effects). Naive resampling shuffles individual periods independently, which can understate the likelihood of extended drawdown streaks driven by clustered bad periods, or conversely, break up genuine clusters of favorable conditions.
The more rigorous alternative is the block bootstrap: instead of resampling individual returns, you resample contiguous blocks of consecutive returns (e.g., 10- or 20-day blocks), preserving the local dependence structure within each block while still randomizing the overall sequence. Block length itself becomes a parameter to calibrate — too short and you're back to approximately i.i.d. behavior, too long and you lose the randomization benefit entirely. Practitioners often test a range of block lengths and confirm that drawdown estimates are reasonably stable across them before trusting the result.
This connects directly to a broader theme in Marcos López de Prado's "Advances in Financial Machine Learning": naive statistical validation techniques imported from cross-sectional machine learning frequently fail when applied unmodified to time-dependent financial data. Bootstrap methodology is no exception — it must be adapted to respect the serial structure of returns, not applied blindly.
Monte Carlo simulation is not a replacement for careful validation of a strategy's underlying logic. It also cannot detect selection bias introduced by testing many strategy variants and reporting only the best one — for that class of problem, tools that estimate the probability of backtest overfitting, such as those described by David Bailey and López de Prado, are more appropriate, and our related article on the data-snooping ratio and the deflated Sharpe ratio covers that topic directly. What Monte Carlo bootstrap resampling does give you is a much more honest picture of the drawdown risk embedded in a strategy's own historical return distribution, beyond the single path you happened to observe. For readers who want a structured walkthrough of building this kind of audit process end to end, TrueTrueVerdikt's ebook on risk management methodology covers the sizing and capital-allocation decisions that typically follow this kind of simulation. If you'd rather run this analysis on your own equity curve without writing the code yourself, TrueVerdikt's statistical backtest-audit tool at /outils automates bootstrap resampling alongside checks for insufficient sample size and inflated Sharpe ratios from multiple testing.
Disclaimer: This article is provided for educational and informational purposes only. It does not constitute financial, investment, or trading advice. Trading and investing involve substantial risk of loss. Past performance, backtested or live, is not indicative of future results. Always conduct your own research and consult a licensed financial advisor before making investment decisions.
From theory to practice
Measure and control a strategy's risk: drawdown, VaR, Sortino, Calmar and Omega ratios, position sizing and the Kelly criterion.
Analyse portfolio risk
