Short answer: Monte Carlo backtesting reruns a backtest's results many times with random variation, either by resampling its trades or by simulating new price paths, to show the range of drawdowns and outcomes the same rules could have produced. It measures path luck, not edge: it cannot repair bad data, look-ahead bias, selection among many trials or a regime change, and it depends on assumptions such as independent trades. A backtest, simulated or not, does not guarantee future results.
Measure the drawdown of an equity series in your browser: Max drawdown calculator.
A common version of Monte Carlo backtesting shuffles a list of trades and draws a fan of equity curves. This guide tests that and the alternatives in Python on seeded synthetic data, where the true distribution is known. Every number is synthetic, produced on 6 November 2026 with Python 3.11.3 and NumPy 2.0.0, and describes no strategy or market.
The biases that fake backtest profits are covered in backtesting pitfalls and the tools in Python backtesting libraries compared.
Monte Carlo backtesting for algorithmic trading strategies: the methods
Each method replaces the one historical sequence with many alternatives, and each rests on a different assumption.
| Method | What is randomized | What it can show | Assumption that carries it |
|---|---|---|---|
| Trade shuffle | Order of the same trades | Drawdowns and losing streaks from ordering alone | Every order is equally likely; size does not depend on the path |
| Trade bootstrap | Which trades occur, drawn with replacement | End values and drawdowns from a different mix | Trades are independent and the sample is representative |
| Block bootstrap | Runs of consecutive trades or returns | The same, keeping short streaks | Dependence is shorter than the blocks |
| Path simulation | New price series, with the rules rerun on each | Behavior of rules and software on many paths | The generator keeps what the rules react to |
| Input perturbation | Costs, slippage, skipped trades, parameter values | Sensitivity to execution and exact settings | The ranges tried are realistic |
None of them adds market information beyond the sample; path simulation and perturbation add assumptions.
Monte Carlo trading simulation in Python: core code and tests
One function covers the three resampling methods; the tests check each method and the inputs that commonly break it.
import numpy as np
def check(r):
r = np.asarray(r, dtype=float)
if r.ndim > 1 or r.size < 2 or not np.isfinite(r).all() or (r <= -1).any():
raise ValueError("need 2+ finite returns above -100%")
return r
def resample(r, n, rng, method, mean_block=1.0):
r = check(r)
if not isinstance(n, (int, np.integer)) or n < 1 or not 1 <= mean_block < np.inf:
raise ValueError("bad n or mean_block")
if method == "shuffle": # same trades, new order
return rng.permuted(np.tile(r, (n, 1)), axis=1)
if method == "iid": # draw trades with replacement
return rng.choice(r, size=(n, r.size))
if method == "block": # stationary bootstrap, Politis and Romano (1994)
idx = np.empty((n, r.size), dtype=int)
idx[:, 0] = rng.integers(0, r.size, n)
jump = rng.random((n, r.size)) < 1 / mean_block
start = rng.integers(0, r.size, (n, r.size))
for t in range(1, r.size):
idx[:, t] = np.where(jump[:, t], start[:, t], (idx[:, t - 1] + 1) % r.size)
return r[idx]
raise ValueError(f"unknown method {repr(method)}")
def max_drawdown(paths): # rows of fractional trade returns
eq = np.cumprod(1 + np.atleast_2d(paths), axis=1)
peak = np.maximum.accumulate(np.hstack([np.ones((len(eq), 1)), eq]), axis=1)[:, 1:]
return (1 - eq / peak).max(axis=1)
# tests
r, rng = check([0.01, -0.02, 0.03, -0.005]), np.random.default_rng(0)
s, b = resample(r, 100, rng, "shuffle"), resample(r, 100, rng, "iid")
assert (np.sort(s) == np.sort(r)).all() and len(np.unique(s, axis=0)) > 1
assert b.shape == s.shape and np.isin(b, r).all() and not (np.sort(b) == np.sort(r)).all()
assert np.allclose(max_drawdown([[0.1, -0.5, 0.2], [-0.5, 0.2, 0.1]]), 0.5)
steps = np.rint(np.diff(resample(np.arange(1, 201) / 1e4, 10_000, rng, "block", 20), axis=1) * 1e4) % 200
assert abs(1 - (steps == 1).mean() - 1 / 20) < 0.001 # new block on 1 step in 20
cases = [{"r": [], "method": "shuffle"}, {"r": [[0.01, 0.02], [0.03, 0.04]], "method": "iid"},
{"r": [0.01, float("1e400")]}, {"r": [0.01, -1.0]}, {"n": 0}, {"n": 2.5},
{"mean_block": float("nan")}, {"method": "shufle"}]
for case in cases:
try:
resample(**({"r": r, "n": 9, "rng": rng, "method": "block", "mean_block": 5.0} | case))
except ValueError:
continue
raise AssertionError(f"accepted {case}")
print("all checks passed")all checks passedThe checks reject what tends to arrive from a CSV export: an empty or nested column, 1e400, which parses to infinity, and a return of -100% or worse. An unknown method raises an error instead of falling back silently. Each test failed when the code it guards was broken on purpose. The arch package documentation (opens in a new tab), read 6 November 2026, offers stationary, circular and moving block bootstraps and a block-length estimate.
Shuffling trades vs bootstrapping them
Shuffling the same trades never changes the end result, only the path; a bootstrap changes both but cannot produce a trade worse than the worst in the sample.
rng = np.random.default_rng(7)
r = check(rng.standard_t(4, 200) / 100) # 200 synthetic trade returns, true mean zero
print("observed: worst trade", r.min().round(4), "| max drawdown", max_drawdown(r)[0].round(3))
for m in ("shuffle", "iid"):
p = resample(r, 10_000, rng, m)
end, dd = np.prod(1 + p, axis=1), max_drawdown(p)
print(m, "| end value min/max", end.min().round(3), end.max().round(3),
"| drawdown p50/p95", np.percentile(dd, [50, 95]).round(3), "| worst trade", p.min().round(4))observed: worst trade -0.0586 | max drawdown 0.415
shuffle | end value min/max 0.604 0.604 | drawdown p50/p95 [0.413 0.449] | worst trade -0.0586
iid | end value min/max 0.322 1.117 | drawdown p50/p95 [0.419 0.552] | worst trade -0.0586Reading the output (synthetic, seed 7). All 10,000 shuffled paths end at 0.604, like the original 200 trades, because multiplication does not depend on order. Every path ends 39.6% down, so no ordering can show a smaller drawdown; the shuffle moves it only from 0.413 at the median to 0.449 at the 95th percentile. The bootstrap spreads the end value from 0.322 to 1.117, yet its worst trade in two million draws is -5.86%, the worst trade observed.
The same holds when profits add with fixed-size positions, but not when size depends on the path, such as cutting size after losses. And if the history never held a gap or an outage, no resample of it will.
Why the simple bootstrap understates drawdown when losses cluster
When wins and losses come in streaks, drawing trades independently breaks the streaks and makes drawdowns look smaller than they are.
def clustered(rng, n, trades=200, stay=0.95, mu=0.004, sd=0.01):
# synthetic trades whose winning and losing streaks persist; mean zero overall
s = np.empty((n, trades), dtype=bool)
s[:, 0] = rng.random(n) < 0.5
flip = rng.random((n, trades)) > stay
for t in range(1, trades):
s[:, t] = s[:, t - 1] ^ flip[:, t]
return np.where(s, mu, -mu) + rng.normal(0, sd, (n, trades))
rng = np.random.default_rng(42)
print("true p95 drawdown:", np.percentile(max_drawdown(clustered(rng, 20_000)), 95).round(3))
histories = clustered(rng, 100) # 100 one-off "backtests" of 200 trades
for label, m, b in (("iid", "iid", 1), ("block 5", "block", 5), ("block 20", "block", 20)):
est = [np.percentile(max_drawdown(resample(h, 2_000, rng, m, b)), 95) for h in histories]
print(f"{label:8} mean {np.mean(est):.3f} | range {min(est):.3f} to {max(est):.3f}")true p95 drawdown: 0.437
iid mean 0.307 | range 0.127 to 0.611
block 5 mean 0.355 | range 0.117 to 0.643
block 20 mean 0.376 | range 0.130 to 0.630Because the generator is known, the true 95th-percentile drawdown can be estimated: 0.437 here, 0.433 from a million paths. Averaged over 100 synthetic backtests, the simple bootstrap estimated 0.307, about 30% too low; blocks with mean lengths of 5 and 20 trades gave 0.355 and 0.376, less biased but still low, even at a block length matched to the generator, which real data does not reveal.
The ranges matter more: one backtest's estimate ran from 0.127 to 0.611, because the luck of a single history dominates and more simulations do not shrink it.
Can Monte Carlo simulation detect an overfit strategy?
Not when the overfitting comes from picking the best of many variants: resampling the winner's trades repeats the luck that selected it.
rng = np.random.default_rng(11)
trials = rng.standard_t(4, (200, 100)) / 100 # 200 variants, 100 trades each, all mean zero
best = trials[np.argmax(np.prod(1 + trials, axis=1))]
end = np.prod(1 + resample(best, 10_000, rng, "iid"), axis=1)
print("best variant end value:", np.prod(1 + best).round(3))
print("bootstrap paths above 1:", (end > 1).mean().round(3), "| p5 end value:", np.percentile(end, 5).round(3))
fresh = np.prod(1 + rng.standard_t(4, (10_000, 100)) / 100, axis=1)
print("fresh trades, same process, above 1:", (fresh > 1).mean().round(3))best variant end value: 1.492
bootstrap paths above 1: 0.994 | p5 end value: 1.144
fresh trades, same process, above 1: 0.48All 200 synthetic variants have a true mean of zero per trade. The best ended at 1.492, and 99.4% of its bootstrap paths ended above the start, with a 5th percentile of 1.144. New trades from the same process ended above the start 48% of the time. Record every trial, test on data that played no part in the choice and correct for the number of trials, as backtesting pitfalls explains.
Path simulation: the generator decides what survives
Path simulation reruns the rules on new price series, so its answer depends on which market features the generator keeps.
def ac1(x): # lag-1 autocorrelation of each row
x = x - x.mean(axis=-1, keepdims=True)
return (x[..., 1:] * x[..., :-1]).sum(axis=-1) / (x * x).sum(axis=-1)
rng = np.random.default_rng(5)
x = np.zeros(2_000) # synthetic returns, autocorrelated by construction
for t in range(1, x.size):
x[t] = 0.2 * x[t - 1] + rng.normal(0, 0.01)
print("history:", ac1(x).round(3))
print("normal paths:", ac1(rng.normal(x.mean(), x.std(), (1_000, x.size))).mean().round(3))
for m, b in (("iid", 1), ("block", 20)):
print(m, "paths:", ac1(resample(x, 1_000, rng, m, b)).mean().round(3))history: 0.229
normal paths: -0.0
iid paths: -0.0
block paths: 0.216The synthetic history has a lag-1 autocorrelation of 0.229. Normal paths with the same mean and volatility, a common random-walk generator, have none, nor do simple bootstrap paths; block paths keep 0.216. A rule that reacts to this autocorrelation looks like noise on the first two generators and like the history on the third: the simulation reports the generator's assumption back. Volatility clustering, fat tails and gaps work the same way, untested here.
Path simulation tests software better than edge: across thousands of paths, check that position limits hold and a drawdown stop fires, the controls in trading bot risk management.
What a backtest cannot show, with or without Monte Carlo
A Monte Carlo backtest inherits every flaw of the backtest it resamples and adds the assumptions of its own method.
- Bad data and look-ahead. Missing delisted symbols, bad ticks or a signal that uses a closing price too early are resampled with everything else.
- Costs and fills. A fill assumed at the backtest price repeats in every path; measure slippage and transaction costs separately.
- Regime change. A market unlike the sample appears in no resample of it.
- Capacity. Historical trades do not record how larger orders move prices.
- Operations. Outages, rejected orders and restarts are absent from price history; paper trading tests some of them.
Report the method, number of paths, seed, block length, sample size and percentile with any Monte Carlo figure.
In the United States, 17 CFR 4.41(b)(1), read 6 November 2026, requires simulated performance of commodity pool operators and trading advisors to carry a prescribed statement, such as one saying the results have "certain inherent limitations". The SEC marketing rule, 17 CFR 275.206(4)-1(e)(8), treats backtested and projected returns as hypothetical performance; an investor-run simulation tool escapes that label only with disclosed methodology, limitations and key assumptions, among other conditions. Rules differ by country; take legal advice before publishing simulated results. Automated trading can lose money.
Frequently asked questions
How many Monte Carlo simulations are enough for a backtest?
Enough that the reported figure stops changing at the precision you report when you rerun with another seed. More paths cut simulation noise; they cannot cut the uncertainty of a short history.
Does a good Monte Carlo result mean a strategy will work?
No. It shows what the sampled trades could produce under the method's assumptions and adds no information about the future.
What is the difference between Monte Carlo and walk-forward analysis?
Walk-forward analysis tests rules on data not used to choose them; Monte Carlo resamples results already obtained.
Can Monte Carlo set a drawdown limit for a trading bot?
It can describe the drawdowns a sample implies under stated assumptions. The limit is your decision, and one drawn from a short or clustered sample inherits its blind spots.
Sources
- arch documentation: Time-series bootstraps (stationary, circular and moving block), read 6 November 2026 (opens in a new tab)
- arch documentation: StationaryBootstrap, Politis and Romano (1994), block_size as average block size (opens in a new tab)
- arch documentation: optimal_block_length, Politis and White (2004) with the 2009 correction (opens in a new tab)
- NumPy v2.5 manual: Generator.permuted, each slice shuffled independently (opens in a new tab)
- 17 CFR 4.41: advertising by commodity pool operators and commodity trading advisors, hypothetical results statement (Cornell LII) (opens in a new tab)
- 17 CFR 275.206(4)-1: investment adviser marketing rule, hypothetical performance and interactive analysis tools (Cornell LII) (opens in a new tab)
This guide is general information about software and markets, not investment, legal or tax advice. Automated trading can lose money, past or backtested performance does not guarantee future results, and the rules that apply depend on your jurisdiction and your exchange's terms. Sources, checks and corrections: see the editorial policy.