Bootstrap Monte Carlo : comment obtenir des intervalles de confiance pour un backtest en 10 lignes de code
📄 Cet article a donné naissance à un article de recherche. Les intervalles de confiance bootstrap décrits ici sont soumis à un test de couverture contrôlé sous dépendance sérielle (iid vs bootstrap au niveau des trades vs block bootstrap, 6 000 expériences avec vérité exactement connue). Lisez l'article en ligne (version interactive + PDF) sur bootstrap.marketmaker.cc, code et données sur github.com/suenot/bootstrap-coverage.
Vous avez exécuté une stratégie via un backtest. Vous avez obtenu un PnL de +42 %, un Sharpe de 1,8, un MaxDD de -12 %. Les résultats semblent excellents. Vous lancez le bot en production, et un mois plus tard vous découvrez que le drawdown est déjà à -28 % et que le PnL se dirige vers zéro.
Qu'est-ce qui a mal tourné ? Ce n'est ni un bug ni "un marché qui a changé". Le problème, c'est que vous avez pris une décision basée sur un seul chiffre — une estimation ponctuelle. Vous avez appris que la stratégie affichait +42 %, mais vous n'avez pas appris à quel point vous pouviez faire confiance à ce chiffre.
Le problème des estimations ponctuelles
Un seul point de données (à gauche) donne une image trompeuse, tandis que la distribution complète (à droite) révèle la véritable étendue des résultats possibles.
Un backtest sur des données historiques est une seule exécution à travers une séquence spécifique d'événements de marché. Le résultat dépend de l'ordre des trades : la même stratégie avec les mêmes trades, mais dans un ordre différent, peut afficher un drawdown maximal complètement différent.
Imaginez 491 trades. Chaque trade est un événement aléatoire avec une certaine distribution de rendements. Le backtest historique ne montre qu'une seule réalisation de ce processus. C'est comme lancer un dé une fois et en conclure que le dé tombe toujours sur quatre.
Ce dont nous avons réellement besoin :
- Pas une estimation ponctuelle, mais un intervalle : "avec 95 % de probabilité, le PnL final se situera entre X et Y"
- Pas un drawdown maximal unique, mais une distribution : "dans les 5 % de pires scénarios, le drawdown dépasse Z %"
- Pas la moyenne, mais les queues de distribution : que se passe-t-il si la chance ne vous sourit pas ?
C'est exactement à cela que sert le bootstrap Monte Carlo.
Qu'est-ce que le bootstrap Monte Carlo
Le bootstrap génère des milliers de trajectoires d'equity alternatives par rééchantillonnage des trades avec remise à partir du jeu de données d'origine.
Le bootstrap est une méthode de rééchantillonnage proposée par Bradley Efron en 1979. L'idée est élégante : si nous disposons d'un échantillon de données, nous pouvons générer des milliers de "nouveaux" échantillons en sélectionnant aléatoirement des éléments de l'échantillon d'origine avec remise.
Dans le contexte d'un backtest, cela fonctionne ainsi :
- Vous disposez d'un tableau de rendements pour chaque trade — par exemple 491 valeurs
- Vous sélectionnez aléatoirement 491 valeurs de ce tableau avec remise — certains trades apparaîtront deux fois, d'autres n'apparaîtront pas du tout
- Vous construisez une courbe d'equity à partir de ce nouvel échantillon
- Vous répétez l'opération 10 000 fois
- Vous obtenez une distribution de métriques finales, et non un chiffre unique
Chaque itération est un "scénario alternatif" : ce qui aurait pu se passer si l'ordre et l'ensemble des trades avaient été légèrement différents.
Implémentation en 10 lignes
Voici une implémentation complète et fonctionnelle :
import numpy as np
def max_drawdown(equity_curve):
"""Calculate the maximum drawdown of an equity curve."""
peak = np.maximum.accumulate(equity_curve)
drawdown = (equity_curve - peak) / peak
return drawdown.min()
trade_returns = [...] # 491 values, e.g. [0.012, -0.005, 0.008, ...]
n_simulations = 10000
results = []
for _ in range(n_simulations):
sampled = np.random.choice(trade_returns, size=len(trade_returns), replace=True)
equity = np.cumprod(1 + sampled)
results.append({
"final_pnl": equity[-1] - 1,
"max_dd": max_drawdown(equity),
"sharpe": np.mean(sampled) / np.std(sampled) * np.sqrt(252)
})
Temps d'exécution : ~2 secondes sur un ordinateur portable ordinaire. 10 000 historiques alternatifs de votre stratégie.
Extraire les intervalles de confiance
Intervalles de confiance pour les métriques clés de la stratégie : PnL, MaxDD et Sharpe Ratio, montrant les bandes des percentiles 5, 50 et 95.
Nous avons maintenant non pas un chiffre, mais une distribution. Voici comment en extraire des informations utiles :
import pandas as pd
df = pd.DataFrame(results)
pnl_5 = np.percentile(df['final_pnl'], 5)
pnl_50 = np.percentile(df['final_pnl'], 50)
pnl_95 = np.percentile(df['final_pnl'], 95)
dd_5 = np.percentile(df['max_dd'], 5) # 5th — worst case
dd_50 = np.percentile(df['max_dd'], 50)
dd_95 = np.percentile(df['max_dd'], 95) # 95th — best case
print(f"PnL: {pnl_5:.1%} | {pnl_50:.1%} | {pnl_95:.1%}")
print(f"MaxDD: {dd_5:.1%} | {dd_50:.1%} | {dd_95:.1%}")
print(f"Sharpe: {np.percentile(df['sharpe'], 5):.2f} — {np.percentile(df['sharpe'], 95):.2f}")
Exemple de sortie pour une stratégie réelle :
| Metric | 5th percentile (worst) | Median | 95th percentile (best) |
|---|---|---|---|
| PnL | +18.3% | +41.7% | +72.1% |
| MaxDD | -23.4% | -12.8% | -5.1% |
| Sharpe | 1.12 | 1.76 | 2.41 |
Désormais, la différence saute aux yeux :
- Le backtest affichait un PnL de +42 % — mais dans les 5 % de pires scénarios, le PnL n'est que de +18,3 %
- Le backtest affichait un MaxDD de -12 % — mais dans les 5 % de pires scénarios, le drawdown est de -23,4 %
- Sharpe de 1,8 — mais la borne inférieure est de 1,12
Le 5e percentile est votre "pire cas réaliste". Si la stratégie cesse d'être rentable au 5e percentile, la lancer en production est risqué.
Visualisation : Fan Chart
Le bootstrap Monte Carlo se visualise naturellement sous forme de fan chart — un éventail de courbes d'equity :
import matplotlib.pyplot as plt
fig, axes = plt.subplots(1, 2, figsize=(16, 6))
ax = axes[0]
for i in range(min(500, n_simulations)):
sampled = np.random.choice(trade_returns, size=len(trade_returns), replace=True)
equity = np.cumprod(1 + sampled)
ax.plot(equity, alpha=0.02, color='#4FC3F7')
all_equities = []
for _ in range(n_simulations):
sampled = np.random.choice(trade_returns, size=len(trade_returns), replace=True)
equity = np.cumprod(1 + sampled)
all_equities.append(equity)
all_equities = np.array(all_equities)
p5 = np.percentile(all_equities, 5, axis=0)
p50 = np.percentile(all_equities, 50, axis=0)
p95 = np.percentile(all_equities, 95, axis=0)
ax.fill_between(range(len(p5)), p5, p95, alpha=0.3, color='#7C4DFF', label='90% CI')
ax.plot(p50, color='#E040FB', linewidth=2, label='Median')
ax.set_title('Monte Carlo Bootstrap: Equity Curves')
ax.legend()
ax = axes[1]
ax.hist(df['final_pnl'] * 100, bins=80, color='#4FC3F7', alpha=0.7, edgecolor='#1A237E')
ax.axvline(pnl_5 * 100, color='#FF5252', linestyle='--', label=f'5th: {pnl_5:.1%}')
ax.axvline(pnl_50 * 100, color='#E040FB', linestyle='--', label=f'Median: {pnl_50:.1%}')
ax.axvline(pnl_95 * 100, color='#69F0AE', linestyle='--', label=f'95th: {pnl_95:.1%}')
ax.set_title('Distribution of Final PnL')
ax.set_xlabel('PnL, %')
ax.legend()
plt.tight_layout()
plt.savefig('monte_carlo_fan_chart.png', dpi=150)
plt.show()
Un fan chart offre une compréhension intuitive de la dispersion des résultats possibles. Un éventail étroit signifie que la stratégie est stable. Un éventail large signifie que le résultat dépend fortement de la "chance" quant à l'ordre des trades.
Le fan chart (à gauche) montre la dispersion des trajectoires d'equity possibles, et l'histogramme (à droite) montre la distribution de densité des rendements finaux avec les intervalles de confiance mis en évidence (5 %, 50 %, 95 %).
Analyse avancée : probabilité de ruine
Visualisation de la probabilité de ruine : les trajectoires d'equity survivantes (cyan) montent, tandis que les trajectoires ruinées (rouge) tombent sous le seuil d'equity zéro.
Le bootstrap permet de répondre à une question cruciale : quelle est la probabilité que la stratégie perde X % du capital ?
ruin_threshold = -0.20
prob_ruin = (df['max_dd'] < ruin_threshold).mean()
print(f"P(MaxDD < -20%) = {prob_ruin:.1%}")
prob_loss = (df['final_pnl'] < 0).mean()
print(f"P(PnL < 0) = {prob_loss:.1%}")
worst_5pct = df['final_pnl'].quantile(0.05)
cvar = df[df['final_pnl'] <= worst_5pct]['final_pnl'].mean()
print(f"CVaR(5%) = {cvar:.1%}")
Ces métriques sont impossibles à obtenir à partir d'un seul run de backtest. Elles sont pourtant essentielles pour décider de lancer une stratégie.
Pour en savoir plus sur les raisons pour lesquelles les drawdowns profonds sont mathématiquement dangereux et sur le fonctionnement de l'asymétrie des rendements, lisez notre article Loss-Profit Asymmetry.
Quand le bootstrap classique ne fonctionne pas
La méthode a des limites qu'il est important de connaître.
Autocorrélation des rendements
Le bootstrap classique suppose que les trades sont indépendants. En réalité, ce n'est souvent pas le cas — une stratégie peut connaître des séries de gains et de pertes. Si l'autocorrélation est significative, utilisez le block bootstrap :
def block_bootstrap(returns, block_size=10, n_simulations=10000):
"""Bootstrap preserving local dependency structure."""
n = len(returns)
results = []
for _ in range(n_simulations):
starts = np.random.randint(0, n - block_size + 1, size=n // block_size + 1)
sampled = np.concatenate([returns[s:s+block_size] for s in starts])[:n]
equity = np.cumprod(1 + sampled)
results.append({
"final_pnl": equity[-1] - 1,
"max_dd": max_drawdown(equity),
})
return pd.DataFrame(results)
Le block bootstrap préserve les dépendances locales entre trades consécutifs, fournissant des intervalles de confiance plus réalistes pour le MaxDD.
Le block bootstrap préserve l'autocorrélation au sein des blocs en partitionnant la séquence de trades en blocs et en les rééchantillonnant avec remise.
Non-stationnarité du marché
Le bootstrap travaille avec la distribution de trades d'origine. Si le marché a structurellement changé (par exemple, la volatilité a baissé ou la liquidité a changé), les trades historiques peuvent ne plus être représentatifs. Pour en tenir compte :
- Utilisez une fenêtre glissante : bootstrap uniquement sur les N derniers trades
- Pondérez davantage les trades récents : weighted bootstrap
- Divisez les données par régimes de marché et effectuez le bootstrap séparément
Nombre réduit de trades
Le bootstrap est fiable lorsque n > 30 trades. Avec 10 trades, aucun rééchantillonnage ne pourra aider. 491 trades constituent un excellent échantillon ; vous pouvez faire confiance aux résultats.
Comparaison des approches d'évaluation de la robustesse d'un backtest
| Method | What it provides | Complexity | Time | When to use |
|---|---|---|---|---|
| Single backtest | One point estimate | Minimal | Seconds | Never as a final result |
| Walk-forward | Out-of-sample metrics | Medium | Minutes | To check for overfitting |
| Monte Carlo bootstrap | Confidence intervals | Minimal | ~2 sec | Always before production |
| Monte Carlo path | New price paths | High | Minutes-hours | For stress testing |
| Cross-validation | Average metrics across folds | Medium | Minutes | For parameter tuning |
Le bootstrap Monte Carlo est la seule méthode qui, en un temps minimal, offre une image complète des risques.
Checklist : interpréter les résultats
Voici comment nous recommandons d'interpréter les résultats du bootstrap Monte Carlo :
Lancer en production si :
- le PnL au 5e percentile est positif
- le MaxDD au 5e percentile est acceptable pour votre appétit pour le risque
- la probabilité de ruine est < 1 %
- le Sharpe au 5e percentile est > 0,5
Nécessite du travail si :
- le PnL au 5e percentile est proche de zéro
- le MaxDD au 5e percentile est nettement pire qu'au 50e
- l'éventail du fan chart est large — la stratégie est instable
Ne pas lancer si :
- le PnL au 5e percentile est négatif
- la probabilité de ruine est > 5 %
- l'intervalle de confiance du Sharpe inclut 0
Notre expérience chez marketmaker.cc
Chez marketmaker.cc, nous développons notre propre moteur de backtest, et le bootstrap Monte Carlo fait partie intégrante de notre pipeline. Chaque stratégie passe par le bootstrap automatiquement avant d'être approuvée pour le trading en direct.
Nous avons intégré le bootstrap directement dans le moteur de backtest : après une exécution, vous obtenez non seulement le PnL final, mais un rapport complet avec intervalles de confiance, fan chart, probabilité de ruine et une comparaison entre block bootstrap et bootstrap standard. Cela prend 2 à 3 secondes supplémentaires — un prix négligeable pour comprendre les risques réels.
D'après notre expérience : environ 30 % des stratégies qui semblent attrayantes selon l'estimation ponctuelle sont filtrées après le bootstrap Monte Carlo. Leur PnL au 5e percentile devient négatif ou leur MaxDD s'avère inacceptable. Sans bootstrap, ces stratégies seraient passées en production et auraient très probablement entraîné des pertes.
Conclusion
Le bootstrap Monte Carlo, ce sont environ 10 lignes de code et environ 2 secondes de calcul. Il transforme un chiffre unique issu d'un backtest en une distribution complète avec intervalles de confiance. C'est peut-être l'outil d'analyse quantitative au meilleur retour sur investissement :
- Coût minimal : implémentation en 30 minutes
- Gain maximal : compréhension des risques réels de la stratégie
- Aucune dépendance : uniquement NumPy
Si vous n'utilisez pas encore le bootstrap — ajoutez-le à votre pipeline dès aujourd'hui. C'est le seul moyen de savoir à quel point vous pouvez faire confiance aux résultats de votre backtest.
References
- Efron, B. — Bootstrap Methods: Another Look at the Jackknife (1979)
- Davison, A.C., Hinkley, D.V. — Bootstrap Methods and their Application (Cambridge)
- Aronson, D.R. — Evidence-Based Technical Analysis: Monte Carlo permutation
- QuantStart — Monte Carlo Simulation for Backtest Analysis
- Marcos Lopez de Prado — Advances in Financial Machine Learning, Chapter 12: Backtesting
- Kevin Davey — Building Winning Algorithmic Trading Systems: Monte Carlo Analysis
- NumPy — numpy.random.choice
Citation
@software{soloviov2026montecarlobootstrap,
author = {Soloviov, Eugen},
title = {Monte Carlo Bootstrap: How to Get Confidence Intervals for a Backtest in 10 Lines of Code},
year = {2026},
url = {https://marketmaker.cc/ru/blog/post/monte-carlo-bootstrap-backtest},
version = {0.1.0},
description = {Why a single-point estimate from a backtest is a dangerous illusion. How Monte Carlo bootstrap in 2 seconds of computation gives you a 95\% confidence interval for PnL and MaxDD, and why this is a mandatory step before launching a strategy in production.}
}
Authors
Trading-systems engineer
Trading-systems engineer building bots since 2017: cross-exchange arbitrage (connected up to 30 venues), cointegration-based pairs arbitrage across spot and futures, scalping, news and sentiment-driven strategies, trend algorithms, and portfolio management and balancing algorithms. Also builds sub-millisecond order execution, big-data warehouses, backtesting engines, AI agents, and trading interfaces (incl. open-source profitmaker.cc). Stack: JS/TS, Python, Rust/Zig/Go, DevOps, backend, frontend, architecture.