バックテストで勝率92%が出て小躍りした話→犯人はルックアヘッドバイアスでした

Python実装・コード

先月、日本株のスイング戦略をバックテストしたところ、勝率92%、シャープレシオ3.5という数字が出ました。見たことのない水準です。僕は本気で「これはもう億り人へのチケットでは」と一人でガッツポーズをしていました。

結論から言うと、バグでした。しかもコードのどこにもエラーは出ていません。プログラムは正常に動き、資産曲線はほぼ一直線に右上がり。有頂天のまま実弾を入れていたら、と思うとぞっとします。

この記事は、その「おかしいと気づいてから、原因を特定するまで」の過程をそのまま記録したものです。ルックアヘッドバイアスの網羅的な解説はバックテストが実運用と乖離する本当の理由にまとめてあるので、ここでは実際にどう追い詰めたかに絞ります。

※本記事のコードや情報は執筆時点の仕様に基づいています。投資は自己責任であり、必ずデモ環境や少額資金でテストした上で運用してください。

問題のコードと、出てしまった数字

まず、僕が書いていたコードをそのまま載せます。ぱっと見て、どこがおかしいか分かるでしょうか。

pip install yfinance pandas numpy matplotlib
import yfinance as yf
import numpy as np
import pandas as pd

df = yf.download("7203.T", period="3y", auto_adjust=True, progress=False)

# ボリンジャーバンドで逆張り
df["ma"] = df["Close"].rolling(20).mean()
df["std"] = df["Close"].rolling(20).std()
df["lower"] = df["ma"] - 2 * df["std"]
df["upper"] = df["ma"] + 2 * df["std"]

# -2σを割ったら買い、+2σを超えたら売り
df["position"] = 0
df.loc[df["Close"] < df["lower"], "position"] = 1
df.loc[df["Close"] > df["upper"], "position"] = 0
df["position"] = df["position"].ffill()

df["ret"] = df["Close"].pct_change()
df["pnl"] = df["position"] * df["ret"]      # ← ここに問題がある

d = df.dropna()
equity = (1 + d["pnl"]).cumprod()
wins = (d["pnl"][d["position"] != 0] > 0).mean()

print(f"累積リターン : {float(equity.iloc[-1] - 1) * 100:>7.1f} %")
print(f"勝率         : {float(wins) * 100:>7.1f} %")
print(f"シャープ     : "
      f"{float(d['pnl'].mean() / d['pnl'].std() * np.sqrt(252)):>7.2f}")

実行すると、勝率90%前後という数字が出ます。当時の僕は、この結果を見て「ボリンジャーバンドの逆張りは効くんだ」と本気で信じました。

📘 外部参考Look-Ahead Bias(Investopedia)

最初に感じた違和感

おかしいと思ったきっかけは、資産曲線があまりに滑らかだったことでした。

実際の相場では、どんな戦略でも必ず連敗する時期があります。それなのに僕のグラフには、目立った落ち込みがほとんどありませんでした。最大ドローダウンを計算してみたら−3.2%。株式戦略でこの数字は明らかに変です。

# 成績が現実的な範囲かを確認する
mdd = float((equity / equity.cummax() - 1).min())
print(f"最大ドローダウン: {mdd * 100:.1f} %")

checks = {
    "勝率が70%超": float(wins) > 0.70,
    "シャープが3超": float(d["pnl"].mean() / d["pnl"].std() * np.sqrt(252)) > 3,
    "最大DDが5%未満": mdd > -0.05,
}
for name, flagged in checks.items():
    print(f"  {'⚠' if flagged else '✓'} {name}: {flagged}")

3つとも引っかかりました。この時点で、「戦略がすごい」ではなく「コードがおかしい」と考えるべきだったのです。

ここで役に立った経験則を書いておきます。相場の世界で長く生き残っている人ほど、良い数字を見ると疑います。理由は単純で、本当に優れた戦略はそう簡単には見つからないからです。数十年にわたって研究され尽くしている分野で、素人が数時間で発見できるものなど、まずありません。

切り分け1:ランダムなシグナルと比べる

最初にやったのは、売買タイミングを完全にランダムにしてみることでした。戦略に意味があるなら、ランダムより明らかに良い成績になるはずです。

rng = np.random.default_rng(42)

