← 記事一覧に戻る
June 3, 2026
読了時間: 5分

機械学習によるビッドアスクスプレッドのモデリングと予測

機械学習によるビッドアスクスプレッドのモデリングと予測
#microstructure
#spread
#market-making
#prediction
#machine-learning
#gradient-boosting
#deep-learning

ビッドアスクスプレッドは、マーケットメーカーが制御する変数の中で最も重要なものです。広く設定しすぎれば競合にフローを奪われます。狭く設定しすぎれば逆選択が在庫を食い荒らします。伝統的なマイクロストラクチャ理論は、スプレッドをその経済的構成要素へとエレガントに分解する手法を与えてくれます。機械学習は、それらの構成要素がリアルタイムでどう変化するかを予測するツールを与えてくれます。本稿は両方の世界を橋渡しします。古典的な理論から始め、Rollの暗黙的スプレッド推定量へと積み上げ、その後オーダーブック特徴量からスプレッドを予測する勾配ブースティングおよびディープラーニングモデルへと進みます。その道中で、実務においてスプレッドモデルを静かに無効化してしまう単位・リーク・ベンチマークの罠を指摘します。

なぜスプレッドはマーケットメーカーにとって重要なのか

マーケットメーカーはビッド価格 PbP_b とアスク価格 PaP_a を継続的に提示します。クォートスプレッドは次の通りです。

S=PaPbS = P_a - P_b

すべてのラウンドトリップ(メーカーのビッドで買い、メーカーのアスクで売る。いずれもテイカーが約定させる)は、理論上、最大 SS をテイカーからメーカーへ移転します。実務上は、メーカーが得るのは SS より少なくなります。逆選択があるためです。一部のテイカーは情報を持っており、価格がメーカーに不利に動く直前に取引します。ラウンドトリップあたりの実現利益は 実現スプレッド であり、これは実効スプレッドから価格インパクトを引いたものに等しくなります。

Srealized=SeffectivePriceImpactS_{\text{realized}} = S_{\text{effective}} - \text{PriceImpact}

これら3つの量はすべて同じ フルスプレッド 基準(ハーフではなく)で測定するため、恒等式は次元的に整合します。単一の取引に対する実効スプレッドは次の通りです。

Seffective=2dt(PtMt)S_{\text{effective}} = 2 \cdot d_t \cdot (P_t - M_t)

ここで dt{+1,1}d_t \in \{+1, -1\} は取引方向(テイカーの買いまたは売り)、PtP_t は約定価格、MtM_t は取引時点でのベストビッドとベストアスクの中値です。価格インパクト項は、取引後のホライズン τ\tau にわたって対称的に定義されます。

PriceImpact=2dt(Mt+τMt)\text{PriceImpact} = 2 \cdot d_t \cdot (M_{t+\tau} - M_t)

ここで Mt+τM_{t+\tau} は取引後ホライズン τ\tau 経過時点の中値です。ホライズンは明示的に述べなければなりません。一般的な選択肢は、株式では5分、価格の再評価がより速いクリプトでは30秒です。実効スプレッドからインパクトを引くと実現スプレッドが残ります。これは市場が動いた後にメーカーが手元に残すものです。

次の1秒、5秒、または60秒におけるスプレッドとその構成要素を 予測 できるマーケットメーカーは、約定率を維持しつつ実現スプレッドを最大化するようにクォートを動的に調整できます。

スプレッドの3つの構成要素

オーダー処理コスト、在庫コスト、逆選択コストの各層に分解されたビッドアスクスプレッド

マーケットマイクロストラクチャの文献(Stoll 1978、Glosten and Milgrom 1985、Huang and Stoll 1997)は、ビッドアスクスプレッドを3つの経済的構成要素に分解します。

1. オーダー処理コスト(α\alpha)

これは、約定したサイドごとにマーケットメイクサービスを提供するコストです。メーカーが実際に支払う手数料に加え、テクノロジーインフラ、規制コンプライアンス、そして投下資本の機会コストが含まれます。Demsetz(1968)とTinic(1972)がこの構成要素を最初に定式化しました。

重要な区別は 誰がどの手数料を支払うか です。パッシブにクォートを出すメーカーは、自身の約定に対して メーカー手数料 fmf_m を支払います。そして多くの取引所では fmf_mリベート、すなわち負の値です。メーカーはそれらのパッシブな約定に対してテイカー手数料 ftf_t を支払い ません。スプレッドをまたぐ相手方が ftf_t を支払います。したがって、メーカーのサイドあたりのオーダー処理コストは次の通りです。

αfm+cinfra\alpha \approx f_m + c_{\text{infra}}

ここで fmf_m は符号付き(リベートは α\alpha を下げ、負にもなり得る)で、cinfrac_{\text{infra}} は接続性、コロケーション、計算リソースをカバーします。現代の電子市場では、この構成要素は劇的に縮小しました。株式ではサブペニー、クリプトでは数ベーシスポイントまたはネットのリベートです。

