先月、日本株のスイング戦略をバックテストしたら勝率92%、シャープレシオ3.5という見たことのない数字が出まして。。。僕は本気で「これはもう億り人へのチケットでは…!?」と一人でガッツポーズしてました。有頂天のまま少額で実運用に切り替えたら、2週間で見事に含み損。心当たりを探してコードを1行ずつ読み返したら、犯人は自分自身でした。
「ルックアヘッドバイアス」って何なのか
正式には look-ahead bias(未来参照バグ)と呼ばれるやつです。ざっくり言うと「その時点ではまだ手に入らないはずの情報を使って、過去の売買判断をしてしまう」バグのこと。バックテストの中だけで起きる、いわば時間のズルです。厄介なのは、コードはちゃんと動くし、エラーも出ないところ。ただ成績だけが異様に良くなる。
僕がやらかしたのは典型パターンで、pandasの.shift()忘れでした。「終値がある水準を上回ったら買い」というシグナルを作ったんですが、判定に使った終値と、実際にエントリーする日の終値が同じ日になっていたんです。つまり「今日の終値を見てから今日のうちに買う」という、現実には不可能な神業トレードをバックテスト上だけでやっていました。そりゃ勝率も上がります。
よくある混入パターン
調べてみると、僕以外にも同じ沼にハマる人は多いようで、代表的なパターンはだいたい以下の4つでした。
①シグナル計算とエントリーが同じ日になっている(shift忘れ)②決算発表の内容を、実際の発表日より前の日付データに反映してしまう③その日の高値・安値を使った指標を、その日のうちの判断に使う④当時は存在しなかった銘柄(上場廃止・倒産銘柄を除いた後の現在の構成銘柄)で過去をバックテストする、いわゆる生存バイアス。特に④は日本株の製造業銘柄でユニバースを組むときに見落としがちです。
Pythonでの修正例
僕がやった修正はシンプルで、シグナル列を1日分ずらしてからエントリー判定に使うだけです。
import pandas as pd
# df["close"] に終値、df["ma25"] に25日移動平均が入っている前提
df["signal_raw"] = (df["close"] > df["ma25"]).astype(int)
# NG例:計算した当日にそのままエントリーしてしまう(未来参照)
df["position_bad"] = df["signal_raw"]
# OK例:シグナルを1日ずらしてから翌日のエントリーに使う
df["signal"] = df["signal_raw"].shift(1)
df["position_good"] = df["signal"].fillna(0)
# リターン計算も「当日の終値」ではなく「翌日の終値変化」で評価する
df["return"] = df["close"].pct_change().shift(-1)
df["strategy_return"] = df["position_good"] * df["return"]
これだけで、さっきの勝率92%は一気に57%くらいまで落ちました。落ちた瞬間は正直へこみましたが、これが「現実的な数字」なんだと思うとむしろホッとしました。実運用に出す前に気づけただけマシです。
見分け方のコツ
一番わかりやすい防御策は「バックテストの成績が良すぎたら、まず疑う」という姿勢そのものだと思います。特にシャープレシオが3を超えたり、勝率が9割近いような結果が出たときは、喜ぶ前にshift漏れ・生存バイアス・データの参照タイミングを1つずつ確認する。地味ですが、これをやるかどうかで実運用の結果がまるで変わってきます。
個人的には、今回の一件で「良すぎる結果は疑ってから喜ぶ」を自分ルールに追加しました。次は過去に組んだドル円のFX戦略も全部shiftチェックし直そうと思っています。同じ轍を踏んでる人、僕だけじゃないはず。。。

