← 返回文章列表
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 交易见解、市场分析和平台更新。

我们尊重您的隐私。您可以随时退订。