バックテストで「勝率70%、利益率+35%!」という結果が出て小躍りした。。。でも冷静になって「このデータ期間たまたま良かっただけでは?」と不安になった。実際、同じ戦略で違う期間を試したら全然違う結果が出て、ドキっとした経験がある。そこで行き着いたのが「モンテカルロシミュレーション」という手法だ。
モンテカルロシミュレーションとは何か
モンテカルロシミュレーションは、乱数を使って大量のシナリオを生成し、結果の分布を確認する手法だ。バックテストへの応用では、「実際の取引リターンをランダムに並び替えて、1000回分の損益曲線を作る」という使い方が定番になっている。
ポイントは「取引の順序はたまたまだった可能性がある」という発想だ。同じ利益/損失のトレードがあっても、並ぶ順序によって最大ドローダウンや最終損益は大きく変わる。1000通りの「もしこの順番だったら」を見ることで、戦略の本当のリスクとリターンの分布が見えてくる。
実装してみる
まずは簡単なバックテストで取引ごとのリターン(pnl_list)を取得する前提で進める。
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import yfinance as yf
# ===== ダミー戦略:25日移動平均クロス(トヨタ 7203.T) =====
ticker = "7203.T"
df = yf.download(ticker, start="2020-01-01", end="2026-06-24", auto_adjust=True)
close = df["Close"].squeeze()
ma_short = close.rolling(10).mean()
ma_long = close.rolling(25).mean()
signal = (ma_short > ma_long).astype(int)
signal_prev = signal.shift(1)
buy = (signal == 1) & (signal_prev == 0)
sell = (signal == 0) & (signal_prev == 1)
# 各トレードのリターンを記録
pnl_list = []
entry_price = None
for i in range(len(close)):
if buy.iloc[i] and entry_price is None:
entry_price = close.iloc[i]
elif sell.iloc[i] and entry_price is not None:
ret = (close.iloc[i] - entry_price) / entry_price
pnl_list.append(ret)
entry_price = None
pnl_array = np.array(pnl_list)
print(f"総トレード数: {len(pnl_array)}")
print(f"平均リターン: {pnl_array.mean():.4f}")
print(f"勝率: {(pnl_array > 0).mean():.2%}")
モンテカルロでランダム並び替えを1000回実行
def montecarlo_simulation(pnl_array, n_simulations=1000, initial_capital=1_000_000):
"""
取引リターンをランダム並び替えして損益曲線を n_simulations 回生成する。
"""
n_trades = len(pnl_array)
curves = np.zeros((n_simulations, n_trades + 1))
curves[:, 0] = initial_capital
for i in range(n_simulations):
shuffled = np.random.permutation(pnl_array)
equity = initial_capital
for j, ret in enumerate(shuffled):
equity *= (1 + ret)
curves[i, j + 1] = equity
return curves
np.random.seed(42)
curves = montecarlo_simulation(pnl_array, n_simulations=1000)
# 最終残高の分布
final_equity = curves[:, -1]
print(f"\n=== モンテカルロ結果 ===")
print(f"中央値最終資産: {np.median(final_equity):,.0f} 円")
print(f"5パーセンタイル(悲観シナリオ): {np.percentile(final_equity, 5):,.0f} 円")
print(f"95パーセンタイル(楽観シナリオ): {np.percentile(final_equity, 95):,.0f} 円")
print(f"元本割れ確率: {(final_equity < 1_000_000).mean():.2%}")
損益曲線を可視化する
fig, axes = plt.subplots(1, 2, figsize=(14, 6))
fig.patch.set_facecolor("#0d1117")
# 左: 1000本の損益曲線
ax1 = axes[0]
ax1.set_facecolor("#0d1117")
for curve in curves[:200]: # 見やすさのため200本だけ描画
ax1.plot(curve, color="#34d399", alpha=0.03, linewidth=0.5)
# パーセンタイルを太く描く
p5 = np.percentile(curves, 5, axis=0)
p50 = np.percentile(curves, 50, axis=0)
p95 = np.percentile(curves, 95, axis=0)
ax1.plot(p50, color="#f0f6fc", linewidth=1.5, label="中央値")
ax1.plot(p5, color="#f87171", linewidth=1.0, linestyle="--", label="5%ile(悲観)")
ax1.plot(p95, color="#34d399", linewidth=1.0, linestyle="--", label="95%ile(楽観)")
ax1.axhline(1_000_000, color="#8b949e", linewidth=0.8, linestyle=":")
ax1.set_title("モンテカルロ損益曲線(1000シナリオ)", color="#f0f6fc")
ax1.tick_params(colors="#8b949e")
ax1.legend(facecolor="#161b22", labelcolor="#e6edf3", fontsize=8)
ax1.spines[:].set_color("#30363d")
# 右: 最終資産の分布
ax2 = axes[1]
ax2.set_facecolor("#0d1117")
ax2.hist(final_equity / 1_000_000, bins=50, color="#38bdf8", alpha=0.7, edgecolor="#0d1117")
ax2.axvline(1.0, color="#f87171", linewidth=1.5, linestyle="--", label="元本")
ax2.axvline(np.median(final_equity)/1_000_000, color="#34d399", linewidth=1.5, label="中央値")
ax2.set_xlabel("最終資産(百万円)", color="#8b949e")
ax2.set_title("最終資産の分布", color="#f0f6fc")
ax2.tick_params(colors="#8b949e")
ax2.legend(facecolor="#161b22", labelcolor="#e6edf3", fontsize=8)
ax2.spines[:].set_color("#30363d")
plt.tight_layout()
plt.savefig("montecarlo_result.png", dpi=150, facecolor="#0d1117")
plt.show()
最大ドローダウンの分布も確認する
最終損益だけでなく、途中の最大ドローダウン(MDD)の分布も見ると、「最悪何%まで含み損を我慢しなければいけないか」がわかる。
def max_drawdown(curve):
"""損益曲線から最大ドローダウン(%)を計算"""
peaks = np.maximum.accumulate(curve)
drawdowns = (curve - peaks) / peaks
return drawdowns.min()
mdd_list = [max_drawdown(curve) for curve in curves]
mdd_array = np.array(mdd_list)
print(f"\n=== 最大ドローダウンの分布 ===")
print(f"中央値MDD: {np.median(mdd_array):.2%}")
print(f"最悪ケース(5%ile)MDD: {np.percentile(mdd_array, 5):.2%}")
print(f"MDD -30%超えの確率: {(mdd_array < -0.30).mean():.2%}")
実際に使ってみてわかったこと
最初に「勝率70%!」と喜んでいた戦略をモンテカルロにかけたところ、5パーセンタイルシナリオでは元本割れしていた。。。「たまたまうまくいった期間を使っていた」ということが数値ではっきり見えた瞬間だった。元本割れ確率が20%超の戦略は、自分の中では「実運用には使えない」と判断するようにしている。
逆に、ちゃんとした戦略は5パーセンタイルでも元本を維持していて、中央値と楽観シナリオの差も小さい。こういう「ぶれ幅が小さい戦略」を探すのがモンテカルロの本来の使いどころだと実感した。
まとめ
バックテスト結果をモンテカルロで検証することで「この戦略はたまたまか、本物か」が定量的に判断できるようになる。numpy.random.permutationを使えば実装自体は50行以下と簡単だ。個人的には「元本割れ確率」と「最悪ケースMDD」の2指標を必ずチェックするようにしたら、過信によるポジションの張りすぎがかなり減った。次はFXドル円の戦略にも同じ検証を当ててみて、どの時間足が一番ロバストか比較してみたい。

