← 返回文章列表
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}

我們在同一個全價差(而非半價差)的基準上度量這三個量,使該恆等式在量綱上保持一致。單筆成交的有效價差為:

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 秒內價差(及其組成部分)的做市商,可以動態調整報價,在維持成交率的同時最大化實現價差。

價差的三個組成部分

買賣價差被分解為訂單處理成本、庫存成本和逆向選擇成本三層

市場微觀結構文獻(Stoll 1978、Glosten 和 Milgrom 1985、Huang 和 Stoll 1997)把買賣價差分解為三個經濟組成部分。

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 和 Mendelson(1980)把它建模為波動率與做市商當前庫存的函數:

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

其中 σ\sigma 是資產的波動率,QQ 是做市商當前的庫存。隨著庫存增長,做市商在自己暴露的那一側加寬價差、在另一側收窄價差,這種手法稱為庫存傾斜

3. 逆向選擇成本 (γ\gamma)

這是最危險的組成部分。知情交易者——那些對即將到來的價格變動掌握更優資訊的人——會系統性地"狙擊"陳舊的報價。逆向選擇成本等於因知情對手方而導致的每筆交易的預期損失。Copeland 和 Galai(1983)把它建模為做市商免費送給知情交易者的一份期權的價值。Glosten 和 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 和 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})

計算一階自協方差:

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 是樣本一階自協方差。

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,差了一個價格水平的因子;若再用中間價去除那個結果來得到基點,誤差會被進一步放大。如果你更想用收益率空間的估計量,那就在對數價格空間中推導模型,並去掉那次額外的除以中間價的步驟;選定一種約定,並讓程式碼與數學保持一致。

Roll 模型的侷限

Roll 的模型假設:(1)市場有效;(2)無資訊不對稱;(3)成交方向獨立同分布;(4)價差恆定。所有這些在實踐中都被違反。Harris(1990)指出,當應用於含噪資料時,由於 Jensen 不等式,該估計量存在嚴重偏差。儘管有這些侷限,Roll 估計量作為一個快速的基準仍然有用,並在實證金融研究中被廣泛使用。

用於價差預測的機器學習特徵

工程化的微觀結構特徵——波動率、訂單流失衡、深度、成交強度——饋入一個價差預測器

要超越靜態模型,我們需要能夠刻畫價差變化動態驅動因素的特徵。以下是一個特徵分類,按其所代理的價差組成部分組織。

訂單簿特徵(庫存與逆向選擇)

特徵 公式 代理的物件
訂單簿失衡 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's lambda Δ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 折),就能消除這種偏差。這一點對梯度提升流水線和深度學習同樣適用,儘管這種洩露在序列模型中被討論得更多。

特徵重要性分析

基於樹的模型的關鍵優勢之一是可解釋性。訓練之後,檢查 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. 已實現波動率是第二重要的——日內波動率與價差強正相關,無論是同期還是動態地。
  3. 訂單簿失衡在劇烈波動期間最為重要——它預示著即將到來的方向性變動。
  4. 成交流失衡捕捉短期逆向選擇——一波單邊流量預示著價差將加寬。
  5. 時段捕捉 U 形的日內模式(開盤/收盤較寬,午間較窄)。

超參數考量

專門針對價差預測:

  • 使用 MAE 或 Huber 損失,而不是 MSE。價差分佈是右偏的,偶爾會出現極端離群值(在新聞事件期間)。MAE 更穩健。
  • min_child_samples 設得較高(100 以上),以防模型去擬合單個快照中的微觀結構噪聲。
  • 使用 subsample < 1.0 來去除樹之間的相關性,並改善在不同波動率狀態下的泛化能力。

深度學習方法

雖然梯度提升在表格型特徵上表現出色,但深度學習可以直接從原始訂單簿資料中學習表徵。有兩種架構在與價差相關的預測任務中被證明是有效的。

架構一:用於訂單簿快照的 CNN-LSTM

DeepLOB 架構(Zhang 等人,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)

如果你確實想在檔位軸上做池化以控制參數量,那就使用帶步長的或可學習的池化,以保留多於一個位置——而不是 AdaptiveAvgPool1d(1),它會把每個檔位都平均成一個數字,從而丟掉空間訊號。

架構二: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. 歸一化:把價格歸一化為相對中間價的偏移量(以最小變動價位或基點為單位)。把成交量按其滾動均值歸一化。原始價格和成交量會導致訓練不穩定。

  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 萬行以上
特徵工程 手工(投入高,控制力強) 學習得到(投入低,可解釋性差)
推理延遲 用編譯後的預測器只需個位數微秒;從 Python 呼叫則是幾十微秒 在 GPU 上數百微秒
可解釋性 高(SHAP) 低(注意力圖)
狀態適應 重訓/線上更新 在近期資料上微調
短視野價差預測能力 與深度學習大體相當 在更長視野/更大數據上優勢擴大