テイカー手数料 ftf_t は別の理由で重要です。それは フル スプレッドがどこまで狭く採算が取れるかの下限を設定します。スプレッドをまたぐテイカーは、スプレッドに加えて ftf_t を支払うからです。そのテイカーコストに対してクォートを経済的に魅力的に保つスプレッド下限を設けたいなら、ftf_t をメーカー自身のコストに織り込むのではなく、別個に動機づけてください。両者を混同すると、単一のハーフスプレッドの内側でラウンドトリップ手数料を二重計上することになります。

2. 在庫保有コスト(β\beta)

マーケットメーカーが方向性のあるポジション(ロングまたはショート)を蓄積すると、価格リスクを負います。在庫構成要素はこのリスクを補償します。Stoll(1978)とAmihud and Mendelson(1980)は、これをボラティリティとメーカーの現在の在庫の関数としてモデル化しました。

βσQ\beta \propto \sigma \cdot |Q|

ここで σ\sigma は資産のボラティリティ、QQ はメーカーの現在の在庫です。在庫が増えるにつれて、メーカーはエクスポージャーを持つサイドのスプレッドを広げ、反対側を狭めます。これは 在庫スキューイング と呼ばれる手法です。

3. 逆選択コスト(γ\gamma)

これは最も危険な構成要素です。情報を持つトレーダー、すなわち目前の価格変動についてより優れた情報を持つ者は、古いクォートを体系的に取りに来ます。逆選択コストは、情報を持つ相手方への取引あたり期待損失に等しくなります。Copeland and Galai(1983)は、これをメーカーが情報を持つトレーダーに与える無料のオプションの価値としてモデル化しました。Glosten and Milgrom(1985)は、取引を観測した後のメーカーの信念のベイズ的改訂として定式化しました。

γ=E[Vtrade]Mt\gamma = E[V | \text{trade}] - M_t

ここで VV は真のファンダメンタル価値です。流動性の高い市場では、逆選択がスプレッド全体の30〜60%を占めることがあります。

完全な分解

クォートハーフスプレッドは次のように書けます。

S2=α+β+γ\frac{S}{2} = \alpha + \beta + \gamma

α\alphaβ\betaγ\gamma はすべてサイドあたり(ハーフスプレッド)コストとして表現されます。これが会計上の整合性を保つものです。Huang and Stoll(1997)は、取引データとクォートデータからこれらの構成要素を推定する計量経済学的手法を提案しました。重要な洞察は次の通りです。オーダー処理コストは固定的なスプレッド下限を作り出し、在庫コストはポジションとボラティリティに応じて変動するスプレッドを作り出し、逆選択コストは情報の非対称性に応じて変動するスプレッドを作り出します。

Rollの暗黙的スプレッドモデル

Rollの暗黙的スプレッドモデル:ビッドとアスクの間を跳ねる約定価格が、負の系列共分散の特徴を残す様子

高頻度データが広く利用可能になる前、Richard Roll(1984)は約定価格のみを用いて実効スプレッドを推定するエレガントな手法を提案しました。彼の洞察は次の通りです。効率的市場では、新しい情報がない場合でも、ビッドアスクバウンスが価格変化に負の系列共分散を誘発します。

モデル

ファンダメンタル価値 VtV_t がランダムウォークに従うと仮定します。

Vt=Vt1+ut,uti.i.d.(0,σu2)V_t = V_{t-1} + u_t, \quad u_t \sim \text{i.i.d.}(0, \sigma_u^2)

観測される約定価格はビッドとアスクの間を跳ねます。

Pt=Vt+S2dtP_t = V_t + \frac{S}{2} \cdot d_t

ここで dt{1,+1}d_t \in \{-1, +1\} は等確率(すなわち、買いと売りは等しく起こりやすい)です。価格変化は次の通りです。

ΔPt=ut+S2(dtdt1)\Delta P_t = u_t + \frac{S}{2}(d_t - d_{t-1})

1次自己共分散を計算すると次のようになります。

Cov(ΔPt,ΔPt1)=S24\text{Cov}(\Delta P_t, \Delta P_{t-1}) = -\frac{S^2}{4}

このモデルは 価格単位 で導出されます。SS はリターンではなく価格 変化 の自己共分散から出てきます。この区別は最も多い実装上の誤りであり、以下のコードではこれに忠実に保ちます。

Roll推定量

SS について解くと次のようになります。

S^Roll=2Cov(ΔPt,ΔPt1)\hat{S}_{\text{Roll}} = 2\sqrt{-\text{Cov}(\Delta P_t, \Delta P_{t-1})}

標本自己共分散が正の場合(ノイズやモメンタムのため実務上頻繁に起こります)、推定量は定義されません。一般的な対処法は、推定値をゼロに設定するか、符号付きの平方根を用いることです。

S^Roll=2sign(γ^1)γ^1\hat{S}_{\text{Roll}}^{*} = 2 \cdot \text{sign}(-\hat{\gamma}_1) \cdot \sqrt{|\hat{\gamma}_1|}

ここで γ^1\hat{\gamma}_1 は標本1次自己共分散です。

Pythonでの実装

