この記事は Zenn に投稿したものの再掲です。初出: 「バックテストは勝つのにライブで負ける」を分解する
前回までで、時間の漏れ(第1回・未来参照)と情報の漏れ(第2回・特徴量リーク)を潰しました。特徴量もラベルもその時刻までに確定した情報だけで作り、前処理は分割の内側でfitし、重なるラベルはpurgeした。データもコードも、もう正しい。
それでも、システムトレードを自分で組んだことがある人なら、ほぼ全員が突き当たる壁が残っています。
バックテストでは綺麗な右肩上がり。なのに、ライブで回すと負ける。
私も例外ではありませんでした。日本株の日中(ザラ場)で動かす自前システムを開発していて、検証では文句のない成績が出るのに、実際にライブで回すと約定の中身がバックテストと食い違う。この「ライブ/BT乖離」の正体を突き止めるのに、結果的に数ヶ月を溶かしました。
この記事では、その過程で身についた乖離の切り分け方を共有します。個別の閾値やロジックの話はしません。再現性のある「診断の手順」だけを書きます。これは戦略が何であれ効く話です。
結論:乖離には2種類ある
最初に結論を書きます。ライブ/BT乖離を前にしたとき、一番やってはいけないのは「全部バグだ」と思い込んで延々とコードを追うことと、その逆に「相場のせいだ」と諦めることです。
乖離は性質の違う2種類に分かれます。
| 種類 | 正体 | あるべき状態 | 対処 |
|---|---|---|---|
| バグ由来 | コードやデータの不整合 | ゼロにできる/すべき | 潰す |
| 構造由来 | 執行の物理的な現実(遅延・解像度・スリッページ) | ゼロにはならない | BT側にモデル化して寄せる |
この2つを混同すると、潰せるはずのバグを「構造だから仕方ない」と放置したり、消えるはずのない構造差を「バグだ」と思って永遠に直そうとしたりします。最初にやるべきは、目の前の乖離がどちらなのかを判定することです。
切り分けの土台:疑似バックテスト(pseudo-BT)
判定の鍵になるのが、私が「疑似BT」と呼んでいる仕組みです。発想はシンプルです。
通常のバックテスト(以下、訓練BT)は、綺麗に整形済みのヒストリカルデータを使います。一方ライブは、その瞬間に実際に手元にあった生のデータを使って動きます。この2つは入力がそもそも違うので、いきなり突き合わせても原因の切り分けができません。
そこで、ライブ中に「その時刻に実際に受け取ったデータのスナップショット」を全部記録しておき、後からライブと完全に同一の関数・モデルに、時系列順で流し直す。これが疑似BTです。
# ライブ実行中:意思決定の入力をそのまま記録しておく
recorded_live_stream.append((now, feed_snapshot))
# 後日:ライブと"同じ関数"に流し直す(ここが重要。BT専用の別実装を使わない)
pseudo_log = []
for t, snapshot in recorded_live_stream:
feat = make_features(snapshot) # ライブが呼んだのと同じ関数
sig = model.predict(feat) # ライブが使ったのと同じモデル
pseudo_log.append((t, feat, sig))
これで 3者比較 ができるようになります。
- ライブ(本番)
- 疑似BT(同じコード × 記録した生データ)
- 訓練BT(同じ/別コード × 整形済みヒストリカル)
切り分けは、この3つのどこがズレるかで決まります。
ライブ ≠ 疑似BT → コードパスかライブデータのバグ(潰せる)
ライブ = 疑似BT ≠ 訓練BT → 構造差(執行モデル・データ前提の違い)
「ライブと疑似BTが食い違う」なら、原因は自分のコードか、ライブ特有のデータ汚染です。これは必ず潰せます。逆に「ライブと疑似BTは一致するのに、訓練BTとだけ食い違う」なら、それはバグではなく執行の現実であり、追っても直りません。
バグ由来の代表例:ダミーバー混入
「ライブ ≠ 疑似BT」側の典型が、ライブ特有のデータ汚染です。私が踏んだのはこれでした。
訓練BTのヒストリカルは確定済みのバーしか含みません。ところがライブのデータフィードは、まだ確定していないバー、出来高ゼロのプレースホルダ、配信の都合で挿入される穴埋めバーなどを末尾に混ぜてくることがあります。
これが特徴量計算にそのまま入ると、ライブだけが訓練BTでは絶対に出ない特徴量の値を見て推論してしまう。
bars = feed.get_bars(symbol) # 末尾に未確定/ダミーが混じりうる
rsi = compute_rsi(bars["close"], 14) # ← 汚染された系列でRSIが歪む
sig = model.predict(make_features(bars)) # ← 訓練BTには存在しない入力
厄介なのは、これがエラーを出さないことです。処理は最後まで通り、それらしいシグナルが出る。だから「なぜか勝てない」という曖昧な症状としてしか現れません。
診断は特徴量ベクトルの突き合わせで一発です。意思決定の瞬間に、ライブと疑似BTそれぞれで「モデルに渡した特徴量ベクトルそのもの」をログし、差分を取ります。
# 意思決定の瞬間に、入力そのものを記録する
log.debug("decision feat=%s", feat.tolist())
ここがズレていたら、シグナル云々の前に入力が違う。原因はモデルではなくデータパイプラインです。対処は、特徴量計算に入る前に未確定バーを検証・除去し、「ライブの特徴量ベクトル == 疑似BTの特徴量ベクトル」をアサーションで保証することです。ここが一致して初めて、シグナルの議論ができます。
構造由来の代表例:損切りの時間解像度 + 約定遅延
一方、「ライブ = 疑似BT ≠ 訓練BT」側、つまり追っても直らない乖離の代表が、損切り(や手仕舞い)の時間解像度と約定ラグです。
訓練BTが1分足で動いているとします。よくある実装はこうです。
# 訓練BT:分足のlowがSL価格を割ったら、そのバーで約定したとみなす
if bar.low <= sl_price:
fill_price = sl_price # ちょうどSL価格で約定したことにする
fill_time = bar.time # 実際にはバー内のどこかで割っている
ところがライブは違います。
- 価格はティックで動き、SL価格に触れた瞬間に成行が飛ぶ
- 注文が板に届くまで数百ms〜数秒の遅延がある
- 約定価格はSLちょうどではなく、滑った先
つまり訓練BTは「分足の安値ちょうどで、バー確定時刻に約定」と仮定するのに対し、ライブは「ティックで触れた瞬間に発注し、ラグとスリッページを伴って約定」する。約定の価格も時刻も構造的に違うわけです。
これはバグではありません。市場と発注の物理的な現実です。だから疑似BT(=ライブと同じ生データ・同じコード)とは一致し、整形済み前提の訓練BTとだけズレる。ここを「バグだ」と思ってコードを直そうとしても、永遠に直りません。
正しい対処は、訓練BT側を現実に寄せることです。具体的には、
- 約定遅延を時間として注入する(タッチから一定ラグ後に約定判定)
- SL/Exitの判定解像度を、ライブの監視頻度に合わせる
- 約定価格にスリッページモデルを乗せる(呼値で丸める)
これで訓練BTの成績は下がります。でもそれが本物の期待値です。乖離を消すのではなく、乖離の分だけ訓練BTを悲観的にして、ライブと整合させるのが目的です。
切り分けフロー(チェックリスト)
乖離に気づいたら、上から順に。
- 疑似BTを用意する — ライブの入力スナップショットを記録し、同一コードで再生する。これが無いと何も切り分けられない
- 特徴量ベクトルを3者で突き合わせる — ライブ vs 疑似BTがズレたら、そこで止めてデータパイプラインを直す(バグ由来)
- ライブ=疑似BTを確認 — 一致したら、コードとライブデータは正しい。残る乖離は訓練BTとの間にある
- 執行モデルを疑う — 約定遅延・SL解像度・スリッページ・呼値・手数料。訓練BT側にこれらを注入して寄せる(構造由来)
- それでも残る差はログで説明する — 1トレードずつ、ライブと訓練BTで「いつ・いくらで・なぜ」約定したかを並べ、差の出どころを言語化する
実務的なコツ
- 意思決定の瞬間の入力を必ずログする。 結果(PnL)だけを見ても乖離は切り分けられません。「その時刻にモデルへ渡した特徴量」こそが証拠です
- タイムスタンプを揃える。 タイムゾーンや「バーの時刻はバーの開始か終了か」のズレは、それ単体で乖離を生みます。先物など別系列を特徴量に混ぜている場合は特に(この話は次々回で単独で掘り下げます)
- ライブ専用と検証専用でコードを分けない。 同じ関数を両方が呼ぶ構造にしておくと、疑似BTが自然に成立します。実装が2本に分かれた瞬間、乖離の温床になります
- 乖離をゼロにしようとしない。 目標は「乖離の正体を全部説明できる状態」であって、差をゼロにすることではありません
まとめ
ライブ/BT乖離は、システムトレードで最初にぶつかり、そして一番価値のあるスキルです。要点は3つ。
- 乖離にはバグ由来(潰す)と構造由来(寄せる)の2種類がある
- 疑似BTを挟んだ3者比較で、どちらかを判定する
- ゴールは差をゼロにすることではなく、差の出どころを全部説明できること
「なぜか勝てない」を「ここがこうズレているから、この分だけ訓練BTを悲観化する」に変換できれば、システムは一気に信用できるものになります。
ここまでの3回で、検証は信用できるものになりました。時間も情報も漏らさず、ライブとの差も説明できる。では——その正しい検証の上で、無数のパラメータの中から何を選び、何を信じればいいのか。次回はこのシリーズの山場、過学習との戦いを書きます。
質問や「うちの環境だとこうズレる」という話があればコメントで。連載として、検証が嘘をつくパターンを順に潰していきます。