先週末、久しぶりにまとまった時間が取れたので新しいスクリーニング戦略のバックテストを回してみたら、勝率92%・年利+180%という「これもう億り人では?」みたいな数字が出てきて、一人でニヤニヤしてました。。。が、翌朝冷静になって見返したら見事に嘘でした。原因はpandasのshift()の向きを1つ間違えていただけ。未来のデータを見てトレードしていた、というよくあるやつです。
子供が生まれてから、土日にまとまってコードを見返す時間なんてほぼ取れなくて、平日の夜にちょこちょこ触るのが精一杯。「バグを自力で見つける」の優先度がどうしても下がっていく中で、最近会社の先輩に勧められてClaudeにコードを丸ごと貼ってレビューしてもらう習慣をつけたら、これが想像以上に効きました。
この記事では、AIに指摘された3つのバグと、そのあと作った同じミスを二度と通さないpytestの回帰テストまで書きます。前回「次はpytestを書く」と言って終わっていたので、その続きです。結論だけ先に言うと、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%くらいまで下がりました。。。数字を見た瞬間、正直ちょっと凹みましたが、これが現実だったわけです。
あとから考えると、疑うべきサインは出ていました。勝率92%です。日次の終値ベースで9割当たる戦略が個人のノートPCから出てくるはずがない。「良すぎる数字はバグの通知」だと思っておいた方がいいです。
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)
これは成績を歪めるというより、「書いたつもりの値が入っていない」系のバグです。静かに間違うので厄介でした。
②リバランス日のオフバイワン
「その日の終値」で判断したはずが、実際の発注は翌営業日の寄り付きになる前提を、コードのどこにも反映していませんでした。頭では分かっていたのに、実装ではすっぽり抜けていたパターンです。
# NG:判定した当日の終値で約定したことにしている
fill_price = df.loc[signal_date, "close"]
# OK:判定は当日終値、約定は翌営業日の寄り付き
next_day = df.index[df.index.get_loc(signal_date) + 1]
fill_price = df.loc[next_day, "open"]
3つとも、言われてみれば分かる話です。ただ、平日の夜に30分だけ触っている状態で自力で気づけたかというと、正直むずかしかったと思います。
再発防止はpytestに任せる
ここからが今回やったことです。AIレビューは毎回コードを貼らないと動きません。忘れます。なので、同じ種類のバグが入ったら自動で落ちるテストを書きました。
テスト1:未来を知っていたら勝ちすぎる合成データ
「翌日必ず上がる日と下がる日が交互に来る」人工データを作ります。lookaheadがあるコードなら、この相場で異常な成績を出します。正しいコードなら、ほぼゼロ付近になります。
# test_backtest.py
import numpy as np
import pandas as pd
import pytest
from strategy import run_backtest # 自分のバックテスト関数
def make_alternating_prices(n: int = 200) -> pd.DataFrame:
"""翌日リターンが +1% / -1% を交互に繰り返す価格系列。"""
rets = np.array([0.01 if i % 2 == 0 else -0.01 for i in range(n)])
close = 1000 * np.cumprod(1 + rets)
idx = pd.bdate_range("2020-01-01", periods=n)
return pd.DataFrame({"close": close, "open": close}, index=idx)
def test_no_lookahead_on_alternating_market():
df = make_alternating_prices()
result = run_backtest(df)
total = float(result["strategy_return"].sum())
# 未来が見えていれば毎日プラスを取れてしまう。5%を超えたらリークを疑う
assert total < 0.05, f"lookahead suspected: total={total:.4f}"
この交互データがうまいのは、lookaheadがあると必ず勝ってしまう点です。実データだと「たまたま強かった」のか「未来を見た」のか区別がつきませんが、これなら一発で出ます。
テスト2:シグナル列が必ず1日ずれているか
def test_signal_is_lagged():
df = make_alternating_prices()
result = run_backtest(df)
raw = result["raw_signal"].dropna()
used = result["signal"].dropna()
common = raw.index.intersection(used.index)
# signal[t] は raw_signal[t-1] と一致していなければならない
shifted = raw.shift(1).loc[common].dropna()
aligned = used.loc[shifted.index]
assert (shifted == aligned).all(), "signal が shift(1) されていない"
def test_signal_never_uses_same_day_return():
df = make_alternating_prices()
result = run_backtest(df)
same_day = result["signal"].corr(result["close"].pct_change())
# 同日リターンとシグナルの相関が高すぎるのは未来参照のサイン
assert abs(same_day) < 0.5, f"suspicious corr={same_day:.3f}"
テスト3:手数料を上げたら成績は必ず下がる
意外と抜けるのがこれです。手数料の計算がどこにも効いていないコードは、料率を10倍にしても結果が変わりません。
@pytest.mark.parametrize("fee", [0.0, 0.001, 0.01])
def test_cost_monotonicity(fee):
df = make_alternating_prices()
totals = []
for f in [0.0, 0.001, 0.01]:
r = run_backtest(df, fee_one_way=f)
totals.append(float(r["strategy_return"].sum()))
# 手数料が上がるほど成績は単調に下がるはず
assert totals[0] >= totals[1] >= totals[2], f"fee not applied: {totals}"
def test_no_nan_leak():
df = make_alternating_prices()
r = run_backtest(df)
# ウォームアップ期間以外にNaNが混ざると集計が静かに狂う
tail = r["strategy_return"].iloc[30:]
assert tail.notna().all(), "NaN remains after warmup"
| テスト | 捕まえるバグ | 今回の3つとの対応 |
|---|---|---|
| 交互データ | 未来参照全般 | ①shift忘れ |
| signal遅延 | shiftの向き・回数 | ①shift忘れ |
| 同日相関 | 判定と約定の同一足 | ③オフバイワン |
| 手数料単調性 | コスト未適用 | (新規) |
| NaN残留 | copy忘れ・代入漏れ | ②SettingWithCopy |
実行はpytest -qだけです。平日の夜にコードをいじったあと、これを1回叩く。30秒で終わります。AIに貼り直すより、こっちの方が続きました。
AIコードレビューを使うときに気をつけていること
便利は便利なんですが、過信は禁物だなとも感じています。AIも自信満々に見当違いの「修正案」を出してくることがあるので、指摘された内容は必ず自分で数字を出して検証するようにしています。
あと、自分のトレード戦略のロジックを外部AIにそのまま貼るのは情報漏洩っぽくて少し気が引けるので、変数名を伏せたり、コアのロジック部分だけ抜き出して貼るようにしています。APIキーや証券口座のIDが混ざったファイルを丸ごと貼るのは論外です。一度貼ったものは取り消せません。
効いた聞き方
「おかしいところない?」でも指摘は出ますが、観点を指定した方が精度が上がりました。いま使っている定型文です。
このバックテストコードを、次の観点だけでレビューしてください。
改善案の提案は不要です。該当行と理由だけ列挙してください。
1. 未来参照:シグナルの計算に、その時点で未確定の値を使っていないか
2. 約定タイミング:判定した足と約定する足が同一になっていないか
3. コスト:手数料・スリッページが売買が発生した行にだけ適用されているか
4. 欠損:dropna / fillna が成績に有利な方向へ働いていないか
5. 代入:スライスへの直接代入で値が反映されていない箇所はないか
「改善案は不要」と書くのがコツでした。これを入れないと、指摘より先に長いリファクタ案が返ってきて、本題が埋もれます。生成AIでコードを書く側の注意点はVibe CodingでBOTを作る記事にまとめました。
直したあとの数字をどう受け止めるか
+180%が+11%になったとき、最初は「戦略がダメだった」と思いました。でも冷静に考えると、+11%は手数料を入れる前の数字です。売買回数が多い戦略だったので、片道0.1%を入れたらほぼ消えました。
つまり、直した結果分かったのは「この戦略は使えない」ということです。これは損ではありません。実弾を入れる前に分かったのだから、むしろ得をしています。バグを直さずに運用を始めていたら、想定と違う挙動の理由が永遠に分からないままでした。
成績の見方そのものは最大ドローダウンを計算した記事でも書きましたが、総リターンより先にドローダウンとコストを見た方が、期待値のズレが小さくなります。
まとめ
「数字が良すぎるバックテストは疑え」とはよく言われますが、自分でやらかしてみて初めて身にしみました。勝率92%は実力ではなく、shift(1)が1行足りないという事実の表示でした。
いまの運用はこうしています。新しい戦略を書いたら、まずpytestの5本を通す。通ったらAIに観点を指定してレビューさせる。指摘は自分で数字を出して確かめる。この順番にしてから、「良すぎる数字」を見て舞い上がる時間がなくなりました。
次にやりたいのは、テストを合成データだけでなく、実データの一部を固定したフィクスチャでも回すことです。相場のクセを含んだデータで落ちるバグは、交互データでは見つからないので。

