バックテストが実運用と乖離する本当の理由:ルックアヘッドバイアスをPythonで排除する方法

「バックテストで年利30%出たのに実運用で即損失」——これ、僕の実話だ。原因を調べたらルックアヘッドバイアス(未来参照)だった。今回はこのバグをPythonで再現して、正しい直し方を解説する。

ルックアヘッドバイアスとは

シグナルを判定するとき、その日の「未来のデータ」を使ってしまうバグ。バックテストでは全データが手元にあるので気づきにくい。でも実運用では未来のデータは存在しない。これが乖離の原因になる。

典型的なバグコードと修正版

import yfinance as yf
import pandas as pd

df = yf.download("7203.T", start="2021-01-01", end="2024-12-31",
                 auto_adjust=True)
df.columns = [c[0] if isinstance(c, tuple) else c for c in df.columns]

# ❌ バグあり: 当日終値でシグナルを出して当日終値で約定
df["sma20"] = df["Close"].rolling(20).mean()
df["signal_bug"] = df["Close"] > df["sma20"]  # 当日終値でシグナル
df["entry_bug"] = df["Close"] * df["signal_bug"]  # 当日終値で約定 → ルックアヘッド

# ✅ 正しい実装: 前日終値でシグナルを出して翌日始値で約定
df["signal_ok"] = df["Close"].shift(1) > df["sma20"].shift(1)  # 前日確定値でシグナル
df["entry_ok"] = df["Open"] * df["signal_ok"]  # 翌日始値で約定

# バックテスト比較
def simple_backtest(df, entry_col, label):
    positions = df[entry_col] > 0
    daily_ret = df["Close"].pct_change()
    strategy_ret = daily_ret * positions.shift(1)
    total = (1 + strategy_ret).cumprod().iloc[-1] - 1
    print(f"{label}: 総リターン {total*100:.1f}%")

simple_backtest(df, "entry_bug", "❌ バグあり(ルックアヘッド)")
simple_backtest(df, "entry_ok",  "✅ 正しい実装")

実行するとどうなるか

バグありは過去の「お宝データ」を使い放題なので見た目の成績が良くなる。正しい実装に直すと現実的な数字が出る。この差が「バックテスト vs 実運用の乖離」の正体だ。

僕が最初に書いたコードはほぼ全部バグありだった。shift(1)を一行追加するだけで解決するのに、それを知らずに数ヶ月やってた。。。

次回はウォークフォワード分析でパラメータの過学習も防ぐ方法を書く予定。

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