バックテストの ROI が嘘をつく 3 つの理由
バックテストの ROI が嘘をつく 3 つの理由
予測モデルを作り、期待値 (EV) の閾値でフィルタして過去データに賭けるシミュレーションをしたら ROI 130% が出た——ここで喜ぶのはまだ早いです。バックテストの ROI には、データリークが一切なくても測定の仕方そのものが数字を盛ってしまう構造的な罠が複数あります。この記事では代表的な 3 つの理由と、それぞれの確認方法をコード付きで紹介します。
理由1: 閾値の後出し選択 (多重比較)
EV 閾値・対象コース・人気帯・最低オッズ……と条件を振りながら何百通りもバックテストし、一番良かった組合せを報告する。これは統計でいう多重比較の問題で、winner's curse とも呼ばれます。各設定の真の ROI が全部 90% 前後だったとしても、測定ノイズで上振れた設定が必ずどこかに出ます。「最良を選ぶ」という操作は、ノイズの上振れを選ぶ操作と区別がつきません。
自分がこの罠にいるかは、全組合せの ROI 分布を描けば見えます。
import numpy as np
import pandas as pd
def backtest_roi(df: pd.DataFrame, ev_threshold: float) -> tuple[float, int]:
"""EV >= 閾値の馬に等額単勝を賭けたときの ROI と購入点数"""
bets = df[df['ev'] >= ev_threshold]
if len(bets) == 0:
return np.nan, 0
# win_odds は払戻倍率 (単勝オッズ)、won は 1/0
roi = (bets['won'] * bets['win_odds']).sum() / len(bets)
return roi, len(bets)
thresholds = np.arange(1.0, 3.0, 0.05)
scan = pd.DataFrame(
[(t, *backtest_roi(df, t)) for t in thresholds],
columns=['threshold', 'roi', 'n_bets'],
)
import matplotlib.pyplot as plt
plt.hist(scan['roi'].dropna(), bins=20)
plt.axvline(scan['roi'].max(), color='red', label='採用しようとしている「最良」')
plt.axvline(scan['roi'].median(), color='gray', linestyle='--', label='median')
plt.legend()
このヒストグラムで、自分の「最良」が分布の右端に 1 点だけポツンといるなら要警戒です。分布の中央値 (= 閾値を選ばなかった場合の典型的な ROI) と最良値の差が大きいほど、その差は「選択によって作られた数字」の可能性が高くなります。閾値をひとつ隣にずらしただけで ROI が大きく動くのも同じ症状です。
理由2: 少数の高配当への依存
競馬の払戻は分布の裾が非常に長いので、的中数本の万馬券がバックテスト全体の ROI を支配することがあります。購入 200 点・的中 15 本で ROI 130% でも、その内訳が「万馬券 2 本が払戻の 6 割」なら、その ROI は 2 レースの偶然に乗っているだけかもしれません。
確認は jackknife (高配当の的中を 1 本ずつ抜いて再計算) が手軽です。
def jackknife_top_payouts(bets: pd.DataFrame, max_drop: int = 3) -> pd.DataFrame:
"""払戻の大きい的中を上から k 本除外したときの ROI の変化を見る"""
hits = bets[bets['won'] == 1].sort_values('win_odds', ascending=False)
rows = []
for k in range(max_drop + 1):
remaining = bets.drop(index=hits.head(k).index)
roi = (remaining['won'] * remaining['win_odds']).sum() / len(remaining)
rows.append({'dropped_top_hits': k, 'roi': roi, 'n_bets': len(remaining)})
return pd.DataFrame(rows)
print(jackknife_top_payouts(bets))
# dropped_top_hits roi n_bets
# 0 0 1.31 200
# 1 1 1.02 199 <- 1本抜いただけで 3 割消えた
# 2 2 0.84 198
1〜2 本抜いただけで ROI が 100% を大きく割るなら、その戦略の成績は実質「その 1〜2 レースを当てたかどうか」で決まっています。サンプルを増やしても同じ構図が続くか、的中が特定の時期・条件に固まっていないかを必ず確認しましょう。逆に 3 本抜いてもなだらかにしか下がらないなら、ROI が広い的中に支えられている分だけ報告値の信頼度は上がります。
理由3: 選んだ閾値は将来も最良とは限らない
理由 1 の閾値スキャンを全期間でやって最良閾値を選んだ場合、その数字は「全期間を見てから選んだ閾値」の成績です。実運用では過去のデータで閾値を決めて、未来に適用することしかできません。この時間方向の制約を入れて測り直すのが leave-one-period-out です。
df['quarter'] = pd.PeriodIndex(df['race_date'], freq='Q')
def select_best_threshold(df: pd.DataFrame, thresholds, min_bets: int = 100) -> float:
best_t, best_roi = None, -np.inf
for t in thresholds:
roi, n = backtest_roi(df, t)
if n >= min_bets and roi > best_roi:
best_t, best_roi = t, roi
return best_t
rows = []
for q in sorted(df['quarter'].unique()):
past = df[df['quarter'] < q] # この四半期より前のデータだけで閾値を選ぶ
if len(past) == 0:
continue
t = select_best_threshold(past, thresholds)
roi_q, n_q = backtest_roi(df[df['quarter'] == q], t)
rows.append({'quarter': str(q), 'chosen_t': t, 'roi': roi_q, 'n_bets': n_q})
oos = pd.DataFrame(rows)
print(oos)
print('全期間スキャン最良 ROI :', scan['roi'].max())
print('過去で選んで未来に適用:', (oos['roi'] * oos['n_bets']).sum() / oos['n_bets'].sum())
これをやると、ほとんどの場合 2 行目の数字は 1 行目より下がります。下がり幅が小さければ閾値の選択が期間を跨いで安定している証拠ですし、四半期ごとに chosen_t がふらふら動いて ROI も期間ごとにバラバラなら、その閾値には期間を跨ぐ再現性がない、という診断になります。全期間スキャンの最良値と LOPO 値のギャップこそが「後出し分の盛り」の見積もりです。
処方箋
3 つの罠への対処は、結局「測り方を固定する」ことに尽きます。
- 主指標は roi_all (固定した 1 設定の全期間 ROI) にする。 閾値スキャンの最良値は参考値であって主指標にしない。報告するなら「設定 X を固定したときの全期間 ROI」を先に書く。
- 閾値スキャンは「探索」と「評価」で期間を分ける。 評価期間を何度も見て閾値を直したら、それは探索期間に昇格したとみなしてやり直す。
# 探索期間と評価期間を最初に切る (評価期間は最後に 1 回しか触らない)
explore = df[df['race_date'] < '2023-01-01']
holdout = df[df['race_date'] >= '2023-01-01']
t_fixed = select_best_threshold(explore, thresholds) # 探索はここだけ
roi_final, n_final = backtest_roi(holdout, t_fixed) # 評価は 1 回だけ
print(f'固定閾値 {t_fixed} の holdout ROI: {roi_final:.3f} (n={n_final})')
- jackknife と購入点数を ROI に添える。 「ROI 130% (n=200、top1 除外で 102%)」のように書く習慣にすると、自分自身も騙されにくくなる。
なお、評価期間の ROI は「1 回しか測れない」ことに意味があります。holdout を見て閾値を直し、また holdout で測る——を繰り返すと、それは粒度の粗い閾値スキャンを評価期間でやっているのと同じで、理由 1 の罠に逆戻りします。評価期間を使い切ったら、新しいデータが貯まるまで待つのが原則です。
まとめ
バックテストの ROI は、リークがなくても「後出しの閾値選択」「少数高配当への依存」「閾値の期間安定性」の 3 点で簡単に盛れてしまいます。逆に言えば、スキャン分布のヒストグラム・jackknife・leave-one-period-out の 3 つを習慣にすれば、自分の数字の信頼度を自分で診断できるということです。
JV-Link データで何年分もバックテストできる環境を作ったからこそ、測り方の規律で差がつきます。ROI の数字そのものより先に、その数字がどの手続きで出てきたかを記録しておきましょう。
この記事をシェア