モンテカルロシミュレーションでバックテスト結果の信頼性を検証する【Python】

Python実装・コード

バックテストで「勝率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ドル円の戦略にも同じ検証を当ててみて、どの時間足が一番ロバストか比較してみたい。

タイトルとURLをコピーしました