バックテストで「年利30%!勝率70%!」みたいな結果が出て、小躍りしながら実運用に移したら1ヶ月でマイナス15%になった。。。こういう経験、僕だけじゃないと思う。原因を調べたら「カーブフィッティング(過学習)」という言葉に行き着いた。今回はその問題と、対策として有名な「ウォークフォワード最適化」をPythonで実装する話を書く。
なぜバックテストは「嘘」をつくのか
正確には「嘘」ではなくて、「過去データに対しては本当のこと」を言っているだけ。問題は、未来のデータに対しても同じ結果が出るとは限らない点にある。
例えばパラメータ最適化ツール(Optunaやvectorbtのグリッドサーチなどなんでもいい)で「移動平均の期間を1〜200まで全部試して、一番良かった期間を採用する」とする。この方法で過去5年のデータに対して「最高の結果」が出るパラメータを見つけても、それはその5年間のノイズ(たまたまうまくいったタイミング)を含めて最適化しているので、未来でも同じように動くとは言えない。
機械学習の世界で言うと「訓練データの精度は高いけどテストデータでボロボロ」という状態。これが過学習(Overfitting)、トレードの世界では「カーブフィッティング」と呼ばれている。
ウォークフォワード最適化とは
ウォークフォワード最適化(Walk-Forward Optimization、WFO)は、この問題に対する代表的な解決策のひとつ。ざっくり言うと「古いデータで最適化 → 直後の新しいデータで検証 → また古いデータで最適化 → また検証…」を繰り返す手法。
具体的には以下のようなステップで動く。データを時系列順に並べ、まず最初の一定期間(インサンプル:IS)でパラメータを最適化する。次にその直後の期間(アウトオブサンプル:OOS)で、最適化したパラメータを使ってシミュレーションする。その結果はOOSなので「見ていないデータ」に対するフェアな評価になる。次にウィンドウをずらして(過去を1期間分動かして)同じことを繰り返す。全期間でこれを繰り返し、OOSの結果を全部つなげたものが「ウォークフォワードテスト結果」になる。
IS:OOS の比率は一般的に3:1〜5:1程度がよく使われる。つまり3〜5ヶ月で最適化して1ヶ月で検証、を繰り返すイメージ。
Pythonで実装してみる
ここでは移動平均クロスオーバー戦略を対象に、シンプルなウォークフォワード実装を示す。最適化はブルートフォース(全探索)で行い、評価指標は「総リターン」とする。
import yfinance as yf
import pandas as pd
import numpy as np
import itertools
# ===== データ取得(ドル円日足) =====
df = yf.download("USDJPY=X", start="2021-01-01", end="2026-05-31", auto_adjust=True)
close = df["Close"].dropna()
# ===== バックテスト関数(一期間) =====
def backtest_ma_cross(close_series, fast, slow, fees=0.0003):
"""移動平均クロスオーバー戦略のバックテストを実行し、総リターンを返す"""
if fast >= slow:
return -np.inf
fast_ma = close_series.ewm(span=fast, adjust=False).mean()
slow_ma = close_series.ewm(span=slow, adjust=False).mean()
signal = np.where(fast_ma > slow_ma, 1, 0)
signal = pd.Series(signal, index=close_series.index)
position = signal.shift(1).fillna(0)
daily_ret = close_series.pct_change().fillna(0)
strat_ret = daily_ret * position
# 売買コスト(ポジション変化があった日に手数料発生)
turnover = (position.diff().abs() * fees)
strat_ret -= turnover
total_return = (1 + strat_ret).prod() - 1
return total_return
# ===== ウォークフォワード最適化 =====
is_months = 18 # インサンプル: 18ヶ月
oos_months = 6 # アウトオブサンプル: 6ヶ月
# パラメータ候補
fast_range = range(5, 50, 5) # [5, 10, 15, ..., 45]
slow_range = range(30, 150, 10) # [30, 40, 50, ..., 140]
results = []
dates = pd.date_range(start=close.index[0], end=close.index[-1], freq="MS")
for i in range(len(dates) - is_months - oos_months):
is_start = dates[i]
is_end = dates[i + is_months]
oos_end = dates[i + is_months + oos_months]
is_data = close.loc[is_start:is_end]
oos_data = close.loc[is_end:oos_end]
if len(is_data) < 60 or len(oos_data) < 20:
continue
# インサンプルで最適パラメータを探す
best_ret = -np.inf
best_fast = 10
best_slow = 60
for fast, slow in itertools.product(fast_range, slow_range):
ret = backtest_ma_cross(is_data, fast, slow)
if ret > best_ret:
best_ret = ret
best_fast = fast
best_slow = slow
# アウトオブサンプルで検証
oos_ret = backtest_ma_cross(oos_data, best_fast, best_slow)
results.append({
"is_start": is_start,
"oos_start": is_end,
"oos_end": oos_end,
"best_fast": best_fast,
"best_slow": best_slow,
"is_return": best_ret,
"oos_return": oos_ret
})
print(f"OOS期間 {is_end.date()}〜{oos_end.date()}: fast={best_fast}, slow={best_slow}, OOSリターン={oos_ret:.2%}")
result_df = pd.DataFrame(results)
# ===== 結果の集計 =====
if len(result_df) > 0:
print("\n===== ウォークフォワード結果サマリ =====")
print(f"OOS期間数: {len(result_df)}")
print(f"OOS平均リターン(月次換算): {result_df['oos_return'].mean():.2%}")
print(f"OOS勝率(プラス期間の割合): {(result_df['oos_return'] > 0).mean():.1%}")
print(f"IS平均リターン: {result_df['is_return'].mean():.2%}")
efficiency = result_df['oos_return'].mean() / result_df['is_return'].mean()
print(f"WFO効率(OOS÷IS): {efficiency:.2f} ← 0.3以上が目安")
最後の「WFO効率(OOS÷IS)」は重要な指標で、インサンプルでの成績に対してアウトオブサンプルがどれだけ維持できているかを示す。0.3以上あれば「そこそこ頑健な戦略」、0.1以下なら「過学習が疑われる」と考えるのが一般的。
失敗から学んだこと:IS期間の長さで結果が変わる
最初にこれを実装したとき、IS期間を6ヶ月・OOS期間を1ヶ月に設定したら、最適パラメータがウィンドウごとに全然違う値を行ったり来たりして、実運用では月に何度もパラメータを変更する羽目になった。これは「IS期間が短すぎてノイズに引っ張られている」状態。
日足データなら最低でもIS期間は12〜18ヶ月くらいは確保したほうが良い。日本株の製造メーカーは決算サイクルが年4回あるから、少なくとも決算を複数回含む期間を学習に使わないとサイクルを学習できない。FXのドル円も、金融政策イベント(日銀・FRB)を複数回含む期間は欲しい。
まとめ
バックテストで好成績が出ても、それが「過去データへの過学習」である可能性は常にある。ウォークフォワード最適化を使えば「見ていないデータで検証」のサイクルを自動化でき、戦略の頑健性をより正確に評価できる。コードの実行時間はそれなりにかかるが、これを飛ばして実運用に突っ込むのは避けたい。
個人的には、バックテストは「アイデアを捨てるためのフィルター」くらいの位置づけで使うのが精神的に楽だと感じている。WFO効率が低い戦略はさっさとボツにして、次のアイデアを試す。その繰り返しの先に、実際に使える戦略が一つ二つ残る感じ。次は日本株の製造メーカー銘柄に絞ったWFOをやってみて、業種・銘柄ごとに最適なIS/OOS比率が変わるかどうか検証してみたい。

