「バックテストで好成績だったのに実際の運用では負けた」という経験はありませんか?それはバックテストの落とし穴にはまっている可能性があります。この記事では、信頼できるバックテストと信頼できないバックテストの違いを徹底解説します。
バックテストとは?
バックテストとは、過去の価格データを使って投資戦略の有効性を検証することです。「この戦略を10年前から使っていたら、どれだけ儲かったか?」をシミュレーションします。
負けるバックテストの典型的な失敗パターン
失敗1:先読みバイアス(未来参照)
最も危険な失敗です。今日の終値を使って今日の売買判断をしてしまうケースです。
# ❌ 悪い例:今日の終値でシグナルを出して今日中に取引
df['MA'] = df['Close'].rolling(20).mean()
df['Signal'] = (df['Close'] > df['MA']).astype(int)
# この日の終値が確定した後にシグナルが出るので、実際には翌日に取引
# ✅ 良い例:シグナルは翌日に実行(shift(1)を使う)
df['Signal'] = (df['Close'] > df['MA']).astype(int).shift(1)
df['Return'] = df['Close'].pct_change() * df['Signal']
失敗2:サバイバーシップバイアス
「現在上場している銘柄」だけでテストすると、すでに上場廃止になった銘柄が含まれないため、結果が過剰に良くなります。
# ❌ 悪い例:現在のS&P500構成銘柄のみでバックテスト
# 倒産・上場廃止した銘柄は含まれないため結果が歪む
# ✅ 対策:
# 1. 過去の指数構成銘柄データを使う(Compustat等の有料データ)
# 2. 全銘柄ではなく特定の大型株に絞ってテストする
# 3. バイアスの存在を意識して過信しない
失敗3:過学習(オーバーフィッティング)
パラメーター最適化を繰り返すと、過去データに「完璧にフィット」するが未来では機能しない戦略ができてしまいます。
import yfinance as yf
import pandas as pd
import numpy as np
from itertools import product
# ❌ 悪い例:無数の組み合わせから最高パフォーマンスを選ぶ
df = yf.download("7203.T", period="5y", progress=False)
best_return = -999
best_params = None
for short, long_ in product(range(5, 50), range(20, 200)):
if short >= long_:
continue
df['MA_s'] = df['Close'].rolling(short).mean()
df['MA_l'] = df['Close'].rolling(long_).mean()
df['Pos'] = np.where(df['MA_s'] > df['MA_l'], 1, 0).shift(1)
ret = (df['Close'].pct_change() * df['Pos']).sum()
if ret > best_return:
best_return = ret
best_params = (short, long_)
# この「最高の」パラメーターは過去データに過剰適合している
print(f"最適パラメーター: {best_params}") # 未来では機能しない可能性大
失敗4:スリッページ・手数料の無視
実際の取引には手数料とスリッページ(注文価格と約定価格のズレ)があります。これを無視すると成績が過大評価されます。
# ✅ 手数料・スリッページを考慮したバックテスト
commission = 0.001 # 片道0.1%の手数料
slippage = 0.001 # スリッページ0.1%
df['Position_Change'] = df['Signal'].diff().abs()
df['Transaction_Cost'] = df['Position_Change'] * (commission + slippage)
df['Net_Return'] = df['Strategy_Return'] - df['Transaction_Cost']
print(f"手数料考慮前リターン: {df['Strategy_Return'].sum():.1%}")
print(f"手数料考慮後リターン: {df['Net_Return'].sum():.1%}")
勝てるバックテストの条件
条件1:適切なデータ分割
import yfinance as yf
import pandas as pd
import numpy as np
df = yf.download("7203.T", period="10y", progress=False)
# データを3分割
n = len(df)
train_end = int(n * 0.6)
valid_end = int(n * 0.8)
df_train = df.iloc[:train_end] # 訓練データ(60%)
df_valid = df.iloc[train_end:valid_end] # 検証データ(20%)
df_test = df.iloc[valid_end:] # テストデータ(20%)
print(f"訓練期間: {df_train.index[0].date()} - {df_train.index[-1].date()}")
print(f"検証期間: {df_valid.index[0].date()} - {df_valid.index[-1].date()}")
print(f"テスト期間: {df_test.index[0].date()} - {df_test.index[-1].date()}")
条件2:複数の評価指標で判断
def evaluate_strategy(returns):
"""戦略の総合評価"""
total_return = (1 + returns).prod() - 1
annual_return = (1 + total_return) ** (252 / len(returns)) - 1
volatility = returns.std() * np.sqrt(252)
sharpe = annual_return / volatility if volatility != 0 else 0
# 最大ドローダウン
cumulative = (1 + returns).cumprod()
rolling_max = cumulative.cummax()
drawdown = (cumulative - rolling_max) / rolling_max
max_drawdown = drawdown.min()
# 勝率
win_rate = (returns > 0).mean()
print(f"総リターン: {total_return:.1%}")
print(f"年率リターン: {annual_return:.1%}")
print(f"ボラティリティ: {volatility:.1%}")
print(f"シャープレシオ: {sharpe:.2f}")
print(f"最大ドローダウン: {max_drawdown:.1%}")
print(f"勝率: {win_rate:.1%}")
return {
'total_return': total_return,
'annual_return': annual_return,
'sharpe': sharpe,
'max_drawdown': max_drawdown,
'win_rate': win_rate
}
条件3:十分なサンプル数
取引回数が少なすぎると統計的な信頼性が低くなります。最低でも30回以上の取引、できれば100回以上が望ましいです。
勝てるバックテストのチェックリスト
- ✅ 未来参照(先読みバイアス)がない
- ✅ サバイバーシップバイアスを考慮している
- ✅ 手数料・スリッページを含めている
- ✅ 訓練データと検証データを分けている
- ✅ 取引回数が50回以上ある
- ✅ シャープレシオが1.0以上
- ✅ 最大ドローダウンが許容範囲内
- ✅ 複数の銘柄・期間で検証している
まとめ
バックテストはアルゴリズムトレードの命綱ですが、正しく実施しなければ「嘘の結果」を信じてしまう危険があります。特に先読みバイアスと過学習には細心の注意を払い、手数料・スリッページを必ず含めましょう。

