0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

バックテストに「摩擦」を実装する——約定モデルの作り方

0
Last updated at Posted at 2026-07-15

この記事は Zenn に投稿したものの再掲です。初出: バックテストに「摩擦」を実装する——約定モデルの作り方

以前、「検証が嘘をつくとき」という連載で、ライブとバックテストの乖離について書きました。その中で、バグではない構造的な乖離——約定遅延やSLの時間解像度の話をして、「対処は訓練BT側を現実に寄せることだ」と締めました。

今回はその宿題の実装編です。バックテストが理想化している部分を、どう「摩擦」としてモデル化し、どこまでやればいいのかを具体的に書きます。

なぜ理想的なバックテストは嘘になるのか

素朴なバックテストは、暗黙にこう仮定しています。

  • 指値/成行を出したら、その足の価格でちょうど約定する
  • 発注してから約定するまでの時間はゼロ
  • 自分の注文は市場に何の影響も与えない

現実はどれも成り立ちません。この3つのギャップを埋めるのが、今回作る約定モデルです。

モデル化する3要素

1. 約定遅延(レイテンシ)

シグナルが出てから、実際に注文が板に届いて約定するまでには時間がかかります。ネットワーク、証券会社のシステム、板の処理——積み上げれば無視できない時間になります。

from dataclasses import dataclass
import numpy as np

@dataclass
class ExecutionModel:
    latency_ms: float = 300.0       # 発注〜約定までの遅延
    slippage_ticks: float = 1.0     # 平均スリッページ(呼値単位)
    tick_size: float = 1.0          # 銘柄の呼値

    def resolve_fill(self, signal_time, signal_price, future_prices):
        """
        signal_time: シグナルが出た時刻
        future_prices: signal_time以降の価格系列(時刻, 価格)
        """
        fill_time = signal_time + pd.Timedelta(milliseconds=self.latency_ms)
        # 遅延後、最初に観測できる価格で約定したとみなす
        idx = future_prices.index.searchsorted(fill_time)
        if idx >= len(future_prices):
            return None  # 遅延中にデータが尽きた=約定できない
        base_price = future_prices.iloc[idx]
        return fill_time, base_price

固定値のlatency_msから始めるのが現実的です。分布(正規分布や対数正規分布)でサンプリングして幅を持たせる高度化は、まず固定値で全体の効果を確認してからで十分です。

2. スリッページ

約定価格は、シグナル発生時の価格から必ずズレます。方向は基本的に不利側(買いなら高く、売りなら安く)に偏ります。

    def apply_slippage(self, base_price, side: str):
        slip = self.slippage_ticks * self.tick_size
        if side == "buy":
            return base_price + slip   # 買いは不利な方向=高く
        else:
            return base_price - slip   # 売りは不利な方向=安く

スリッページ幅の見積もりは、可能ならライブの実約定履歴から逆算するのが一番正確です。「シグナル価格と実約定価格の差」を集計し、その中央値や分布を使います。まだライブ履歴がなければ、呼値の1〜3ティック程度を保守的な初期値として置き、後で実データに合わせて更新します。

3. SL/Exitの判定解像度

前回触れた通り、素朴な実装は「バーの安値がSL価格を割ったら、そのバーで約定」と仮定しがちです。これはバー内のどこで割ったかを無視しています。

    def resolve_stop(self, bar, sl_price, tick_stream=None):
        """
        tick_stream があれば、ティック単位でSLタッチの実時刻を特定する。
        なければ、バー内で保守的に「安値到達は終値寄り」と仮定する。
        """
        if bar["low"] > sl_price:
            return None  # このバーでは未タッチ

        if tick_stream is not None:
            touch = tick_stream[tick_stream["price"] <= sl_price]
            if len(touch) == 0:
                return None
            return touch.index[0], sl_price  # ティックで特定した実タッチ時刻

        # ティックがない場合の保守的近似:終値寄りでタッチしたとみなす
        approx_time = bar.name + (bar["close_time"] - bar.name) * 0.8
        return approx_time, sl_price

理想はティックデータでの再現ですが、無ければ「バー内の後半でタッチした」と仮定する保守的な近似でも、何もしないよりはるかに現実に近づきます。

摩擦を全部合わせる

3要素を1つのフローにまとめます。

class FrictionModel:
    def __init__(self, latency_ms=300.0, slippage_ticks=1.0, tick_size=1.0):
        self.exec_model = ExecutionModel(latency_ms, slippage_ticks, tick_size)

    def simulate_fill(self, signal_time, signal_price, side, future_prices):
        result = self.exec_model.resolve_fill(signal_time, signal_price, future_prices)
        if result is None:
            return None
        fill_time, base_price = result
        final_price = self.exec_model.apply_slippage(base_price, side)
        return {
            "signal_time": signal_time,
            "signal_price": signal_price,
            "fill_time": fill_time,
            "fill_price": final_price,
            "cost": abs(final_price - signal_price),
        }

cost列を記録しておくと、あとで「摩擦だけでどれだけ削られているか」を戦略と切り離して測定できます。これが次の検証ステップの材料になります。

パラメータをどう決めるか

架空の数字を当てずっぽうで置いても意味がありません。優先順位はこうです。

  1. ライブの実約定履歴がある場合:シグナル時刻・シグナル価格・実約定時刻・実約定価格をログに残しておき、そこからlatency_msslippage_ticksの実測分布を作る。これが最も信頼できる
  2. ライブ履歴がまだない場合:証券会社のAPI仕様(発注〜約定の目安レイテンシ)と、対象銘柄の呼値・出来高から保守的な初期値を置く。あえて厳しめに見積もっておくと、後で「思ったより良かった」方向にしか外れません
  3. 感度分析latency_msslippage_ticksを意図的に±50%ふって、戦略の成績がどれだけ揺れるかを見る。摩擦への感度が高い戦略は、それだけ本番でも脆いということです

検証:摩擦を入れて何が変わったか

摩擦モデルを導入する前後で、指標を並べて比較します。

def compare_with_without_friction(trades_ideal, trades_friction):
    return pd.DataFrame({
        "理想(摩擦なし)": summarize(trades_ideal),
        "摩擦あり": summarize(trades_friction),
    })

ここで見るべきは「成績が落ちたかどうか」ではなく、落ち方が説明できるかどうかです。落ち幅がcost列の合計とだいたい一致していれば、摩擦モデルは正しく機能しています。逆に、説明できない落ち幅が残っているなら、まだ捕まえていない構造的な乖離が他にある、というサインです。これは前回の連載で扱った「疑似BTでの3者比較」と地続きの発想です。

まとめ

  • 理想化されたバックテストは、遅延・スリッページ・SL解像度の3つを暗黙に無視している
  • 各要素は、固定値の簡単なモデルから始めれば十分。分布化は後回しでいい
  • パラメータは当てずっぽうではなく、ライブ実約定からの逆算 → 保守的初期値 → 感度分析の順で決める
  • 摩擦導入後は、成績の落ち幅がcost説明できるかを確認する。説明できない落ち幅は、まだ見つけていない乖離のサイン

バックテストに摩擦を足す作業は地味で、成績は必ず下がります。でもそれは損失ではなく、今まで見えていなかった本当の期待値が見えるようになったというだけのことです。


このシリーズで扱ってきた罠(未来参照・特徴量リーク・ライブ/BT乖離・過学習・タイムスタンプ)と、この執行モデルの実装、そして全体を1つの検証パイプラインとして統合する話は、Zennの本にまとめています。気になった方はプロフィールから覗いてみてください。

質問や「うちはこう見積もった」という話があればコメントで。

0
0
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
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?