先週末、久しぶりにまとまった時間が取れたので新しいスクリーニング戦略のバックテストを回してみたら、勝率92%・年利+180%という「これもう億り人では?」みたいな数字が出てきて、一人でニヤニヤしてました。。。が、翌朝冷静になって見返したら見事に嘘でした。原因はpandasのshift()の向きを1つ間違えていただけ。未来のデータを見てトレードしていた、というよくあるやつです。
子供が生まれてから、土日にまとまってコードを見返す時間なんてほぼ取れなくて、平日の夜にちょこちょこ触るのが精一杯。「バグを自力で見つける」の優先度がどうしても下がっていく中で、最近会社の先輩に勧められてClaudeにコードを丸ごと貼ってレビューしてもらう習慣をつけたら、これが想像以上に効きました。同じように「バックテストの数字が良すぎて逆に怖い」人向けに、AIコードレビューで見つかった僕のバグを共有します。
そもそも何が起きていたか:shift()の向き問題
移動平均を使ったシグナルを作るとき、「その日の終値」で判定して「その日の終値」でリターンを計算してしまうと、実際にはまだ確定していない終値を先取りしてトレードしていることになります。これがいわゆるルックアヘッド(未来参照)バグです。僕の場合、シグナル自体は正しく作れていたのに、それを1日ずらすshift(1)を入れ忘れていました。
import pandas as pd
df = pd.read_csv("nikkei_prices.csv", index_col="date", parse_dates=True)
# NG:その日のcloseでシグナルを作り、そのままその日のリターンにかけている
df["ma5"] = df["close"].rolling(5).mean()
df["signal_ng"] = (df["close"] > df["ma5"]).astype(int)
df["return_ng"] = df["close"].pct_change() * df["signal_ng"]
# OK:シグナルは前日までの情報で確定させ、1日遅らせてから使う
df["signal_ok"] = df["signal_ng"].shift(1)
df["return_ok"] = df["close"].pct_change() * df["signal_ok"]
print(df[["return_ng", "return_ok"]].sum())
NG版は年利+180%、OK版に直すと+11%くらいまで下がりました。。。数字を見た瞬間、正直ちょっと凹みましたが、これが現実だったわけです。
AIに指摘された、あと2つのバグ
Claudeにコード全体を貼って「このバックテストコード、おかしいところない?」と聞いただけで、shift()の件に加えて2つ指摘されました。
①copy()忘れによるSettingWithCopyWarningの放置
DataFrameのスライスに直接値を代入していて、意図した通りに反映されているか怪しい状態でした。
# NG:スライスへの直接代入。Warningが出るのに放置していた
rebalance_df = df[df["is_rebalance_day"]]
rebalance_df["weight"] = 1 / len(rebalance_df)
# OK:明示的にcopy()してから代入する
rebalance_df = df[df["is_rebalance_day"]].copy()
rebalance_df["weight"] = 1 / len(rebalance_df)
②リバランス日のオフバイワン
「その日の終値」で判断したはずが、実際の発注は翌営業日の寄り付きになる前提を、コードのどこにも反映していませんでした。頭では分かっていたのに、実装ではすっぽり抜けていたパターンです。
AIコードレビューを使うときに気をつけていること
便利は便利なんですが、過信は禁物だなとも感じています。AIも自信満々に見当違いの「修正案」を出してくることがあるので、指摘された内容は必ず自分で数字を出して検証するようにしています。あと、自分のトレード戦略のロジックを外部AIにそのまま貼るのは情報漏洩っぽくて少し気が引けるので、変数名を伏せたり、コアのロジック部分だけ抜き出して貼るようにしています。
まとめ
「数字が良すぎるバックテストは疑え」とはよく言われますが、自分でやらかしてみて初めて身にしみました。AIコードレビューは自力デバッグの時間が取れない僕みたいな人には正直かなり助かる存在です。次は同じミスを繰り返さないよう、pytestで簡単な回帰テストを書いて、shift()の向きミスなどを機械的に検知できるようにしようと思っています。

