0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

この記事は Zenn に投稿したものの再掲です。初出: 「バックテストは勝つのにライブで負ける」を分解する

系列トレードの検証で踏む罠を扱う連載の第3回です。第1回(未来参照バグ)・第2回(特徴量リーク)の続きです。

前回までで、時間の漏れ(第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を悲観的にして、ライブと整合させるのが目的です。

切り分けフロー(チェックリスト)

乖離に気づいたら、上から順に。

  1. 疑似BTを用意する — ライブの入力スナップショットを記録し、同一コードで再生する。これが無いと何も切り分けられない
  2. 特徴量ベクトルを3者で突き合わせる — ライブ vs 疑似BTがズレたら、そこで止めてデータパイプラインを直す(バグ由来)
  3. ライブ=疑似BTを確認 — 一致したら、コードとライブデータは正しい。残る乖離は訓練BTとの間にある
  4. 執行モデルを疑う — 約定遅延・SL解像度・スリッページ・呼値・手数料。訓練BT側にこれらを注入して寄せる(構造由来)
  5. それでも残る差はログで説明する — 1トレードずつ、ライブと訓練BTで「いつ・いくらで・なぜ」約定したかを並べ、差の出どころを言語化する

実務的なコツ

  • 意思決定の瞬間の入力を必ずログする。 結果(PnL)だけを見ても乖離は切り分けられません。「その時刻にモデルへ渡した特徴量」こそが証拠です
  • タイムスタンプを揃える。 タイムゾーンや「バーの時刻はバーの開始か終了か」のズレは、それ単体で乖離を生みます。先物など別系列を特徴量に混ぜている場合は特に(この話は次々回で単独で掘り下げます)
  • ライブ専用と検証専用でコードを分けない。 同じ関数を両方が呼ぶ構造にしておくと、疑似BTが自然に成立します。実装が2本に分かれた瞬間、乖離の温床になります
  • 乖離をゼロにしようとしない。 目標は「乖離の正体を全部説明できる状態」であって、差をゼロにすることではありません

まとめ

ライブ/BT乖離は、システムトレードで最初にぶつかり、そして一番価値のあるスキルです。要点は3つ。

  1. 乖離にはバグ由来(潰す)と構造由来(寄せる)の2種類がある
  2. 疑似BTを挟んだ3者比較で、どちらかを判定する
  3. ゴールは差をゼロにすることではなく、差の出どころを全部説明できること

「なぜか勝てない」を「ここがこうズレているから、この分だけ訓練BTを悲観化する」に変換できれば、システムは一気に信用できるものになります。

ここまでの3回で、検証は信用できるものになりました。時間も情報も漏らさず、ライブとの差も説明できる。では——その正しい検証の上で、無数のパラメータの中から何を選び、何を信じればいいのか。次回はこのシリーズの山場、過学習との戦いを書きます。


質問や「うちの環境だとこうズレる」という話があればコメントで。連載として、検証が嘘をつくパターンを順に潰していきます。

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?