バックテストの数字は、実装の落とし穴ひとつで簡単に「見栄えのする嘘」に変わります。本記事では、初学者が最初に押さえるべき落とし穴を 俯瞰的に一覧化 し、それぞれを最低限どう確認すれば良いかを Python で示します。各項目の深掘り(実例コード付き)は、後続記事(#10-8「データリーク・先読みバイアスを実例で防ぐ」 / #10-9「時系列クロスバリデーションと訓練/検証分割」)で扱います。

目次

  1. 落とし穴一覧
    1. ルックアヘッドバイアス(look-ahead bias)
    1. 全期間の特徴量正規化
    1. 生存者バイアス(survivorship bias)
    1. データスヌーピング・過剰最適化
    1. 取引コスト・スリッページ
    1. 出来高無視・約定不可能な水準
    1. 配当・株式分割の未調整
    1. 期間バイアス
    1. ベンチマーク欠落
  2. 落とし穴チェックリスト

落とし穴一覧

主要な落とし穴を 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、検証期間に transform
train, 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) / sd
test["feature_z"] = (test["feature"] - mu) / sd

ローリング統計量(過去 60 日平均など)を使うのも、未来情報を漏らさない実用解です。

3. 生存者バイアス(survivorship bias)

「現在の構成銘柄」だけを使って過去をバックテストすると、当時に存在していて その後に上場廃止になった銘柄 が抜け落ちます。結果は実際より良く見えます。

確認の指針は次の通りです。

  • ユニバース(対象銘柄の集合)を「当時の時点で存在した銘柄」に揃える
  • J-Quants では上場廃止銘柄も配信されるかを確認する(プランによる)
  • 上場廃止銘柄を除いた結果と除かない結果を比較し、差を把握しておく

仮想的な確認コードの例:

# 各日付時点での「上場している銘柄」を point-in-time で持つ
# is_listed: index=Date, columns=Code, 値は 0/1
universe = is_listed.shift(1).fillna(0) # 翌営業日からの取扱として shift
filtered_returns = returns * universe

4. データスヌーピング・過剰最適化

「過去の勝てたパラメータ」を選んでしまうと、未来のデータでは機能しません。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"] * COST
df["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 を日本語で書く

まとめ