この推定量は 価格単位 でスプレッドを返します。それをベーシスポイントで表現するには、一度だけ中値で割ります。リターン空間の推定量とは異なり、まだ価格で割られていないためです。

import numpy as np
import pandas as pd

def roll_spread(prices: pd.Series, window: int = 200) -> pd.Series:
    """
    Rolling Roll (1984) spread estimator, in PRICE units.

    The model is P_t = V_t + (S/2) d_t with Cov(ΔP_t, ΔP_{t-1}) = -S^2/4,
    so S is recovered from the autocovariance of price CHANGES (diff),
    not returns (pct_change). Using returns rescales the estimate by the
    price level and is wrong by roughly that factor.

    Parameters
    ----------
    prices : pd.Series
        Transaction prices.
    window : int
        Rolling window size (number of price changes).

    Returns
    -------
    pd.Series
        Estimated spread per window, in price units.
    """
    dprice = prices.diff().dropna()
    autocov = dprice.rolling(window).apply(
        lambda x: np.cov(x[:-1], x[1:])[0, 1], raw=True
    )
    return 2.0 * np.sqrt(np.maximum(-autocov, 0.0))


trades = pd.read_parquet("trades.parquet")
trades["roll_spread"] = roll_spread(trades["price"], window=200)

trades["quoted_spread"] = trades["ask"] - trades["bid"]
trades["midprice"] = 0.5 * (trades["ask"] + trades["bid"])
trades["quoted_spread_bps"] = trades["quoted_spread"] / trades["midprice"] * 1e4
trades["roll_spread_bps"] = trades["roll_spread"] / trades["midprice"] * 1e4

シミュレートした系列(価格100付近のファンダメンタルなランダムウォークで真のスプレッドが S=0.10S = 0.10)に対する手早い健全性チェックでは、価格変化から 0.0999\approx 0.0999 を復元します。リターンベースの変種は 0.001\approx 0.001 を返し、価格水準の分だけずれます。そして それ をさらに中値で割ってbpsにすると誤差が累積します。リターン空間の推定量を好むなら、対数価格空間でモデルを導出し、中値での2回目の除算を省いてください。一つの規約を選び、コードを数式に一致させてください。

Rollモデルの限界

Rollのモデルは次を仮定します。(1)市場効率性、(2)情報の非対称性がないこと、(3)取引方向がi.i.d.であること、(4)スプレッドが一定であること。これらすべてが実務では破られます。Harris(1990)は、ノイズの多いデータに適用した場合、ジェンセンの不等式により推定量が深刻にバイアスされることを示しました。これらの限界にもかかわらず、Roll推定量は手早いベースラインとして依然として有用であり、実証ファイナンス研究で広く用いられています。

スプレッド予測のためのML特徴量

ボラティリティ、オーダーフローの不均衡、デプス、取引強度といった設計されたマイクロストラクチャ特徴量がスプレッド予測器に供給される様子

静的なモデルを超えるには、スプレッド変動の動的な駆動要因を捉える特徴量が必要です。以下は、各特徴量がプロキシとなるスプレッド構成要素ごとに整理した分類です。

オーダーブック特徴量(在庫と逆選択)

特徴量 数式 プロキシ対象
ブック不均衡 BI=Vb1Va1Vb1+Va1\text{BI} = \frac{V_b^1 - V_a^1}{V_b^1 + V_a^1} 方向性の圧力
加重中値 Pw=PaVbVb+Va+PbVaVb+VaP_w = P_a \cdot \frac{V_b}{V_b + V_a} + P_b \cdot \frac{V_a}{V_b + V_a} 短期のフェアバリュー
デプス比(レベル1〜5) DR5=i=15Vbii=15Vai\text{DR}_5 = \frac{\sum_{i=1}^{5} V_b^i}{\sum_{i=1}^{5} V_a^i} 複数レベルの需給
ブック圧力 BP=i=15Vbiwibi=15Vaiwia\text{BP} = \sum_{i=1}^{5} V_b^i\, w_i^b - \sum_{i=1}^{5} V_a^i\, w_i^a 距離加重した圧力
スプレッド/ティック比 S/tickS / \text{tick} 最小値に対するタイトさ

ここでのブック圧力は 中値までの絶対距離による減衰wi=eλPiMw_i = e^{-\lambda |P_i - M|} を用いるため、タッチ付近にある出来高は深い出来高より重く数えられ、両サイドが正で減少する関数によって加重されます。これは、出来高を 符号付き 距離 PiMP_i - M(ビッド側で負、アスク側で正となり、レベルが中値に近づくと発散する)で割ることによる構造的な符号バイアスを回避します。λ\lambda は典型的なブックの深さから選ぶか、指数関数を距離について減少する任意の正の重み w(PiM)w(|P_i - M|) に置き換えてください。

トレードフロー特徴量(逆選択)