# 同じ保有比率でランダムに売買する
hold_ratio = float((d["position"] != 0).mean())
random_pos = pd.Series(
    (rng.random(len(d)) < hold_ratio).astype(int), index=d.index)

random_pnl = random_pos * d["ret"]
random_eq = (1 + random_pnl).cumprod()

print(f"戦略の累積     : {float(equity.iloc[-1] - 1) * 100:>7.1f} %")
print(f"ランダムの累積 : {float(random_eq.iloc[-1] - 1) * 100:>7.1f} %")
print(f"戦略の勝率     : {float(wins) * 100:>7.1f} %")
print(f"ランダムの勝率 : "
      f"{float((random_pnl[random_pos != 0] > 0).mean()) * 100:>7.1f} %")

ランダムの勝率は当然50%前後になります。僕の戦略は92%。差がありすぎる——ここで確信に変わりました。まともな戦略が、ランダムに対してこれほど圧倒的な差をつけられるはずがありません。

この比較には、もう1つ副次的な効果があります。「その戦略が本当に何かを捉えているのか」を素朴に確かめられることです。実際に使えるレベルの戦略でも、ランダムとの差は勝率で数ポイント程度しかありません。

  • ランダムとほぼ同じ: 戦略に意味がない。ただ売買しているだけ
  • 数ポイント上回る: 現実的な優位性。ここからコストを引いて検討する
  • 圧倒的に上回る: バグを疑う。市場はそこまで甘くない

当時の僕は3番目に該当していたわけですが、恥ずかしながら最初は「自分は天才かもしれない」のほうを先に考えました。期待は判断を鈍らせるという、よくある話です。

切り分け2:勝ちトレードの中身を見る

次に、どの日に勝っているのかを1件ずつ確認しました。集計値だけを見ていても、バグの正体は分かりません。

# エントリーした日の前後を並べて確認する
entries = d[d["position"].diff() == 1].index[:5]

for date in entries:
    idx = d.index.get_loc(date)
    window = d.iloc[max(0, idx - 1):idx + 3][
        ["Close", "lower", "position", "ret", "pnl"]]
    print(f"\n=== エントリー日: {date.date()} ===")
    print(window.round(4).to_string())

ここで決定的な事実に気づきました。エントリーした日の行を見ると、position が1になっているのと同じ行に、その日のリターンが計上されているのです。

つまり僕のコードは、こう動いていました。

  • その日の終値が下限バンドを割ったことを確認する
  • 同じ日の始値から終値までのリターンを利益として計上する

言い換えると、「今日下がった」と確認してから、今日の朝に戻って買っているわけです。タイムマシンがあれば可能ですが、現実には不可能です。

なぜ勝率が異常に高くなったのか

この構造が、勝率92%を生んでいた理由も腑に落ちました。下限バンド割れは「その日大きく下げた」ことを意味します。下げた日を後から選んで、その日の下落分を利益として計上していたわけです。

# 「下がった日を選んでいるだけ」であることを確認する
entry_days = d[d["position"].diff() == 1]
print(f"エントリー日の当日リターン平均: "
      f"{float(entry_days['ret'].mean()) * 100:>7.3f} %")
print(f"全期間の平均                  : "
      f"{float(d['ret'].mean()) * 100:>7.3f} %")
print("\n→ エントリー日だけ大きくマイナス。")
print("   下げた日を『後から』選んでいた証拠です")

実際には、下げた日を選んでその下落を利益に変換していたので、逆張りが当たっているように見えていたのです。何のことはない、過去を見て過去に賭けていただけでした。

修正して、現実を突きつけられる

原因が分かれば直すのは簡単です。シグナルを1本ずらすだけです。

# ○ 修正版: 前日のシグナルで当日建てる
df["position_fixed"] = df["position"].shift(1)
df["pnl_fixed"] = df["position_fixed"] * df["ret"]

d2 = df.dropna()
eq_bad = (1 + d2["pnl"]).cumprod()
eq_fixed = (1 + d2["pnl_fixed"]).cumprod()

