バックテストの数字は、実装の落とし穴ひとつで簡単に「見栄えのする嘘」に変わります。本記事では、初学者が最初に押さえるべき落とし穴を 俯瞰的に一覧化 し、それぞれを最低限どう確認すれば良いかを Python で示します。各項目の深掘り(実例コード付き)は、後続記事(#10-8「データリーク・先読みバイアスを実例で防ぐ」 / #10-9「時系列クロスバリデーションと訓練/検証分割」)で扱います。
目次
- 落とし穴一覧
-
- ルックアヘッドバイアス(look-ahead bias)
-
- 全期間の特徴量正規化
-
- 生存者バイアス(survivorship bias)
-
- データスヌーピング・過剰最適化
-
- 取引コスト・スリッページ
-
- 出来高無視・約定不可能な水準
-
- 配当・株式分割の未調整
-
- 期間バイアス
-
- ベンチマーク欠落
- 落とし穴チェックリスト
落とし穴一覧
主要な落とし穴を 8 項目に整理します。本記事の中心はこの表です。
| 落とし穴 | 内容 | 主な対策 |
|---|---|---|
| 1. ルックアヘッドバイアス | 当日のシグナルを当日終値で約定 | shift(1) でポジションを 1 日ずらす |
| 2. 全期間の特徴量正規化 | 訓練・検証を区別せず標準化・スケーリング | 訓練データで fit、検証データに transform |
| 3. 生存者バイアス | 上場廃止銘柄を含めずにユニバース構築 | 当時の構成銘柄を時点ごとに再現 |
| 4. データスヌーピング(過剰最適化) | パラメータを過去に合わせ込みすぎる | アウトオブサンプル検証・時系列 CV |
| 5. 取引コスト・スリッページの軽視 | 0.0% で計算して机上の空論 | 片道 0.1〜0.3% 程度を最低見積もる |
| 6. 出来高無視 | 約定不可能な水準で約定 | 銘柄の平均出来高で発注金額を制限 |
| 7. 配当・株式分割の未調整 | 分割の日に偽の暴落リターン | 調整済み終値(AdjC)を使う |
| 8. 期間バイアス | 都合の良い期間だけ抜き出して検証 | 複数の市場局面で再検証する |
1. ルックアヘッドバイアス(look-ahead bias)
最も多い間違いです。当日終値で計算したシグナルを、その日のうちに執行できる前提でリターンを取ると、未来情報を使った好成績が生まれます。
# NG: 同日のシグナルで同日のリターンを取るdf["bad_ret"] = df["signal"] * df["ret"]
# OK: シグナルを 1 日ずらしてから掛けるdf["position"] = df["signal"].shift(1)df["good_ret"] = df["position"] * df["ret"]詳細とコードレベルの典型パターンは#10-8「データリーク・先読みバイアスを実例で防ぐ」 で扱います。
2. 全期間の特徴量正規化
リターンを標準化したり、ボラティリティで割ったりするとき、全期間の平均・標準偏差 を使ってしまうと、訓練期間の値に未来情報(検証期間の統計)が混じります。
# NG: 全期間で標準化してから分割mu, sd = df["feature"].mean(), df["feature"].std()df["feature_z"] = (df["feature"] - mu) / sd
# OK: 訓練期間で fit、検証期間に transformtrain, test = df.loc[:"2024-12-31"], df.loc["2025-01-01":]mu, sd = train["feature"].mean(), train["feature"].std()train["feature_z"] = (train["feature"] - mu) / sdtest["feature_z"] = (test["feature"] - mu) / sdローリング統計量(過去 60 日平均など)を使うのも、未来情報を漏らさない実用解です。
3. 生存者バイアス(survivorship bias)
「現在の構成銘柄」だけを使って過去をバックテストすると、当時に存在していて その後に上場廃止になった銘柄 が抜け落ちます。結果は実際より良く見えます。
確認の指針は次の通りです。
- ユニバース(対象銘柄の集合)を「当時の時点で存在した銘柄」に揃える
- J-Quants では上場廃止銘柄も配信されるかを確認する(プランによる)
- 上場廃止銘柄を除いた結果と除かない結果を比較し、差を把握しておく
仮想的な確認コードの例:
# 各日付時点での「上場している銘柄」を point-in-time で持つ# is_listed: index=Date, columns=Code, 値は 0/1universe = is_listed.shift(1).fillna(0) # 翌営業日からの取扱として shiftfiltered_returns = returns * universe4. データスヌーピング・過剰最適化
「過去の勝てたパラメータ」を選んでしまうと、未来のデータでは機能しません。SMA の窓幅を 1 日刻みでグリッドサーチして最良値を選ぶ、というのが典型例です。
代表的な防御策:
- 訓練期間と検証期間を 時系列順に分ける(ランダム K-Fold は不可)
- ウォークフォワード(walk-forward)で「未来データを見ない最適化」を行う
- パラメータ選択の自由度自体を減らす(候補を 3 つに絞る)
実装の詳細は#10-9「時系列クロスバリデーションと訓練/検証分割」。
5. 取引コスト・スリッページ
コスト 0% でバックテストすると、シグナルが頻発する戦略ほど見栄えが良くなります。実際には、片道 0.05〜0.3% 程度のコスト(手数料 + スリッページ + 税金)を仮定するのが安全です。
COST = 0.001 # 片道 0.1%
df["pos_change"] = df["position"].diff().abs().fillna(df["position"])df["cost"] = df["pos_change"] * COSTdf["strategy_ret"] = df["position"] * df["ret"] - df["cost"]コストを 0 → 0.1% に変えるだけで CAGR が大きく動く戦略は、実装の頑健性を疑った方が安全です。
6. 出来高無視・約定不可能な水準
バックテスト上は終値で大量に約定できることになっていますが、現実には板の厚みや出来高で制約されます。1 日の平均出来高に対して 1〜2% を超える発注は、現実には終値に近い水準では約定しないことがあります。
ORDER_RATIO_LIMIT = 0.01 # 1 日の平均出来高の 1%
df["dollar_volume"] = df["C"] * df["Vo"]df["max_order_jpy"] = df["dollar_volume"].rolling(20).mean() * ORDER_RATIO_LIMIT最低限、「自分の発注金額が max_order_jpy を超えていないか」をチェックすると、現実離れした想定を避けられます。
7. 配当・株式分割の未調整
調整なしの株価で pct_change をとると、株式分割の日に「マイナス 50% の暴落」のような偽のリターンが入ります。長期バックテストでは致命的です。
# OK: 調整済み終値(配当・分割を反映)を使うdf["ret"] = df["AdjC"].pct_change()J-Quants の場合、配信される価格が調整済みかどうかを必ず確認します。データの性質を取り違えると、ボラティリティ・MDD・ドローダウン日数のすべてが歪みます。
8. 期間バイアス
「2020 年から 2024 年まで」のように都合の良い期間で検証すると、たまたまその期間に有利だった戦略が良く見えます。次の観点を意識します。
- 複数の市場局面: 上昇相場・下落相場・横ばい相場を含む期間で検証
- より長い期間: 短期間ほどたまたまの良し悪しに左右される
- データのウィンドウを変えて再現性を確認: 開始日を 1 年ずらしても結果が頑健か
9. ベンチマーク欠落
戦略の絶対値だけ見ていると、上昇相場で全戦略が利益を出していても気づきません。バイ&ホールドや指数(TOPIX など)との 相対比較 を必ず添えます。
df["bh_equity"] = INITIAL_CAPITAL * (1 + df["ret"]).cumprod()戦略がバイ&ホールドに長期で負けていれば、戦略の存在意義を再考する材料になります。
落とし穴チェックリスト
実装後、最低限次の項目を確認します。
[ ] シグナル → ポジションの間に shift(1) が入っている[ ] 訓練期間と検証期間で正規化を分けている[ ] 上場廃止銘柄の扱いを文書化している(含む / 含まない)[ ] パラメータの選定が時系列順の分割に基づいている[ ] 取引コストを 0% にしていない(最低 0.05%)[ ] 発注金額が出来高の現実的な範囲に収まっている[ ] 価格データが調整済みである(配当・分割反映)[ ] 複数の期間で結果が再現する[ ] バイ&ホールド or 指数と比較している注意点
- 1 つの戦略を細かくチューニングするより、仮説 → 実装 → 検証 のサイクルを多く回すほうが現実的に強くなります
- バックテストは「投資判断のための完全な根拠」ではなく、「仮説の最低限の妥当性確認」と捉えるのが適切です
生成AI へのプロンプト例
落とし穴チェックを自動化したい場合の例です。
バックテスト結果を返す DataFrame には次の列が含まれます:- Date, Code, C, signal, position, ret, strategy_ret, equity
この DataFrame を受け取り、次のチェックを行う関数 audit_backtest(df) を書いてください。戻り値は dict で、各チェックの結果(True/False とメッセージ)。
チェック項目:- (a) position が signal を 1 行 shift しているか(同じ Code 内で確認)- (b) strategy_ret に NaN が混じっていないか- (c) equity が単調に減少していないか(極端な異常)- (d) ポジション切替時にコストが計上されているか(差分の有無)
要件:- pandas 2.2 / numpy 系- 各チェックは独立に評価し、辞書のキーは "lookahead", "nan", "monotone", "cost"- docstring を日本語で書くまとめ
- バックテストの落とし穴は「ルックアヘッド」「過剰最適化」「生存者バイアス」「コスト軽視」が代表
- それぞれは独立に防げる。チェックリストで実装後に検査する
- データの性質(調整済みか・上場廃止を含むか)はコードと同じくらい重要
- バイ&ホールドや指数との相対評価を必ず添える
- 個別の落とし穴は#10-8「データリーク・先読みバイアスを実例で防ぐ」 / #10-9「時系列クロスバリデーションと訓練/検証分割」 で実例コード付きで掘り下げる