「バックテストでは年利30%だったのに、実運用に移した途端に損失が出た」——これは僕自身が経験した話です。原因を調べていくと、コードの中に未来の情報を使っている箇所がありました。いわゆるルックアヘッドバイアスです。
厄介なのは、このバグがエラーを出さずに静かに動くことです。プログラムは正常終了し、きれいな右肩上がりの資産曲線を描いてくれます。おかしいと気づくのは、実弾を入れて損をしてからです。
この記事では、バックテストと実運用が乖離する原因を体系的に切り分けます。ルックアヘッドバイアスの全パターンを分類し、それぞれの検出方法と修正コードを示す——手元の検証コードを点検するためのチェックリストとして使えるようにまとめました。
※本記事のコードや情報は執筆時点の仕様に基づいています。投資は自己責任であり、必ずデモ環境や少額資金でテストした上で運用してください。
乖離の原因は3つに分けられる
バックテストが実運用と合わないとき、原因は次の3つのどれかです。まずはこの切り分けから始めます。
| 原因 | 症状 | 気づき方 | 深刻度 |
|---|---|---|---|
| ルックアヘッド | 成績が非現実的に良い | 勝率80%超・シャープ3超 | 最悪 |
| コスト未計上 | 売買回数が多いほど乖離 | 手数料を入れると別物になる | 大 |
| 過学習 | 検証期間だけ良い | 別期間で成績が崩れる | 中 |
切り分ける順番が大切です。必ずルックアヘッドから疑ってください。これが混入していると、コストを入れても過学習を潰しても、そもそも数字自体に意味がありません。土台が腐っているのに上物を直しても仕方がない、というわけです。
コストの入れ方は手数料・スリッページをPythonで組み込む方法、過学習の対策はウォークフォワード最適化で過学習を防ぐ方法で扱っています。本記事は1つめに集中します。
3つの違いを一言でまとめるなら、次のようになります。
- ルックアヘッド: 検証そのものが成立していない。数字に意味がない
- コスト未計上: 検証は正しいが、現実の摩擦を無視している
- 過学習: 検証もコストも正しいが、その期間にしか通用しない
後ろの2つは「程度の問題」ですが、ルックアヘッドだけは質が違います。コストが甘ければ成績を割り引いて解釈すればよく、過学習が疑われるなら別期間で試せばよい。しかし未来を参照していたら、その検証結果からは何も読み取れません。不正確なのではなく、無意味なのです。
📘 外部参考:Look-Ahead Bias(Investopedia) / Backtesting(Investopedia)
ルックアヘッドバイアスとは何か
定義はシンプルです。その時点では知りえなかった情報を使って売買判断をしてしまうこと。過去のデータを一括で読み込んで計算する構造上、極めて混入しやすいバグです。
いちばん分かりやすい例を見てみます。
pip install yfinance pandas numpy matplotlib
import yfinance as yf
import numpy as np
import pandas as pd
df = yf.download("7203.T", period="3y", auto_adjust=True, progress=False)
df["ret"] = df["Close"].pct_change()
df["ma"] = df["Close"].rolling(25).mean()
# ✕ 悪い例: その日の終値を見て、その日の終値で売買している
df["signal_bad"] = (df["Close"] > df["ma"]).astype(int)
df["pnl_bad"] = df["signal_bad"] * df["ret"]
# ○ 良い例: 前日のシグナルで、当日に売買する
df["signal_ok"] = df["signal_bad"].shift(1)
df["pnl_ok"] = df["signal_ok"] * df["ret"]
d = df.dropna()
print(f"悪い例の累積リターン: {float((1 + d['pnl_bad']).prod() - 1) * 100:>8.1f} %")
print(f"良い例の累積リターン: {float((1 + d['pnl_ok']).prod() - 1) * 100:>8.1f} %")
print(f"差 : "
f"{float((1 + d['pnl_bad']).prod() - (1 + d['pnl_ok']).prod()) * 100:>8.1f} ポイント")
たった1行 .shift(1) を入れるかどうかで、結果が大きく変わります。悪い例は「終値を見てから、その終値で買う」という物理的に不可能な取引を前提にしているためです。
15時に終値が確定した時点で、その日の取引はもう終わっています。終値を見て判断できるのは、翌日の取引だけです。当たり前のことですが、コードの上ではこの当たり前が簡単に崩れます。
混入パターンを分類する
ルックアヘッドには、いくつかの典型的なパターンがあります。先ほどの例はいちばん分かりやすいものにすぎません。実際にはもっと気づきにくい形で混入します。
| パターン | やってしまうこと | 気づきにくさ |
|---|---|---|
| ①同日約定 | 終値でシグナル→終値で約定 | 低 |
| ②全期間統計 | 全期間の平均や標準偏差を使う | 高 |
| ③未来の指標 | センタリングした移動平均など | 中 |
| ④銘柄選定 | 現在の構成銘柄で過去を検証 | 高 |
| ⑤データ改訂 | 後から修正された決算値を使う | 高 |
| ⑥前処理での混入 | 正規化・欠損補完で未来を参照 | 高 |
②全期間の統計量を使う
これは非常に多い落とし穴です。「平均から2σ離れたら逆張り」という戦略を書くとき、その平均をどこから取るかが問題になります。
# ✕ 全期間の統計量(未来を含む)
mean_all = df["Close"].mean()
std_all = df["Close"].std()
df["z_bad"] = (df["Close"] - mean_all) / std_all
# ○ その時点までのデータだけを使う
df["z_ok"] = ((df["Close"] - df["Close"].shift(1).rolling(60).mean())
/ df["Close"].shift(1).rolling(60).std())
# 拡大窓(expanding)でも良い。開始時点から現在までの累積
df["z_expanding"] = ((df["Close"] - df["Close"].shift(1).expanding(60).mean())
/ df["Close"].shift(1).expanding(60).std())
print(df[["z_bad", "z_ok", "z_expanding"]].dropna().tail(5).round(3).to_string())
厄介なのは、このバグがあっても結果がもっともらしく見えることです。2年前の時点で「全期間の平均」を使うと、その後2年の値動きを知った上で判断していることになります。しかしグラフを見ても異常には見えません。
③未来を含む指標を使う
移動平均を「中央揃え」で計算すると、未来のデータが混ざります。ノイズ除去の目的では正しい処理ですが、売買シグナルには絶対に使えません。
# ✕ center=True は前後のデータを使う(未来参照)
df["ma_centered"] = df["Close"].rolling(25, center=True).mean()
# ○ 既定の center=False なら過去のみ
df["ma_ok"] = df["Close"].rolling(25).mean()
# 差を確認する
comp = df[["ma_centered", "ma_ok"]].dropna()
print(f"中央揃えと通常の差の平均: "
f"{float((comp['ma_centered'] - comp['ma_ok']).abs().mean()):.2f} 円")
# 他にも危険な処理
# df["Close"].interpolate() → 前後で補完 = 未来参照
# df["Close"].fillna(method="bfill") → 後の値で埋める = 未来参照
# df.dropna() の位置によっても行がずれる
bfill(後方補完)は特に見落としやすい処理です。欠損を埋めるつもりが、翌日の値を今日に持ってきていることになります。欠損補完は必ず ffill(前方補完)を使ってください。
④現在の銘柄リストで過去を検証する
「日経225の構成銘柄で10年前から検証する」——この一文にバグが潜んでいます。現在の構成銘柄は、この10年を生き残った企業だからです。
途中で経営破綻した企業や、業績不振で指数から外された企業は、最初からリストに入っていません。結果として成績が実態より良く出ます。これは生存者バイアスと呼ばれ、ルックアヘッドの一種です。
個人が当時の構成銘柄リストを入手するのは簡単ではありません。現実的な対処は次のとおりです。
- 成績は上振れしていると自覚する: 検証結果を割り引いて解釈する
- 指数そのものを対象にする: 銘柄選定の問題を回避できる
- 検証期間を短くする: 直近3年程度なら入れ替えの影響は小さい
⑤後から改訂されたデータを使う
ファンダメンタル指標を使う戦略で問題になります。決算数値は後から修正されることがあり、現在取得できる値は当時公表されていた値と違う可能性があります。
また、決算発表日そのものも重要です。3月期の数値が使えるようになるのは、5月の発表後です。3月末時点で使うのは未来参照になります。
# 決算データを使うときは、発表日までずらす
def apply_lag(fundamental, lag_days=45):
"""決算期末から発表までのタイムラグを反映する"""
shifted = fundamental.copy()
shifted.index = shifted.index + pd.Timedelta(days=lag_days)
return shifted
# 例: 四半期ごとのEPSを、45日遅れで使えるようにする
eps = pd.Series(
[120, 135, 128, 142],
index=pd.to_datetime(["2025-03-31", "2025-06-30",
"2025-09-30", "2025-12-31"]),
)
print("--- 期末日ベース(危険) ---")
print(eps.to_string())
print("\n--- 発表日ベース(安全) ---")
print(apply_lag(eps).to_string())
自動で検出する仕組みを作る
目視での確認には限界があります。そこで、ルックアヘッドを機械的に検出する方法を用意しておきます。考え方は単純で、データを途中で切っても同じシグナルが出るかを確かめます。
未来を参照していなければ、その時点までのデータだけで計算しても結果は変わらないはずです。変わったなら、未来を見ている証拠になります。
def detect_lookahead(price, signal_func, n_checks=8, seed=0):
"""データを途中で切って、シグナルが変わらないかを確認する
signal_func : 価格Seriesを受け取り、シグナルSeriesを返す関数
"""
rng = np.random.default_rng(seed)
full = signal_func(price)
n = len(price)
cut_points = sorted(rng.integers(int(n * 0.5), n - 5, n_checks))
mismatches = 0
for cut in cut_points:
truncated = signal_func(price.iloc[:cut])
# 重なる部分を比較する
common = full.index[:cut].intersection(truncated.index)
a = full.loc[common].dropna()
b = truncated.loc[common].dropna()
idx = a.index.intersection(b.index)
diff = int((a.loc[idx] != b.loc[idx]).sum())
if diff > 0:
mismatches += 1
print(f" ⚠ {cut}件で切ったとき、{diff}箇所のシグナルが変化")
if mismatches == 0:
print("✓ ルックアヘッドは検出されませんでした")
else:
print(f"✗ {mismatches}/{n_checks} 回で不一致。未来参照の疑いがあります")
return mismatches == 0
実際に、問題のあるコードと正しいコードで試してみます。
price = yf.download("7203.T", period="3y", auto_adjust=True,
progress=False)["Close"].dropna()
def bad_signal(px):
"""全期間の平均を使う(未来参照あり)"""
return (px > px.mean()).astype(int)
def good_signal(px):
"""前日までの移動平均を使う(安全)"""
ma = px.shift(1).rolling(25).mean()
return (px.shift(1) > ma).astype(int)
print("--- 問題のあるコード ---")
detect_lookahead(price, bad_signal)
print("\n--- 正しいコード ---")
detect_lookahead(price, good_signal)
この検査をバックテストを回す前に必ず通すようにしてから、僕は同じミスをしなくなりました。1分もかからない検査で、数か月分の損失を防げるなら安いものです。
そもそも混入しない書き方をする
検出も大事ですが、構造的に混入しない書き方を身につけるほうが確実です。いくつかの原則があります。
原則1: シグナルは必ず1本ずらす
これを機械的に適用するだけで、パターン①は完全に防げます。関数の中でずらしてしまうのが確実です。
def make_position(signal):
"""シグナルを必ず1本ずらしてポジションに変換する
この関数を必ず経由するルールにしておくと、
shift の入れ忘れが構造的に起きなくなる
"""
return signal.shift(1).fillna(0)
def backtest(price, signal, cost=0.001):
"""コスト込みのシンプルなバックテスト"""
ret = price.pct_change()
position = make_position(signal) # ここで必ずずれる
gross = position * ret
turnover = position.diff().abs().fillna(0)
net = gross - turnover * cost
equity = (1 + net.fillna(0)).cumprod()
return pd.DataFrame({
"position": position, "gross": gross,
"net": net, "equity": equity,
})
原則2: 「その日に知りうるか」を毎回問う
コードを書きながら、使っている値ごとに自問します。判断に迷ったときの目安は次のとおりです。
| 使いたい値 | 使えるタイミング |
|---|---|
| 当日の始値 | 寄り付き後なら使える |
| 当日の高値・安値 | 大引けまで確定しない |
| 当日の終値 | 大引け後。売買は翌日から |
| 当日の出来高 | 大引け後 |
| 決算数値 | 発表日以降 |
特に高値・安値は要注意です。「その日の安値で買って高値で売る」という検証をしている人が意外といますが、これは1日が終わってからでないと分かりません。
原則3: 疑わしい成績はまず疑う
経験的な目安として、次の数字が出たらまずコードを疑ってください。
- 勝率70%超: 市場でこの水準を長期維持するのは極めて困難
- シャープレシオ3超: 世界的なファンドでも稀な水準
- 資産曲線が直線的: 実際の相場では必ず凸凹する
- 最大ドローダウンが5%未満: 株式戦略としては現実的でない
def sanity_check(equity, returns):
"""成績が現実的な範囲かを自動でチェックする"""
r = pd.Series(returns).dropna()
win_rate = float((r[r != 0] > 0).mean())
sharpe = float(r.mean() / r.std() * np.sqrt(252)) if r.std() else 0
eq = pd.Series(equity).dropna()
mdd = float((eq / eq.cummax() - 1).min())
print(f"勝率 : {win_rate:>7.1%}")
print(f"シャープレシオ : {sharpe:>7.2f}")
print(f"最大DD : {mdd:>7.1%}")
warnings = []
if win_rate > 0.70:
warnings.append("勝率が高すぎます")
if sharpe > 3.0:
warnings.append("シャープレシオが非現実的です")
if mdd > -0.05:
warnings.append("ドローダウンが小さすぎます")
if warnings:
print("\n⚠ 次の点を確認してください:")
for w in warnings:
print(f" - {w}")
print(" → ルックアヘッドバイアスの可能性が高いです")
else:
print("\n✓ 成績は現実的な範囲です")
「良すぎる成績は喜ぶものではなく、疑うもの」——この感覚を持てるかどうかが、無駄な実運用を避ける分かれ目になります。シャープレシオの水準感についてはシャープレシオを使って投資戦略を評価する方法もあわせてご覧ください。
修正したら成績はどう変わるか
実際に、バグあり版とバグなし版を並べて比較してみます。
price = yf.download("^N225", period="5y", auto_adjust=True,
progress=False)["Close"].dropna()
ret = price.pct_change()
ma = price.rolling(25).mean()
raw_signal = (price > ma).astype(int)
variants = {
"①バグあり(同日約定)": raw_signal,
"②shift(1) のみ": raw_signal.shift(1),
"③shift(1)+コスト": raw_signal.shift(1),
}
rows = []
for name, sig in variants.items():
pnl = (sig * ret).fillna(0)
if "コスト" in name:
pnl -= sig.diff().abs().fillna(0) * 0.001
eq = (1 + pnl).cumprod()
rows.append({
"パターン": name,
"累積%": round(float(eq.iloc[-1] - 1) * 100, 1),
"シャープ": round(float(pnl.mean() / pnl.std() * np.sqrt(252)), 2),
"最大DD%": round(float((eq / eq.cummax() - 1).min()) * 100, 1),
})
print(pd.DataFrame(rows).to_string(index=False))
①から②への変化が、そのままルックアヘッドが生み出していた幻の利益です。多くの場合、ここで成績が半減します。さらに③でコストを引くと、優位性が消えてしまう戦略も珍しくありません。
厳しい結果ですが、これが本当の実力です。実弾を入れる前に知れたのなら、それは損失ではなく発見だと考えるべきでしょう。
まとめ
バックテストと実運用が乖離する原因、特にルックアヘッドバイアスについて整理しました。
- 乖離の原因はルックアヘッド・コスト・過学習の3つ。この順で疑う
- 最も基本的な混入は同日約定。
shift(1)で防げる - 気づきにくいのは全期間統計・銘柄選定・データ改訂・前処理の4つ
bfillとcenter=Trueは未来参照。使わないと決めておく- データを途中で切ってシグナルが変わらないかを自動検査する
- シグナルからポジションへの変換は専用関数を必ず経由させる
- 勝率70%超・シャープ3超・DD5%未満は喜ぶ前に疑う
このバグの本当の怖さは、誰も教えてくれないことにあります。エラーは出ず、グラフは美しく、自分では気づけない。実運用で損をして初めて気づく——という構造になっています。
だからこそ、検査を自動化して仕組みで防ぐしかありません。本記事の detect_lookahead() と sanity_check() を手元のバックテストに組み込んでみてください。心当たりのある戦略ほど、通してみる価値があります。
最後に、僕自身の反省を1つ。バグを見つけたとき、いちばん辛かったのは損失そのものではなく、「あの数字は幻だった」と認めることでした。良い成績が出たときほど、それを守りたくなって検証が甘くなります。
だからこそ、検査は自分の裁量を挟まず、機械的に走らせる形にしておくのが大事だと考えています。人間は自分に都合のよい結果を疑いたがらない生き物です。その弱さを前提に、仕組みで防ぐ。それが結局いちばん確実な対策になります。