特徴量 数式 プロキシ対象
取引不均衡 TIn=i=1ndivii=1nvi\text{TI}_{n} = \frac{\sum_{i=1}^{n} d_i \cdot v_i}{\sum_{i=1}^{n} v_i} ネットの情報を持つフロー
VPIN 出来高同期化された情報トレーディング確率 トキシシティ
Kyleのラムダ 符号付き出来高に対する ΔM\Delta M の回帰 単位あたり価格インパクト
大口取引頻度 ウィンドウ内で >kmedian> k \cdot \text{median} の取引数 機関投資家の活動

ボラティリティ特徴量(在庫コスト)

特徴量 数式 プロキシ対象
実現ボラティリティ σrv=(ΔlogM)2\sigma_{\text{rv}} = \sqrt{\sum (\Delta \log M)^2} 短期リスク
Garman-Klassボラティリティ 12(logH/L)2(2ln21)(logC/O)2\frac{1}{2}(\log H/L)^2 - (2\ln 2 - 1)(\log C/O)^2 レンジベースのボラティリティ
ボラティリティのボラティリティ σrv\sigma_{\text{rv}} のローリング標準偏差 レジームの不確実性
リターン自己相関 ρ1(ΔM)\rho_1(\Delta M) モメンタム/平均回帰

市場レジーム特徴量

特徴量 説明 プロキシ対象
時刻のエンコーディング sin(2πt/T),cos(2πt/T)\sin(2\pi t / T), \cos(2\pi t / T) 日中の季節性
直近取引からの経過秒数 時間ギャップ 活動水準
クロスアセット相関 インデックス/BTCとのローリング相関 システマティックリスク
ファンディングレート(クリプト) 無期限のファンディングレート レバレッジドポジショニング

スプレッド予測のための勾配ブースティング

勾配ブースティング木(XGBoost、LightGBM、CatBoost)は、クオンツファイナンスにおける表形式予測の主力です。混在した特徴量タイプを扱い、非線形な相互作用を捉え、最小限の前処理で済み、数百万行でも高速に学習します。ただし特徴量の構築自体がベクトル化されていることが前提です(以下の自己相関に関する注意を参照)。

問題設定

スプレッド予測を回帰タスクとして枠組みします。ターゲットは、次の τ\tau 秒間にわたる時間加重平均クォートスプレッドです。

yt=1τtt+τS(u)duy_t = \frac{1}{\tau} \int_{t}^{t+\tau} S(u) \, du

実務では、これを次の NN スナップショットにわたる出来高加重平均スプレッドで近似します。

yt=i=1NSt+iVt+ii=1NVt+iy_t = \frac{\sum_{i=1}^{N} S_{t+i} \cdot V_{t+i}}{\sum_{i=1}^{N} V_{t+i}}

このターゲットは長さ NN(または horizon)の フォワードウィンドウ であり、隣接する行が重なり合う未来のウィンドウを共有することを意味します。その重なりは、素朴な訓練/検証分割にまたがって情報をリークします。これを以下の学習コードで明示的に処理します。

完全なパイプライン

import lightgbm as lgb
import numpy as np
import pandas as pd
from sklearn.metrics import mean_absolute_error, r2_score

def build_features(df: pd.DataFrame) -> pd.DataFrame:
    """Build spread-prediction features from L2 order book snapshots."""
    f = pd.DataFrame(index=df.index)

    f["imb1"] = (df["bid_vol_1"] - df["ask_vol_1"]) / (
        df["bid_vol_1"] + df["ask_vol_1"] + 1e-9
    )

    bid_depth = df[[f"bid_vol_{i}" for i in range(1, 6)]].sum(axis=1)
    ask_depth = df[[f"ask_vol_{i}" for i in range(1, 6)]].sum(axis=1)
    f["depth_imb5"] = (bid_depth - ask_depth) / (bid_depth + ask_depth + 1e-9)

    mid = 0.5 * (df["ask_1"] + df["bid_1"])
    f["spread_bps"] = (df["ask_1"] - df["bid_1"]) / mid * 1e4
    f["log_spread"] = np.log1p(df["ask_1"] - df["bid_1"])

    log_ret = np.log(mid / mid.shift(1))
    f["rvol_50"] = log_ret.rolling(50).std()
    f["rvol_200"] = log_ret.rolling(200).std()

    if "trade_sign" in df.columns and "trade_vol" in df.columns:
        signed_vol = df["trade_sign"] * df["trade_vol"]
        total_vol = df["trade_vol"].rolling(50).sum()
        f["tfi_50"] = signed_vol.rolling(50).sum() / (total_vol + 1e-9)

    lag1 = log_ret.shift(1)
    f["ret_autocorr"] = log_ret.rolling(100).corr(lag1)

    if isinstance(df.index, pd.DatetimeIndex):
        seconds = df.index.hour * 3600 + df.index.minute * 60 + df.index.second
        f["tod_sin"] = np.sin(2 * np.pi * seconds / 86400)
        f["tod_cos"] = np.cos(2 * np.pi * seconds / 86400)

    for lag in [1, 5, 10, 50]:
        f[f"spread_lag_{lag}"] = f["spread_bps"].shift(lag)

    return f.dropna()