def summary(pnl, equity, label):
    active = pnl[pnl != 0]
    print(f"--- {label} ---")
    print(f"  累積リターン : {float(equity.iloc[-1] - 1) * 100:>7.1f} %")
    print(f"  勝率         : {float((active > 0).mean()) * 100:>7.1f} %")
    print(f"  シャープ     : "
          f"{float(pnl.mean() / pnl.std() * np.sqrt(252)):>7.2f}")
    print(f"  最大DD       : "
          f"{float((equity / equity.cummax() - 1).min()) * 100:>7.1f} %")

summary(d2["pnl"], eq_bad, "バグあり(同日約定)")
summary(d2["pnl_fixed"], eq_fixed, "修正後")

修正後の数字は、それはもう見事に平凡でした。勝率は50%台に落ち、シャープレシオも1を切りました。億り人へのチケットは、ただの紙切れだったわけです。

指標バグあり修正後現実味
勝率92%50%台修正後が妥当
シャープ3.51未満修正後が妥当
最大DD−3.2%−15%前後修正後が妥当

落ち込みはしましたが、実弾を入れる前に気づけたのは幸運でした。この数字を信じて資金を投入していたら、想定と現実のギャップに混乱したまま損失を重ねていたはずです。

同じ失敗を繰り返さないためにやったこと

この一件のあと、バックテストの書き方を根本から変えました。気をつけるでは絶対にまた同じことをやると分かっていたからです。

1. shift を関数に閉じ込める

shift(1) を書き忘れることが問題なら、書き忘れようがない構造にすればよいわけです。

def to_position(signal):
    """シグナルをポジションに変換する。必ず1本ずらす。

    バックテストでは必ずこの関数を経由させる。
    直接 signal * ret を書かないというルールにする。
    """
    return pd.Series(signal).shift(1).fillna(0)


def run_backtest(price, signal, cost=0.001):
    """コスト込みのバックテスト"""
    ret = price.pct_change()
    pos = to_position(signal)                # ここを通らない経路を作らない

    gross = pos * ret
    turnover = pos.diff().abs().fillna(0)
    net = (gross - turnover * cost).fillna(0)

    return pd.DataFrame({
        "position": pos, "net": net,
        "equity": (1 + net).cumprod(),
    })

地味ですが、これが一番効きました。「毎回気をつける」を「構造上できない」に変える——バグ対策の基本だと思います。

2. 成績の妥当性を自動で警告させる

良い数字が出たときこそ疑うべきなのに、人間は喜んでしまいます。だから喜ぶ前に機械に叱ってもらうことにしました。

def warn_if_too_good(result):
    """成績が良すぎたら警告する"""
    net = result["net"]
    eq = result["equity"]
    active = net[net != 0]

    win = float((active > 0).mean()) if len(active) else 0
    sharpe = float(net.mean() / net.std() * np.sqrt(252)) if net.std() else 0
    mdd = float((eq / eq.cummax() - 1).min())

    print(f"勝率 {win:.1%} / シャープ {sharpe:.2f} / 最大DD {mdd:.1%}")

    flags = []
    if win > 0.70:
        flags.append(f"勝率{win:.0%}は高すぎます")
    if sharpe > 2.5:
        flags.append(f"シャープ{sharpe:.1f}は非現実的です")
    if mdd > -0.05:
        flags.append(f"最大DD{mdd:.1%}は小さすぎます")

    if flags:
        print("\n🚨 実弾を入れる前にコードを見直してください")
        for f in flags:
            print(f"   - {f}")
        return False
    return True

3. データを途中で切って確かめる

最も確実な検査がこれです。未来を見ていなければ、データを途中で切っても過去のシグナルは変わらないはずです。

def check_no_future(price, signal_func, cut_ratio=0.7):
    """データを途中で切って、シグナルが変わらないかを確認する"""
    full = signal_func(price)
    cut = int(len(price) * cut_ratio)
    partial = signal_func(price.iloc[:cut])

    common = full.index[:cut].intersection(partial.index)
    a, b = full.loc[common].dropna(), partial.loc[common].dropna()
    idx = a.index.intersection(b.index)
    diff = int((a.loc[idx] != b.loc[idx]).sum())

    if diff == 0:
        print("✓ 未来参照なし")
        return True
    print(f"✗ {diff}箇所のシグナルが変化。未来を見ています")
    return False


def bollinger_signal(px):
    ma = px.rolling(20).mean()
    sd = px.rolling(20).std()
    return (px < ma - 2 * sd).astype(int)


