モンテカルロ・ブートストラップ:10行のコードでバックテストの信頼区間を得る方法
あなたは戦略をバックテストにかけました。PnL +42%、Sharpe 1.8、MaxDD -12%。結果は素晴らしく見えます。ボットを本番環境で稼働させ、1ヶ月後にドローダウンがすでに-28%に達し、PnLがゼロに向かっていることに気づきます。
何がうまくいかなかったのでしょうか?バグでもなく、「市場の変化」でもありません。問題は、あなたが1つの数値 — 単一点推定に基づいて判断を下したことです。戦略が+42%を示したことは分かりましたが、その数値をどれだけ信頼できるかは分かりませんでした。
単一点推定の問題
単一のデータポイント(左)は誤解を招く像を示すが、完全な分布(右)は可能な結果の真の範囲を明らかにする。
過去データに対するバックテストは、一連の特定の市場イベントを通じた1回の実行です。結果はトレードの順序に依存します:同じ戦略で同じトレードでも、異なる順序であれば、まったく異なる最大ドローダウンを示す可能性があります。
491回のトレードを想像してください。各トレードは特定のリターン分布を持つランダムイベントです。過去データのバックテストは、このプロセスの1つの実現だけを示します。サイコロを1回振って、そのサイコロは常に4が出ると結論づけるようなものです。
本当に必要なのは:
- 点推定ではなく、区間:「95%の確率で、最終PnLはXからYの間にある」
- 単一の最大ドローダウンではなく、分布:「最悪の5%のシナリオでは、ドローダウンはZ%を超える」
- 平均ではなく、テール:運が味方しない場合に何が起こるか?
これがまさにモンテカルロ・ブートストラップの目的です。
モンテカルロ・ブートストラップとは
ブートストラップは、元のデータセットから復元抽出でトレードをリサンプリングすることにより、数千の代替エクイティ軌跡を生成する。
ブートストラップは、1979年にBradley Efronが提案したリサンプリング手法です。アイデアはエレガントです:データサンプルがあれば、元のデータから復元抽出でランダムに要素を選択することで、数千の「新しい」サンプルを生成できます。
バックテストの文脈では、次のように機能します:
- 各トレードのリターン配列がある — 例えば491個の値
- この配列から491個の値を復元抽出でランダムに選択する — 一部のトレードは2回出現し、一部はまったく出現しない
- この新しいサンプルからエクイティカーブを構築する
- これを10,000回繰り返す
- 単一の数値ではなく、最終指標の分布を得る
各反復は1つの「代替シナリオ」です:トレードの順序とセットが少し異なっていたら、何が起こり得たか。
10行での実装
以下は完全な動作する実装です:
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)
})
実行時間:通常のノートパソコンで約2秒。あなたの戦略の10,000通りの代替履歴。
信頼区間の抽出
主要な戦略指標の信頼区間:PnL、MaxDD、Sharpe Ratio。5パーセンタイル(最悪)、50パーセンタイル(中央値)、95パーセンタイル(最良)のバンドを表示。
これで1つの数値ではなく、分布が得られました。ここから有用な情報を抽出する方法は以下の通りです:
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}")
実際の戦略に対する出力例:
| 指標 | 5パーセンタイル(最悪) | 中央値 | 95パーセンタイル(最良) |
|---|---|---|---|
| PnL | +18.3% | +41.7% | +72.1% |
| MaxDD | -23.4% | -12.8% | -5.1% |
| Sharpe | 1.12 | 1.76 | 2.41 |
これで違いは明白です:
- バックテストはPnL +42%を示した — しかし最悪の5%のシナリオでは、PnLはわずか+18.3%
- バックテストはMaxDD -12%を示した — しかし最悪の5%のシナリオでは、ドローダウンは-23.4%
- Sharpe 1.8 — しかし下限は1.12
5パーセンタイルはあなたの「現実的な最悪のケース」です。 5パーセンタイルで戦略が利益を出さなくなるなら、本番環境での稼働はリスクが高いです。
可視化:ファンチャート
モンテカルロ・ブートストラップは自然にファンチャート — エクイティカーブの扇形として可視化されます:
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()
ファンチャートは、可能な結果の広がりを直感的に理解できます。狭い扇は戦略が安定していることを意味します。広い扇は、結果がトレードの順序の「運」に大きく依存していることを意味します。
ファンチャート(左)は可能なエクイティ軌跡の広がりを示し、ヒストグラム(右)は信頼区間(5%、50%、95%)をハイライトした最終リターンの密度分布を示す。
高度な分析:破産確率
ブートストラップにより、重要な質問に答えることができます:戦略が資本のX%を失う確率はどれくらいか?
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%}")
これらの指標は、単一のバックテスト実行からは得ることが不可能です。しかし、戦略の稼働を決定するためには不可欠です。
深いドローダウンがなぜ数学的に危険で、リターンの非対称性がどのように機能するかについて詳しくは、損失と利益の非対称性の記事をお読みください。
古典的ブートストラップが機能しない場合
この手法には知っておくべき制限があります。
リターンの自己相関
古典的ブートストラップは、トレードが独立であることを前提としています。実際には、これはしばしば当てはまりません — 戦略には連勝や連敗のストリークがある場合があります。自己相関が顕著な場合は、ブロック・ブートストラップを使用してください:
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)
ブロック・ブートストラップは連続するトレード間のローカルな依存関係を保持し、MaxDDに対してより現実的な信頼区間を提供します。
市場の非定常性
ブートストラップは元のトレード分布で動作します。市場が構造的に変化した場合(例えば、ボラティリティが低下した、流動性が変化したなど)、過去のトレードは代表的でない可能性があります。これを考慮するために:
- ローリングウィンドウを使用する:直近N回のトレードのみでブートストラップ
- 最近のトレードに高い重みを付ける:加重ブートストラップ
- データを市場レジームごとに分割し、別々にブートストラップ
トレード数が少ない場合
ブートストラップはn > 30トレードの場合に信頼できます。10回のトレードしかない場合 — どれだけリサンプリングしても助けになりません。491回のトレードは優れたサンプルであり、結果を信頼できます。
バックテストのロバスト性評価アプローチの比較
| 手法 | 提供するもの | 複雑さ | 時間 | 使用タイミング |
|---|---|---|---|---|
| 単一バックテスト | 1つの点推定 | 最小 | 秒 | 最終結果としては使わない |
| ウォークフォワード | アウトオブサンプル指標 | 中程度 | 分 | オーバーフィッティングの確認 |
| モンテカルロ・ブートストラップ | 信頼区間 | 最小 | 約2秒 | 本番前は常に |
| モンテカルロ・パス | 新しい価格パス | 高い | 分〜時間 | ストレステスト用 |
| 交差検証 | フォールド間の平均指標 | 中程度 | 分 | パラメータチューニング用 |
モンテカルロ・ブートストラップは、最小の時間でリスクの完全な全体像を提供する唯一の手法です。
チェックリスト:結果の解釈
モンテカルロ・ブートストラップの結果を解釈する際の推奨事項は以下の通りです:
本番稼働OK の条件:
- 5パーセンタイルのPnLがプラス
- 5パーセンタイルのMaxDDがリスク許容度に対して許容範囲内
- 破産確率 < 1%
- 5パーセンタイルのSharpe > 0.5
改善が必要 な場合:
- 5パーセンタイルのPnLがゼロ付近
- 5パーセンタイルのMaxDDが50パーセンタイルと比べて大幅に悪い
- ファンチャートの広がりが大きい — 戦略が不安定
稼働させない 場合:
- 5パーセンタイルのPnLがマイナス
- 破産確率 > 5%
- Sharpeの信頼区間に0が含まれる
marketmaker.ccでの経験
marketmaker.ccでは、独自のバックテストエンジンを開発しており、モンテカルロ・ブートストラップはパイプラインの不可欠な一部です。すべての戦略は、ライブ取引に承認される前に自動的にブートストラップを通過します。
ブートストラップをバックテストエンジンに直接統合しました:実行後、最終PnLだけでなく、信頼区間、ファンチャート、破産確率、ブロック対標準ブートストラップの比較を含む完全なレポートが得られます。これには追加で2〜3秒かかります — 実際のリスクを理解するためのわずかなコストです。
私たちの経験から:単一点推定で魅力的に見える**戦略の約30%**が、モンテカルロ・ブートストラップ後にフィルタリングされます。5パーセンタイルのPnLがマイナスになるか、MaxDDが許容できないことが判明します。ブートストラップがなければ、これらの戦略は本番環境に投入され、十中八九損失をもたらしていたでしょう。
結論
モンテカルロ・ブートストラップは約10行のコードと約2秒の計算です。バックテストの単一の数値を信頼区間付きの完全な分布に変換します。これはおそらく、あらゆる定量分析ツールの中で最も高いROIです:
- 最小のコスト:30分で実装
- 最大のリターン:戦略の実際のリスクの理解
- 依存関係なし:NumPyのみ
まだブートストラップを使用していないなら — 今日パイプラインに追加してください。バックテスト結果をどれだけ信頼できるかを知る唯一の方法です。
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.}
}
MarketMaker.cc Team
クオンツ・リサーチ&戦略