def build_target(df: pd.DataFrame, horizon: int = 10) -> pd.Series:
    """Forward mean spread over the next `horizon` snapshots (in bps).

    target[t] = mean(spread_bps[t+1 .. t+horizon]). Note that consecutive
    targets share an overlapping forward window of length `horizon`, which
    is why the CV below purges a gap of `horizon` rows around each fold.
    """
    mid = 0.5 * (df["ask_1"] + df["bid_1"])
    spread_bps = (df["ask_1"] - df["bid_1"]) / mid * 1e4
    fwd = spread_bps.shift(-1).rolling(horizon).mean().shift(-(horizon - 1))
    return fwd


def purged_walk_forward(n: int, n_splits: int, horizon: int):
    """Expanding-window splits with a purge/embargo gap of `horizon` rows.

    Because each target spans `horizon` future snapshots, rows straddling a
    train/val boundary share overlapping target windows. Dropping a gap of
    `horizon` rows between train and validation removes that leakage
    (Lopez de Prado-style purging). Without it, validation R²/MAE are
    optimistically biased by the target overlap.
    """
    fold_size = n // (n_splits + 1)
    for k in range(1, n_splits + 1):
        train_end = fold_size * k
        val_start = train_end + horizon       # embargo gap
        val_end = val_start + fold_size
        if val_end > n:
            break
        train_idx = np.arange(0, train_end - horizon)   # purge gap
        val_idx = np.arange(val_start, val_end)
        yield train_idx, val_idx


def train_spread_model(features: pd.DataFrame, target: pd.Series, horizon: int = 10):
    """Train LightGBM with purged, embargoed walk-forward validation."""
    common = features.index.intersection(target.dropna().index)
    X = features.loc[common].reset_index(drop=True)
    y = target.loc[common].reset_index(drop=True)

    models, scores = [], []
    params = {
        "objective": "mae",
        "learning_rate": 0.05,
        "num_leaves": 63,
        "min_child_samples": 100,
        "subsample": 0.8,
        "colsample_bytree": 0.8,
        "reg_alpha": 0.1,
        "reg_lambda": 1.0,
        "verbose": -1,
    }

    for fold, (train_idx, val_idx) in enumerate(
        purged_walk_forward(len(X), n_splits=5, horizon=horizon)
    ):
        X_tr, X_val = X.iloc[train_idx], X.iloc[val_idx]
        y_tr, y_val = y.iloc[train_idx], y.iloc[val_idx]

        ds_tr = lgb.Dataset(X_tr, y_tr)
        ds_val = lgb.Dataset(X_val, y_val, reference=ds_tr)

        model = lgb.train(
            params,
            ds_tr,
            num_boost_round=2000,
            valid_sets=[ds_val],
            callbacks=[lgb.early_stopping(50), lgb.log_evaluation(200)],
        )
        preds = model.predict(X_val)
        mae = mean_absolute_error(y_val, preds)
        r2 = r2_score(y_val, preds)
        print(f"Fold {fold}: MAE={mae:.4f} bps, R²={r2:.4f}")
        models.append(model)
        scores.append({"mae": mae, "r2": r2})

    return models[-1], scores

決定的に重要な詳細は パージ/エンバーゴのギャップ です。フォワード平均ターゲットは、隣接する行が最大 horizon スナップショット分重なり合うことを意味するため、素朴な TimeSeriesSplit では検証行が訓練行と未来のウィンドウを共有してしまい、答えをリークして検証R²を膨らませます。各フォールド境界の両側で少なくとも horizon 行のギャップを落とすこと(Lopez de Prado流のパージドk-fold)で、そのバイアスを取り除きます。これは、リークがシーケンスモデルでより一般的に議論されるとはいえ、ディープラーニングと同じく勾配ブースティングのパイプラインにも当てはまります。

特徴量重要度の分析

木ベースモデルの主要な利点の一つは解釈可能性です。学習後、SHAP値を調べて、どの特徴量がスプレッド予測を駆動しているかを理解します。

import shap

explainer = shap.TreeExplainer(model)
shap_values = explainer.shap_values(X_val)
shap.summary_plot(shap_values, X_val, max_display=15)

アセットクラスをまたぐ典型的な発見は次の通りです。

  1. ラグ付きスプレッド(spread_lag_1\text{spread\_lag\_1})はほぼ常に最も重要な特徴量です。スプレッドは強く自己相関しています。これは見出しのR²が高く見える理由でもあります。スコアの多くは単なる持続性なので、常にAR/EWMAベースラインと比較してください(詳細は後述)。
  2. 実現ボラティリティ は2番目に重要です。日中ボラティリティとスプレッドは、同時点でも動的にも強く正に相関します。
  3. ブック不均衡 はボラティリティの高い局面で最も重要です。目前の方向性の動きを示唆します。
  4. トレードフロー不均衡 は短期の逆選択を捉えます。一方向のフローのバーストはスプレッドの拡大を予測します。
  5. 時刻 はU字型の日中パターン(寄り付き/引けで広く、日中で狭い)を捉えます。

ハイパーパラメータの考慮事項