check_no_future(df["Close"].dropna(), bollinger_signal)

この検査を通してからでないとバックテストを回さない、というルールにしました。手間は10秒、防げる損失は数十万円——費用対効果は圧倒的です。

4. 3つをまとめて1本のパイプラインにする

個別に用意しても、実行し忘れれば意味がありません。検証を1つの関数から呼び出す形にして、途中で止まるようにしました。

def safe_backtest(price, signal_func, cost=0.001):
    """検査に通らなければバックテストを実行しない"""
    print("=== 事前検査 ===")
    if not check_no_future(price, signal_func):
        print("→ 検証を中止します。コードを修正してください")
        return None

    print("\n=== バックテスト ===")
    result = run_backtest(price, signal_func(price), cost=cost)

    print("\n=== 事後検査 ===")
    if not warn_if_too_good(result):
        print("→ 結果は参考値です。実弾投入は見送ってください")

    return result


price = yf.download("^N225", period="5y", auto_adjust=True,
                    progress=False)["Close"].dropna()
result = safe_backtest(price, bollinger_signal)

ポイントは、事前検査に落ちたらバックテストそのものを走らせないところです。数字が出てしまうと、どうしてもそれを見たくなります。見なければ迷いようがありません。

見分け方のコツ

この経験から学んだ、怪しいコードを嗅ぎ分けるポイントをまとめておきます。

見つけたら疑う書き方なぜ危険か直し方
signal * ret がそのまま同日約定になっているsignal.shift(1) * ret
df["Close"].mean()全期間=未来を含むrolling()expanding()
center=True前後の値を使う既定の center=False
bfill / interpolate後の値で埋めるffill を使う
高値・安値でエントリー大引けまで確定しない始値か前日終値を使う

特に1行目は、僕が実際にやらかしたパターンです。signalret を掛けている行を見つけたら、必ず shift が入っているか確認する——これを癖にしてください。

もう1つ、コードを読む前に使える判定法があります。成績を人に説明してみることです。「勝率92%の逆張り戦略を見つけました」と口に出した瞬間、自分でも「そんなわけないな」と気づけます。

これは冗談ではなく、実際にかなり効きます。頭の中では成立していた理屈が、言葉にすると急に怪しく聞こえるという経験は誰にでもあるはずです。相手がいなければ、テキストに書き出すだけでも構いません。

説明するときは、次の3点をセットで言うようにしています。

  • 誰が損をして、自分が勝つのか: 説明できないなら優位性の源泉が不明
  • なぜその歪みが放置されているのか: 簡単に見つかるなら誰かが先に潰している
  • その成績は何回の売買で出たのか: 少なければ偶然の可能性が高い

特に2番目が効きます。「ボリンジャーバンドの−2σで買えば勝率92%」——これが本当なら、とっくに世界中の資金が群がって歪みは消えているはずです。そう考えれば、バグを疑うのが自然な発想になります。

まとめ

勝率92%に浮かれてから、バグを特定して現実に引き戻されるまでの記録でした。要点を整理します。

  • ルックアヘッドバイアスはエラーを出さない。正常動作しつつ嘘の成績を出す
  • 最初の違和感は資産曲線が滑らかすぎることと最大DDの小ささだった
  • 切り分けは①ランダムと比較 ②勝ちトレードを1件ずつ確認の順で有効
  • 原因は「下げた日を後から選んで、その下落を利益にしていた」こと
  • 修正すると勝率50%台・シャープ1未満に。これが本当の実力
  • 対策は「気をつける」ではなくshiftを関数に閉じ込める構造化
  • データを途中で切ってシグナルが変わるか——10秒で終わる決定的な検査

いま振り返って思うのは、あの数字を疑えたのは運が良かったということです。もう少し控えめな成績——たとえば勝率65%くらいだったら、僕はきっと疑わずに実弾を入れていました。

だからこそ、疑うかどうかを自分の感覚に委ねないことにしました。検査を先に走らせて、通ったものだけをバックテストする。地味ですが、これが唯一まともに機能する対策だと思っています。

もし手元に「やけに調子のいいバックテスト」があるなら、check_no_future() を通してみてください。がっかりする結果になるかもしれませんが、実弾を入れてからがっかりするよりはずっとマシです。

タイトルとURLをコピーしました