我們刻意不引用具體的 R² 數字:價差預測的準確度在很大程度上取決於視野、資產,以及分數中有多少隻是價差自相關。一個模型可以拿出一個亮眼的原始 R²,卻幾乎不比一行 EWMA 多出任何東西。報告時要給出在相同資料上、相對於 AR/EWMA 基準的預測能力提升,並註明視野,而不是一個標題裡的 R²。同樣,把延遲數字當作依賴實現的:一個 2000 輪、63 葉子的 LightGBM 模型,從 Python 預測單行需要幾十微秒,只有在用編譯/C++ 預測器時才能達到幾微秒。

在實踐中,許多生產系統採用一種兩階段方法:一個快速的梯度提升模型用於即時報價(對延遲敏感),以及一個非同步執行的深度學習模型,用於調整提升模型的參數或提供一個輔助訊號。

從預測到報價

把預測出的價差轉化為即時買賣報價,圍繞公允中價按庫存傾斜

只有當價差預測能轉化為更好的報價時,它才有價值。下面是一條使用預測價差的簡化報價規則:

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)時,傾斜會同時壓低買價和賣價。從一個單一、一致的視角看:更低的賣價讓吃單方從我們這裡買入更便宜,從而卸掉我們的多頭庫存;更低的買價讓我們更不容易被賣方擊中,從而減緩進一步累積。預測價差控制整體寬度——當模型預期波動或逆向選擇時加寬,在行情平靜時收窄。

評估與回測

評估一個價差模型:預測價差與實現價差的校準,以及成交率和盈虧的衡量

價差預測指標

除了標準迴歸指標(MAE、R2R^2)之外,還要用對做市有意義的指標來評估價差預測:

  • 相對基準的預測能力:始終報告相對於近期價差的 AR(1) 或 EWMA 預測的 MAE/R²。因為價差強烈持續,絕對分數被自相關所主導;只有相對於一個平凡基準的提升才反映真正的預測內容。
  • 方向準確度:模型能否正確預測價差將加寬還是收窄?一個 MAE 平平但方向準確度高的模型仍然可以盈利。
  • 尾部覆蓋:模型能否預測價差尖峰?對價差值最高的 5% 單獨計算 MAE——逆向選擇損失正集中在這裡。
  • 校準:繪製預測價差分位數與實現價差分位數的對比圖。一個校準良好的模型,其第 90 百分位的預測應當與實現價差的第 90 百分位相吻合。

基於盈虧的評估

歸根結底,唯一重要的指標是實現盈虧。回測整個閉環:

  1. 在每個時間戳預測價差
  2. 用預測價差 + 庫存傾斜計算報價
  3. 對歷史成交模擬成交
  4. 追蹤庫存、實現盈虧和夏普比率

與以下基準對比:(a) 恆定價差(時間序列中位數),(b) 近期價差的 EWMA,以及 (c) Roll 估計量。

結論

價差建模處在金融理論與應用機器學習的交匯點。把價差經典地分解為訂單處理、庫存和逆向選擇成本,提供了價差為何變化的經濟直覺。Roll 的模型從極少的資料中給出一個優雅的基準估計量——只要你以價格單位來計算它。梯度提升模型把微觀結構特徵轉化為準確的短視野價差預測,並具備低延遲推理。深度學習架構直接從原始訂單簿資料中學習,捕捉手工特徵可能遺漏的模式——前提是架構保留了跨檔結構,而不是把它池化掉。

對於一個生產級的做市系統,實用的建議是分層的:

  1. 離線使用 Huang-Stoll 分解,以理解你的價差組成部分並校準風險限額
  2. Roll 估計量用作合理性檢查,以及用於那些你缺乏訂單簿資料的品種
  3. 部署一個 LightGBM 模型用於即時價差預測——它快速、可解釋、穩健——配以清洗的前向驗證和一個 AR/EWMA 基準
  4. 在一個輔助閉環中執行 CNN-LSTM 或 Transformer 模型,以檢測狀態變化並調整主模型

價差不是一個數字——它是一個訊號。你對它建模得越好(並且越誠實地度量這個模型),就能越精確地為流動性提供定價。


本文是 marketmaker.cc 演算法做市與微觀結構系列的一部分。

免責宣告:本文提供的資訊僅用於教育和參考目的,不構成財務、投資或交易建議。加密貨幣交易涉及重大損失風險。

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 交易見解、市場分析和平台更新。

我們尊重您的隱私。您可以隨時退訂。