スプレッド予測に特化して言えば次の通りです。

  • MSEではなく MAEまたはHuber損失 を使ってください。スプレッド分布は右に歪んでおり、時折(ニュースイベント時に)極端な外れ値があります。MAEはよりロバストです。
  • min_child_samples を高く(100以上)設定し、モデルが個々のスナップショットのマイクロストラクチャノイズにフィットするのを防いでください。
  • subsample < 1.0 を使って木を脱相関させ、異なるボラティリティレジーム間での汎化を改善してください。

ディープラーニングのアプローチ

勾配ブースティングが表形式特徴量で優れる一方、ディープラーニングは生のオーダーブックデータから直接表現を学習できます。スプレッド関連の予測タスクには、2つのアーキテクチャが有効であることが示されています。

アーキテクチャ1:オーダーブックスナップショットのためのCNN-LSTM

DeepLOBアーキテクチャ(Zhang et al. 2019)は、積み重ねた小カーネルの畳み込み(およびInceptionモジュール)を用いて、その空間構造を 保持 しながらオーダーブックレベル全体にわたる空間パターンを抽出し、続いてLSTM層で時間的依存性をモデル化します。重要な設計上の選択は、再帰層の前にレベル軸をグローバルプーリングで 消し去らない ことです。そうすると、まさに畳み込みが捉えようとしているレベル間構造が潰れてしまいます。

スプレッド予測では、入力は形状 (T,L,F)(T, L, F) のテンソルです。

  • TT = タイムステップ数(例:100スナップショット)
  • LL = 価格レベル数(例:ビッド10 + アスク10 = 20)
  • FF = レベルあたりの特徴量(価格、出来高、注文数)

以下のモデルは、レベルにわたる畳み込み特徴マップを保持し、それをLSTM入力に 平坦化 します(input_size = 16 * L)。レベル次元を16個のチャネル平均に平均化するのではありません。

import torch
import torch.nn as nn


class SpreadPredictor(nn.Module):
    """
    CNN-LSTM model for bid-ask spread prediction from L2 order book.

    Input: (batch, seq_len, n_levels, n_features)
    Output: (batch, 1) — predicted spread in bps
    """

    def __init__(
        self,
        n_levels: int = 20,
        n_features: int = 3,
        seq_len: int = 100,
        hidden_dim: int = 64,
        n_lstm_layers: int = 2,
        dropout: float = 0.2,
    ):
        super().__init__()
        self.seq_len = seq_len
        self.n_levels = n_levels

        self.conv = nn.Sequential(
            nn.Conv1d(n_features, 32, kernel_size=3, padding=1),
            nn.BatchNorm1d(32),
            nn.LeakyReLU(0.1),
            nn.Conv1d(32, 16, kernel_size=3, padding=1),
            nn.BatchNorm1d(16),
            nn.LeakyReLU(0.1),
        )
        conv_out_dim = 16 * n_levels  # flattened (channels × levels)

        self.lstm = nn.LSTM(
            input_size=conv_out_dim,
            hidden_size=hidden_dim,
            num_layers=n_lstm_layers,
            batch_first=True,
            dropout=dropout,
        )

        self.head = nn.Sequential(
            nn.Linear(hidden_dim, 32),
            nn.ReLU(),
            nn.Dropout(dropout),
            nn.Linear(32, 1),
        )

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        """
        Parameters
        ----------
        x : Tensor of shape (batch, seq_len, n_levels, n_features)

        Returns
        -------
        Tensor of shape (batch, 1) — predicted spread
        """
        batch, T, L, F = x.shape

        x = x.reshape(batch * T, L, F).permute(0, 2, 1)
        x = self.conv(x)                  # (batch * T, 16, L)
        x = x.reshape(batch, T, 16 * L)   # (batch, T, 16 * L) — keep levels

        lstm_out, _ = self.lstm(x)        # (batch, T, hidden_dim)
        last_hidden = lstm_out[:, -1, :]  # (batch, hidden_dim)

        return self.head(last_hidden)     # (batch, 1)

パラメータ数を制御するためにレベル軸でプーリングを行いたい場合は、単一の位置より多くを保持するストライド付きまたは学習されたプーリングを使ってください。あらゆるレベルを1つの数値に平均化して空間信号を捨ててしまう AdaptiveAvgPool1d(1) は使わないでください。

アーキテクチャ2:Transformerエンコーダ

Transformerは、LSTMのような逐次的なボトルネックなしに、オーダーブックシーケンスの長距離依存性を捉えられます。スプレッド予測には、軽量なTransformerエンコーダがうまく機能します。

