この記事は Zenn に投稿したものの再掲です。初出: バックテストが良すぎたら、それは未来を見ている
システムトレードを自分で組んで、最初にやってくる「いい知らせ」は、たいてい悪い知らせです。
バックテストを回したら、勝率もプロフィットファクターも文句なし。資産曲線は綺麗な右肩上がり。普通はここで喜びます。でも経験を積むと、ここで手が止まるようになります。
成績が良すぎるのは、喜ぶ場面ではなく、疑う場面だからです。
相場のシグナル/ノイズ比はとても低い。だから「綺麗すぎる結果」は、戦略が優秀なのではなく、バックテストが未来を覗き見しているサインであることがほとんどです。これがlook-ahead(未来参照)バグ。系列データで最初に、そして一番高い確率で踏む罠です。私もこれで長いこと「なぜか検証では勝てるのにライブで負ける」状態に苦しみました。
この記事では、その正体・なぜ気づけないか・どう検出して潰すかを、具体的なコードで共有します。
一番踏みやすい形:rolling の「向き」
ラベル付けでよくある要件を考えます。「この足から見て、この先20本で価格がどれだけ上がるか」を計算して、上昇ラベルを作りたい。素直に書くとこうなりがちです。
# 「この先20本の最高値」のつもり……だが
df["future_max"] = df["high"].shift(-1).rolling(window=20).max()
一見それっぽいですが、これは意図と逆の窓を計算しています。
shift(-1) でデータを1つ上にずらすと、行 i には本来 i+1 の値が入ります。問題はその次の .rolling(window=20).max() で、pandasの rolling はデフォルトで後方(trailing)窓だということです。つまり行 i の結果は、
result[i] = max( shifted[i-19], ..., shifted[i] )
= max( high[i-18], ..., high[i+1] )
になります。狙っていたのは max(high[i+1], ..., high[i+20])(先の20本)なのに、実際には 過去18本+1本先 を見ている。ほぼ後ろ向きです。
何が起きるか。このラベルは high[i-18..i]、つまりその足までの過去にほぼ決まってしまいます。一方、特徴量も普通は直近の過去から作ります。するとラベルと特徴量が同じ過去データを共有することになり、モデルは「未来を予測」しているのではなく「ラベルに混ざった過去を答え合わせ」しているだけになる。検証成績は跳ね上がり、ライブでは当然消えます。
正しい「前向き窓」の書き方
forward窓が必要なら、向きを明示します。pandas 1.1以降なら FixedForwardWindowIndexer が一番素直です。
from pandas.api.indexers import FixedForwardWindowIndexer
fwd = FixedForwardWindowIndexer(window_size=20)
# この先20本(i+1 .. i+20)の最高値を、未来漏れなく計算する
df["future_max"] = (
df["high"].shift(-1) # 起点を1本先にずらし
.rolling(fwd, min_periods=20) # そこから前向きに20本
.max()
)
これで result[i] = max(high[i+1], ..., high[i+20]) になります。min_periods=20 を付けるのも重要で、末尾の足は未来が20本揃わないので NaN になります。揃わない行を平気で埋めてしまうと、それ自体が新しい漏れの入口になります。
FixedForwardWindowIndexer を使わない場合は「反転→trailing rolling→反転」でも書けますが、自分で書くなら必ずテストを添えてください(後述)。
なぜ気づけないのか
このバグの恐ろしさは、何のエラーも出さないことです。
- 例外は飛ばない。処理は最後まで通る
-
NaNも(雑に埋めてしまえば)見えなくなる - 出てくるのは「やたら good な成績」という、むしろ歓迎してしまう症状
だから「動いている。しかも勝てている」と思い込み、本番に持っていって初めて気づく。原因の特定が難しいのは、症状が"失敗"ではなく"成功"の顔をしているからです。
検出する3つの手
1. 「良すぎ」を健全性アラートとして扱う
まず意識から変えます。非現実的に高い指標(勝率が極端、PFが桁違い、資産曲線がほぼ一直線)は、成果ではなく警報。最初に未来参照を疑うクセをつけるだけで、被害の多くは防げます。
2. point-in-time 監査
特徴量とラベルの生成コードを1つずつ追い、「行 i を作るとき、添字が i を超える行に触れていないか」を確認します。要注意の関数は決まっています。
-
shift(負)、rolling/ewm/expandingの向き -
cumsum/cummaxの起点 - その日・全期間で計算した統計(平均、最大、分位点)を各行に配る処理
特に「全期間で計算した値を特徴量に使う」は、時間漏れと情報漏れ(次回の記事で扱う leakage)の両方を起こす定番です。
3. ずらしテスト
意思決定をわざと1本遅らせ、行 i の判断に i-1 までの情報しか使わないように強制して、成績がどう動くかを見ます。
# 全特徴量を1本ぶん「確定済み」側へずらして、未来漏れを物理的に断つ
X_safe = X.shift(1)
# これで成績が崩壊するなら、元のコードは同じ足の未確定情報に依存していた
健全なエッジは、1本ずらしても緩やかに劣化するだけです。逆に、ずらした瞬間に成績がノイズ水準まで崩壊したら、その勝ちは未来参照が作り出した幻だったということです。
予防:設計の原則にする
検出は対症療法です。そもそも入り込ませない設計にします。
- point-in-time を絶対のルールにする。 「この行を作るとき、未来の行を触っていないか?」を全ての特徴量・ラベルで問う
- ラベルと特徴量で役割を固定する。 ラベルだけが未来を見てよい(forward)。特徴量は過去だけ(backward)。コードも関数も分けて、混線しないようにする
-
窓の向きにテストを書く。 forward/backwardを自作したら、小さなダミー系列で「行
iの値が、意図した添字範囲だけから来ているか」をアサーションで固定する
def test_future_max_is_forward_only():
s = pd.Series([1, 2, 3, 4, 5], dtype=float)
# i=0 の「この先2本の最大」は max(s[1], s[2]) = 3
got = forward_max(s, window=2)
assert got.iloc[0] == 3 # 未来だけを見ている
assert pd.isna(got.iloc[-1]) # 未来が足りない行はNaN
たった数行ですが、このテストがあるだけで、向きの取り違えは二度と本番に到達しません。
まとめ
- バックテストの成績が良すぎたら、まず未来参照を疑う。良い結果は警報
-
shift(-1).rolling(...)のような書き方は、意図と逆の後方窓になりやすい。forward窓はFixedForwardWindowIndexerで向きを明示する - このバグはエラーを出さず、成功の顔をして現れるから気づきにくい
- 検出は「良すぎを疑う → point-in-time監査 → 1本ずらして崩壊するか」の順
- 予防は、point-in-timeを原則化し、窓の向きにテストを書くこと
未来参照は「時間的な漏れ」です。やっかいなのは、これを完全に潰しても検証はまだ嘘をつくことがある——時間は守っているのに、情報が漏れているケース。次回は「特徴量リーク(leakage)」を扱います。
質問や「自分はこの形で踏んだ」という話があればコメントで。連載として、検証が嘘をつくパターンを順番に潰していきます。
2026/06/25 追記
👉次回記事を公開しました。