class TransformerSpreadPredictor(nn.Module):
    """Transformer encoder for spread prediction from order book sequences."""

    def __init__(
        self,
        input_dim: int = 40,   # 20 levels * 2 features (price_offset, volume)
        d_model: int = 64,
        nhead: int = 4,
        n_layers: int = 3,
        seq_len: int = 100,
        dropout: float = 0.1,
    ):
        super().__init__()
        self.input_proj = nn.Linear(input_dim, d_model)
        self.pos_encoding = nn.Parameter(torch.randn(1, seq_len, d_model) * 0.02)

        encoder_layer = nn.TransformerEncoderLayer(
            d_model=d_model,
            nhead=nhead,
            dim_feedforward=d_model * 4,
            dropout=dropout,
            batch_first=True,
            activation="gelu",
        )
        self.encoder = nn.TransformerEncoder(encoder_layer, num_layers=n_layers)
        self.head = nn.Sequential(
            nn.LayerNorm(d_model),
            nn.Linear(d_model, 1),
        )

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        """
        x: (batch, seq_len, input_dim) — flattened order book snapshots
        """
        x = self.input_proj(x) + self.pos_encoding[:, : x.size(1), :]
        x = self.encoder(x)
        return self.head(x[:, -1, :])

学習上の考慮事項

  1. 正規化:価格は中値からのオフセット(ティックまたはbps)として正規化してください。出来高はそのローリング平均で正規化してください。生の価格と出来高は学習の不安定性を引き起こします。

  2. 損失関数:スプレッドのスパイクを扱うためにHuber損失(δ=1.0\delta = 1.0)を使ってください。

Lδ(y,y^)={12(yy^)2if yy^δδyy^12δ2otherwiseL_\delta(y, \hat{y}) = \begin{cases} \frac{1}{2}(y - \hat{y})^2 & \text{if } |y - \hat{y}| \le \delta \\ \delta |y - \hat{y}| - \frac{1}{2}\delta^2 & \text{otherwise} \end{cases}

  1. ウィンドウサンプリングとリーク:学習には重ならないウィンドウを使い、そして勾配ブースティングのパイプラインとまったく同じように、訓練と検証の間で少なくとも horizon スナップショットのギャップをパージ/エンバーゴしてください。フォワード平均ターゲットと重なり合う入力ウィンドウの両方が、分割境界をまたいで未来の情報をリークし、見かけの性能を膨らませます。

  2. オンライン適応:本番環境では、小さな学習率で直近データ(直近1〜2時間)に対してモデルを定期的にファインチューニングしてください。マーケットマイクロストラクチャは日中に変化し、午前のデータで学習したモデルは午後にアンダーパフォームするかもしれません。

ディープラーニングと勾配ブースティングをいつ使うか

基準 勾配ブースティング ディープラーニング
入力タイプ 表形式特徴量 生のオーダーブックシーケンス
学習データ量 10万行以上で機能 100万行以上が必要
特徴量エンジニアリング 手動(高労力、高制御) 学習(低労力、解釈性が低い)
推論レイテンシ コンパイル済み予測器で1桁µs、Pythonからは数十µs GPU上で数百µs
解釈可能性 高(SHAP) 低(アテンションマップ)
レジーム適応 再学習/オンライン更新 直近データでファインチューニング
短ホライズンのスプレッドスキル おおむねDLと同等 長いホライズン/大規模データでエッジが拡大

特定のR²数値を引用することは意図的に避けます。スプレッド予測の精度は、ホライズン、資産、そしてスコアのうちどれだけが単なるスプレッドの自己相関であるかに大きく依存します。あるモデルは、1行のEWMAにほとんど何も付け加えていないのに、印象的な生のR²を計上できてしまいます。見出しのR²ではなく、同じデータ上で、ホライズンを明示した上で AR/EWMAベースラインを上回るスキル を報告してください。同様に、レイテンシ数値も実装依存として扱ってください。2000ラウンド、63リーフのLightGBMモデルは、Pythonからは1行を数十マイクロ秒で予測し、コンパイル済み/C++予測器でようやく数マイクロ秒に達します。

実務では、多くの本番システムが 2段階アプローチ を用います。リアルタイムのクォート(レイテンシクリティカル)用の高速な勾配ブースティングモデルと、ブースティングモデルのパラメータを調整したり二次的なシグナルを提供したりするために非同期で動くディープラーニングモデルです。

予測からクォートへ

フェアな中値の周りで在庫によってスキューされ、予測スプレッドをライブのビッド/アスククォートに変換する様子

スプレッド予測は、それがより良いクォートに変換されて初めて価値を持ちます。以下は、予測スプレッドを用いる簡略化したクォートルールです。

def compute_quotes(
    mid: float,
    predicted_spread_bps: float,
    inventory: float,
    max_inventory: float,
    skew_factor: float = 0.5,
    min_spread_bps: float = 1.0,
) -> tuple[float, float]:
    """
    Compute bid/ask quotes from predicted spread and inventory.

    Parameters
    ----------
    mid : float
        Current midprice.
    predicted_spread_bps : float
        Model-predicted spread in basis points.
    inventory : float
        Current inventory (positive = long).
    max_inventory : float
        Maximum allowed inventory.
    skew_factor : float
        How aggressively to skew quotes toward inventory neutrality.
    min_spread_bps : float
        Minimum spread floor (covers order processing costs).

    Returns
    -------
    (bid, ask) : tuple[float, float]
    """
    spread_bps = max(predicted_spread_bps, min_spread_bps)
    half_spread = mid * spread_bps / 2e4

    inv_ratio = inventory / max_inventory  # in [-1, 1]
    skew = skew_factor * inv_ratio * half_spread

    bid = mid - half_spread - skew
    ask = mid + half_spread - skew

    return bid, ask

在庫がロング(Q>0Q > 0)のとき、スキューはビッドとアスクの両方を下げます。単一の一貫した観点から述べると、アスクを下げるとテイカーが私たちから買うのが安くなり、ロング在庫を放出します。ビッドを下げると売り手に当てられにくくなり、さらなる蓄積が遅くなります。予測スプレッドは全体の幅を制御します。モデルがボラティリティや逆選択を予想すれば広げ、状況が穏やかなら狭めます。

評価とバックテスト

スプレッドモデルの評価:予測スプレッドと実現スプレッドのキャリブレーション、ならびに約定率とPnLのゲージ

スプレッド予測の指標

標準的な回帰指標(MAE、R2R^2)を超えて、マーケットメイクにとって重要な指標でスプレッド予測を評価してください。

  • ベースラインを上回るスキル:常に、直近スプレッドのAR(1)またはEWMA予測に 対する MAE/R²を報告してください。スプレッドは強く持続的なので、絶対的なスコアは自己相関に支配されます。自明なベースラインを上回る改善のみが、真の予測内容を反映します。
  • 方向性の正確さ:モデルはスプレッドが広がるか狭まるかを正しく予測しますか? MAEが平凡でも方向性の正確さが高いモデルは、依然として採算が取れることがあります。
  • テールのカバレッジ:モデルはスプレッドのスパイクを予測しますか? スプレッド値の上位5%について別途MAEを計算してください。逆選択損失が集中するのはここです。
  • キャリブレーション:予測スプレッドと実現スプレッドの分位点をプロットしてください。よくキャリブレートされたモデルの90パーセンタイル予測は、実現スプレッドの90パーセンタイルと一致するはずです。

PnLベースの評価

究極的に、重要な唯一の指標は実現PnLです。ループ全体をバックテストしてください。

  1. 各タイムスタンプでスプレッドを予測する
  2. 予測スプレッド + 在庫スキューを用いてクォートを計算する
  3. ヒストリカル取引に対して約定をシミュレートする
  4. 在庫、実現PnL、シャープレシオを追跡する

ベースラインと比較してください。(a)一定スプレッド(時系列の中央値)、(b)直近スプレッドのEWMA、(c)Roll推定量。

結論

スプレッドのモデリングは、金融理論と応用MLの交差点に位置します。オーダー処理、在庫、逆選択コストへの古典的な分解は、スプレッドが なぜ 変動するのかという経済的直観を与えます。Rollのモデルは、最小限のデータからエレガントなベースライン推定量を与えます。ただし価格単位で計算する限りにおいてです。勾配ブースティングモデルは、マイクロストラクチャ特徴量を低レイテンシ推論で正確な短ホライズンのスプレッド予測へと変えます。ディープラーニングアーキテクチャは生のオーダーブックデータから直接学習し、手作りの特徴量が見逃すかもしれないパターンを捉えます。ただしアーキテクチャがレベル間構造をプーリングで消し去るのではなく保持する限りにおいてです。

本番のマーケットメイクシステムに対する実務的な推奨は階層的です。

  1. Huang-Stoll分解 をオフラインで用い、自分のスプレッド構成要素を理解しリスク上限をキャリブレートする
  2. Rollの推定量 を健全性チェックとして、またオーダーブックデータがない銘柄向けに用いる
  3. リアルタイムのスプレッド予測に LightGBMモデル をデプロイする。高速で、解釈可能で、ロバストである。パージドウォークフォワード検証とAR/EWMAベンチマークとともに
  4. 二次的なループで CNN-LSTMまたはTransformerモデル を動かし、レジームの変化を検出して一次モデルを調整する

スプレッドは数値ではありません。それはシグナルです。それをうまくモデル化すればするほど(そしてそのモデルをより正直に測定すればするほど)、流動性供給をより精密に値付けできます。


本稿は、アルゴリズミックなマーケットメイクとマイクロストラクチャに関する marketmaker.cc シリーズの一部です。

blog.disclaimer

Authors

Eugen Soloviov
Eugen Soloviov

Trading-systems engineer

Trading-systems engineer building bots since 2017: cross-exchange arbitrage (connected up to 30 venues), cointegration-based pairs arbitrage across spot and futures, scalping, news and sentiment-driven strategies, trend algorithms, and portfolio management and balancing algorithms. Also builds sub-millisecond order execution, big-data warehouses, backtesting engines, AI agents, and trading interfaces (incl. open-source profitmaker.cc). Stack: JS/TS, Python, Rust/Zig/Go, DevOps, backend, frontend, architecture.

Newsletter

市場の先を行く

ニュースレターを購読して、独占的なAI取引の洞察、市場分析、プラットフォームの更新情報を受け取りましょう。

プライバシーを尊重します。いつでも配信停止可能です。