土法炼钢 · 系统与基础设施

【量化交易】市场微结构:订单簿、价差、流动性、冲击

文章导航

分类入口
quant
标签入口
#market-microstructure#limit-order-book#bid-ask-spread#order-flow-imbalance#kyle-lambda#vpin#hawkes-process#polars

量化交易系列导航

按系列顺序继续阅读,而不是停在单篇。

系列目录上一篇:【量化交易】市场结构:交易所、做市商、暗池、ECN下一篇:【量化交易】订单类型与执行语义:限价、市价、IOC、FOK、冰山

源码下载

本文相关源码已整理,共 2 个文件。

打开下载目录 →

目录

回测通常默认三件事:按中价(mid price)成交、自己的单子不推动价格、盘口永远有量可吃。实盘里每一笔非零数量的交易都要付价差、付冲击,还要承担被更知情的对手方逆向选择(adverse selection)的损失。常见误区是把这些都折成一个固定的「滑点 bp」:它把订单簿形状、信息不对称、流动性恢复速度三种机制混在一起,换品种、换时段、换规模就失效。本文回答一个具体问题:这些成本分别从订单簿的哪里来,用什么可计算的指标量出来。路线是从限价订单簿(Limit Order Book,LOB)的数据模型出发,把价差拆成四种口径并做结构分解,把流动性拆成四个维度,再讲订单流不平衡(OFI)、Kyle λ、PIN/VPIN 和 Hawkes 过程;价差分解、OFI 与 Kyle λ 都在币安 LTCUSDT 永续合约一天的真实盘口数据上复现,Hawkes 的估计陷阱用模拟数据演示。冲击成本模型的标定、Almgren-Chriss 最优执行、Implementation Shortfall 与 TCA 放在姊妹篇《交易成本模型》,本文只交代冲击的微结构来源。

速查表

概念 一句话定义 可计算指标 见
L1 / L2 / L3 订单簿 按价格、时间排队的未成交限价单,三种聚合粒度 best bid/ask、档量、队列位置 第一节
加权中价 / 微观价 用最优档量不平衡修正的中价 \((q_b a + q_a b)/(q_a+q_b)\);Stoikov micro-price 第一节
报价价差 挂出来的最优卖价减最优买价 \(a_t - b_t\) 第二节
有效价差 成交价偏离成交前中价的 2 倍 \(2D_t(p_t - m_t)\) 第二节
已实现价差 流动性提供者在 \(\Delta\) 之后真正留住的部分 \(2D_t(p_t - m_{t+\Delta})\) 第二节
价格冲击(逆向选择) 成交后中价朝成交方向的漂移 \(2D_t(m_{t+\Delta} - m_t)\) 第二、五节
价差结构分解 订单处理、库存、逆向选择各占多少 Roll、Glosten-Harris、Huang-Stoll 回归 第二节
流动性四维 紧度、深度、即时性、弹性 价差、档量、Amihud、冲击半衰期 第三节
Kyle λ 单位净订单流推动的价格 \(\Delta p = \lambda Q + \varepsilon\) 的斜率 第三节
OFI 最优档上买方净增量减卖方净增量 Cont-Kukanov-Stoikov 的 \(e_n\) 求和 第四节
永久 / 临时冲击 留在价格里的部分 / 之后回吐的部分 markout 曲线、Hasbrouck VAR 第五节;标定见第 18 篇
PIN / VPIN 成交来自知情交易者的概率 / 其成交量同步版本 EKOP 极大似然;成交量桶失衡 第六节
Hawkes 分支比 一个事件平均直接触发的后续事件数 指数核下 \(n = \alpha/\beta\) 第七节

本文在真实数据和模拟数据上实际跑出的三个结论(脚本与环境见第二、四、七节):

  1. 币安 LTCUSDT 永续 2023-05-17 08:46 至 24:00(UTC),10 秒窗口内最优档 OFI 对中价变化的 \(R^2\) 为 0.61,同窗口带符号成交量只有 0.37。见第四节。
  2. 同一样本中,有效价差均值 1.58 bp,成交后 0.1 秒就有 1.04 bp 表现为价格冲击,5 分钟后升到 1.26 bp,流动性提供者留住的已实现价差只剩 0.33 bp。见第二节。
  3. 一个没有任何自激、只是强度在两档之间切换的泊松过程,用指数核 Hawkes 拟合出的分支比约 0.91。Hawkes「临界性」的解读要先排除这种伪临界。见第七节。

上一篇《市场结构:交易所、做市商、暗池、ECN》讨论交易场所和制度;本文把镜头推到撮合引擎门口的那张订单簿上,讨论微秒到分钟尺度。撮合本身怎么实现,见《撮合引擎实现》;本文关心的是撮合留下的痕迹该怎么读。


一、限价订单簿:所有微结构现象的载体

1.1 数据模型:三层抽象

订单簿在不同层次上呈现给不同消费者。把这一点搞清楚,可以避免大量「为什么我看到的数据和别人不一样」的混乱。

层级 内容 典型数据源 适合做什么
L1 最优买价、最优卖价、最优档量 多数行情终端、Level-1 行情、币安 bookTicker 计算中价、报价价差、最优档 OFI
L2 全部价格档的聚合数量(每档累计 qty,部分源带单数) 沪深 Level-2、Nasdaq TotalView 聚合视图、币安 depth 看深度、多档不平衡
L3 每一笔单独订单(order id、到达时间、修改撤单事件) Nasdaq TotalView-ITCH、CME MDP 3.0 MBO 排队位置预测、Hawkes 估计、做市回测

L2 和 L3 的差别不只是分辨率:L2 把同一价格上的所有订单聚合成一个数字,丢掉了「我排在第几位」。要预测排队位置(queue position)的策略,例如做市挂单、被动执行算法,必须用 L3 或对 L2 做排队近似(见第 8.10 节)。做秒级方向预测的统计套利,L1/L2 通常够用,本文第二、四节的实证只用了 L1。

下图是一个 L2 快照的示意。100.04 这一档没有挂单,价差是 2 个 tick。

限价订单簿 L2 快照:卖盘、买盘、价差与最优买价队列内部的 FIFO 顺序

1.2 价格优先、时间优先

几乎所有连续竞价市场使用价格优先、时间优先(price-time priority)撮合:价格更优的订单先成交;同价位按到达时间先进先出(FIFO)。上交所、深交所《交易规则》对竞价交易都规定按价格优先、时间优先撮合;集合竞价阶段按「最大成交量」等原则确定单一成交价,同一价格内仍按时间先后成交。

时间优先在工程上有一个常被忽略的细节:改单是否保留优先级。以 CME Globex 为例,减少数量保留原队列位置,改价或增加数量视为新单重新排队;A 股委托不能修改,只能撤单后重新申报,重新排到队尾。各市场的具体语义见《订单类型与执行语义》。这条规则直接决定做市策略的挂单管理:任何「轻轻动一下报价」都要付出排队位置的代价。

1.3 队列位置的价值

排在队列前面有两重好处:更早成交、拿到价差;以及更少「最后才成交」。队尾订单往往恰好在该价位即将被吃穿、价格随即朝不利方向跳动时才成交,这就是逆向选择。Moallemi 与 Yuan 在 A Model for Queue Position Valuation in a Limit Order Book(工作论文,SSRN 2017)中把队列位置价值拆成两部分:静态部分是价差收益与逆向选择成本之差,越靠后越差;动态部分是保住位置的期权价值。他们的结论是,在大 tick 品种上,队列位置的价值可以与半个价差同量级。

由此推出的经验规则:tick 相对价格越大,价差越容易被钉在 1 个 tick、队列越长,排队位置越值钱;tick 相对价格越小,价差可以在多个 tick 间浮动,抢一个更优价位比排队更有效。A 股最小变动单位 0.01 元,30 元的股票一个 tick 约 3.3 bp,3 元的股票约 33 bp,后者是更「大 tick」的品种。本文实证用的 LTCUSDT 永续中价中位数 92.56 USDT、tick 0.01,97.7% 的盘口更新里价差恰好 1 个 tick,同样处于大 tick 状态。

1.4 隐藏单与冰山单

L2 看到的档量未必是真实供给:

识别冰山的一个工程启发式(经验判断,非文献结论):短窗口内同一价位反复出现「被吃完、立即补上相同数量」的模式。对做市策略,遇到冰山意味着价格被某个不可见的供给压住,按可见深度估计的冲击会偏大。

1.5 加权中价与微观价

中价 \(m = (a+b)/2\) 忽略了最优档的量。当买盘远厚于卖盘时,下一次价格变动更可能向上。常用的修正是按对侧挂单量加权的加权中价(weighted mid):

\[ p_{\text{w}} = \frac{q_b \cdot a + q_a \cdot b}{q_a + q_b} \]

其中 \(q_a, q_b\) 是最优卖、最优买的挂单量。\(q_b\) 大时权重落在 \(a\) 上,价格偏向卖价。图 1 右侧的例子里 \(q_b = 700\)、\(q_a = 500\),加权中价 100.042,高于中价 100.040。

Stoikov 在 The Micro-Price: A High-Frequency Estimator of Future Prices(Quantitative Finance, 18(12), 2018)中提出的 micro-price 与加权中价不同:它被定义为在当前不平衡和价差状态下、未来中价条件期望序列的极限,需要从数据里估计状态转移,而不是直接用上式。加权中价是更简单的启发式。两者的共同出发点「最优档不平衡包含短期价格方向信息」,正是第四节 OFI 的思想来源。


二、买卖价差:四种定义与结构分解

价差不是一个数字。报价价差、有效价差、已实现价差、价格冲击回答的是不同的问题,混用会让交易成本、做市收益和市场质量的讨论全部失焦。

报价价差、有效价差、已实现价差与价格冲击的关系示意:一笔市价买单吃到卖一之上一档后,中价先超调再部分回落

2.1 报价价差(Quoted Spread)

\[ S^q_t = a_t - b_t \]

或归一化为相对价差 \(S^q_t / m_t\)。报价价差是「看到的成本」,不是「实际付出的成本」:单子可能吃穿多档,也可能用限价单在中价附近被动成交。

2.2 有效价差(Effective Spread)

\[ S^e_t = 2 D_t (p_t - m_t) \]

\(D_t \in \{+1, -1\}\) 是成交方向(买方主动为 \(+1\))。乘以 2 是为了与报价价差同量纲:恰好在最优价成交时 \(S^e_t = S^q_t\);吃穿多档时 \(S^e_t > S^q_t\)。

方向 \(D_t\) 常常拿不到,需要推断。最常用的是 Lee 与 Ready(Journal of Finance, 46(2), 1991)的算法:成交价高于中价标 \(+1\),低于中价标 \(-1\);恰好等于中价时用 tick test(与上一笔不同成交价比较,价升 \(+1\)、价降 \(-1\))。原文针对当时 NYSE 报价时间戳滞后的问题,建议用成交前 5 秒的报价,现代毫秒级数据不应照搬这一滞后。

币安 aggTrades 自带主动方标志(is_buyer_maker),可以直接检验报价规则。用成交前最近一条(严格早于成交时刻的)最优报价判断,LTCUSDT 样本 208,554 笔非中价成交里,报价规则与交易所标志一致的比例是 99.17%,恰好落在中价上的成交只有 0.05%。在价差被钉在 1 个 tick 的品种上,报价规则几乎就是真值;价差宽、成交常落在价差内部的品种上,误判率会高得多。沪深 Level-2 逐笔成交可以直接或通过买卖委托序号得到主动方向,字段定义以各所行情接口规范为准。

2.3 已实现价差(Realized Spread)

\[ S^r_t = 2 D_t (p_t - m_{t+\Delta}) \]

已实现价差衡量流动性提供者在 \(\Delta\) 之后真正留住的部分:如果买单成交后中价马上被推上去,做市商卖出的那部分价差就有一块被对手方的信息吃掉了。\(\Delta\) 的选择本身就是口径。SEC Rule 605 原先用 5 分钟;2024 年 3 月 SEC 通过的 Rule 605 修订要求在 50 毫秒、1 秒、15 秒、1 分钟和 5 分钟多个时点报告已实现价差,正是因为不同尺度上看到的逆向选择不同。

2.4 价格冲击(Price Impact)

\[ \text{Impact}_t = 2 D_t (m_{t+\Delta} - m_t) \]

由定义直接得到价差分解恒等式:

\[ S^e_t = S^r_t + \text{Impact}_t \]

有效价差的一部分作为提供流动性的补偿留给做市商(已实现价差),另一部分是被逆向选择的代价(冲击)。注意这里的「冲击」是单笔、固定 \(\Delta\) 的口径,在很短的 \(\Delta\) 上它混入了机械性的盘口消耗,不能直接等同于第五节的永久冲击。

2.5 真实数据:markout 曲线

下面用币安 USD-M 永续 LTCUSDT 在 2023-05-17 的公开历史数据(data.binance.vision 的 bookTicker 与 aggTrades 两个文件)计算四种价差。该日 bookTicker 文件从 08:46:22 UTC 开始,样本取与成交重叠的 15.23 小时:最优报价更新 2,143,612 条,主动成交(aggTrade)208,654 笔。方向用交易所的主动方标志,均值按成交笔数等权,单位是成交前中价的 bp,不含手续费。

markout \(\Delta\) 报价价差 有效价差 已实现价差 价格冲击
0.1 s 1.130 1.584 0.543 1.040
1 s 1.130 1.584 0.456 1.128
5 s 1.130 1.584 0.515 1.068
15 s 1.130 1.584 0.531 1.053
60 s 1.130 1.584 0.371 1.212
300 s 1.130 1.584 0.327 1.257
币安 LTCUSDT 永续真实数据的 markout 曲线:随 Δ 增大,已实现价差下降、价格冲击上升,两者之和恒等于有效价差

这张图回答「有效价差里多少是做市补偿、多少是逆向选择」。从表里能读出三件事:

这只是一个合约、一天的数据,结论的作用是说明口径和量级关系,不能外推到其他品种或其他日期。

2.6 从成交价反推并分解价差:Roll → Glosten-Harris → Huang-Stoll

有效价差需要报价;没有报价、只有成交价时,可以从价格的统计结构里反推价差,再进一步把价差拆成成本来源。这一支文献的演进是:

Roll(Journal of Finance, 39(4), 1984)。假设有效价格随机游走、成交方向独立同分布且与价格新息无关,成交价在买卖价之间来回弹跳(bid-ask bounce),相邻价格变化的一阶自协方差为:

\[ \operatorname{Cov}(\Delta p_t, \Delta p_{t-1}) = -\frac{S^2}{4}, \qquad \hat{S} = 2\sqrt{-\widehat{\operatorname{Cov}}(\Delta p_t, \Delta p_{t-1})} \]

Roll 模型隐含价差全是订单处理成本:成交不改变有效价格。自协方差为正时(成交方向持续、存在信息冲击)估计量无定义。

Glosten 与 Harris(Journal of Financial Economics, 21(1), 1988)。把价差拆成会回吐的暂时部分和改变有效价格的逆向选择部分,并允许两者随成交量 \(V_t\) 线性变化:

\[ p_t = m_t + Q_t (c_0 + c_1 V_t), \qquad m_t = m_{t-1} + Q_t (z_0 + z_1 V_t) + \varepsilon_t \]

\(Q_t\) 是方向,\(c\) 项是暂时成本,\(z\) 项是逆向选择成本。这是「永久 vs 临时」区分在价差层面的第一个可估计模型。

Huang 与 Stoll(Review of Financial Studies, 10(4), 1997)。在一个统一框架里同时纳入订单处理、库存和逆向选择三部分。其基本模型是:

\[ \Delta p_t = \frac{S}{2} Q_t + (\lambda - 1)\frac{S}{2} Q_{t-1} + e_t \]

\(\lambda\) 是半价差里逆向选择加库存成本的份额,\(1-\lambda\) 是订单处理份额;要进一步分开库存与逆向选择,需要对成交方向的序列相关建模(原文的三方程扩展)。基本模型在方向已知时就是一个无截距的 OLS。

把 Roll 和 Huang-Stoll 基本模型都放到上面的 LTCUSDT 成交序列上(同一个脚本,实际运行输出,以下输出经删减):

Roll spread from aggTrades: 0.01322 USDT  vs mean quoted 0.01029 USDT
Huang-Stoll basic model on aggTrades: S_hat=0.01373 USDT  adverse-selection+inventory share lambda=0.602  order-processing share=0.398  R2=0.420

两种成交价估计量给出的价差(0.0132、0.0137 USDT)都接近有效价差(1.58 bp × 92.56 ≈ 0.0147 USDT),高于报价价差,与 2.5 节的观察一致。Huang-Stoll 的 \(\hat\lambda = 0.60\) 和 markout 表里冲击占有效价差的比例(0.1 秒 0.66、5 分钟 0.79)在同一量级,但两者口径不同:前者是逐笔价格变化的回归,后者依赖 \(\Delta\) 的选择。Roll 模型「价差全是订单处理」的假设在这个样本上显然不成立。

2.7 只有日级数据时的价差估计

做跨国、跨年代的横截面研究时,经常只有日级 OHLC。两个常用估计量:

Corwin 与 Schultz(Journal of Finance, 67(2), 2012)利用日内最高价与最低价:

\[ \hat{S}_{CS} = \frac{2(e^\alpha - 1)}{1 + e^\alpha}, \quad \alpha = \frac{\sqrt{2\beta} - \sqrt{\beta}}{3 - 2\sqrt{2}} - \sqrt{\frac{\gamma}{3 - 2\sqrt{2}}} \]

\(\beta\) 是相邻两日 \([\ln(H/L)]^2\) 之和,\(\gamma\) 是两日合并后的 \([\ln(H_{2d}/L_{2d})]^2\)。直觉是:日内最高价多为买方主动成交、最低价多为卖方主动成交,高低价比同时包含波动和价差,而波动部分随窗口长度线性增长、价差部分不增长,用一日和两日窗口就能把两者分开。

Abdi 与 Ranaldo(Review of Financial Studies, 30(12), 2017)引入收盘价,记 \(\eta_t = (\ln H_t + \ln L_t)/2\)、\(c_t = \ln C_t\):

\[ \hat{S}_{AR} = 2 \sqrt{\max\left(0,\ \mathbb{E}\left[(c_t - \eta_t)(c_t - \eta_{t+1})\right]\right)} \]

两者都只适合日级以上的横截面研究,不要用于日内策略。

2.8 代码:用 polars 计算四种价差

以下函数与同目录脚本 ofi_spread_analysis.py 中的实现一致(Python 3.12.10、polars 2.0.0、numpy 2.5.3、statsmodels 0.15.0,Windows 11)。脚本会自动下载上文的币安数据并输出本文所有真实数据表格。

import polars as pl

NS = 1_000_000_000

def estimate_spreads(trades: pl.DataFrame, quotes: pl.DataFrame, delta_s: float = 300) -> pl.DataFrame:
    """
    trades: ts(int64 ns), price(float64), qty(float64)
    quotes: ts(int64 ns), bid(float64), ask(float64)
    返回每笔成交的 quoted / effective / realized spread 与 price impact。
    """
    quotes = quotes.sort("ts").with_columns(
        (pl.col("ask") - pl.col("bid")).alias("S_q"),
        ((pl.col("ask") + pl.col("bid")) * 0.5).alias("mid"),
    )
    # 只取严格早于成交时刻的报价:同一毫秒的报价可能已经包含这笔成交
    aligned = trades.sort("ts").join_asof(
        quotes.select("ts", "S_q", "mid"), on="ts", strategy="backward", allow_exact_matches=False
    )
    aligned = aligned.with_columns(
        pl.when(pl.col("price") > pl.col("mid")).then(1)
        .when(pl.col("price") < pl.col("mid")).then(-1)
        .otherwise(0).alias("dir_lr"),
        (pl.col("ts") + int(delta_s * NS)).alias("ts_target"),
    )
    fwd = quotes.select(pl.col("ts").alias("ts_fwd"), pl.col("mid").alias("mid_fwd"))
    aligned = aligned.sort("ts_target").join_asof(
        fwd, left_on="ts_target", right_on="ts_fwd", strategy="backward"
    ).sort("ts")
    d = pl.col("dir_lr")
    return aligned.with_columns(
        pl.col("S_q").alias("quoted_spread"),
        (2 * d * (pl.col("price") - pl.col("mid"))).alias("effective_spread"),
        (2 * d * (pl.col("price") - pl.col("mid_fwd"))).alias("realized_spread"),
        (2 * d * (pl.col("mid_fwd") - pl.col("mid"))).alias("price_impact"),
    )

dir_lr 是 Lee-Ready 报价规则的方向;有交易所主动方标志时(如币安 is_buyer_maker),应当用标志替换它,2.5 节的表就是这样算的。两个对齐细节决定结果是否可信:

allow_exact_matches  mean(eff/quoted)  share_eff>quoted  share_eff<0
              False             1.396            0.1246       0.0083
               True             0.961            0.0573       0.0958

Roll 估计量只需要成交价序列:

import numpy as np

def roll_spread(prices: np.ndarray) -> float:
    dp = np.diff(prices)
    cov = np.cov(dp[:-1], dp[1:], ddof=0)[0, 1]
    return float("nan") if cov >= 0 else 2.0 * np.sqrt(-cov)

三、流动性的四个维度

把流动性折成一个数字一直不可靠。Kyle(Econometrica, 53(6), 1985)把市场流动性描述为紧度(tightness)、深度(depth)、弹性(resiliency)三个方面;Harris 在 Trading and Exchanges(Oxford University Press, 2003)中用即时性(immediacy)、宽度(width)、深度、弹性四个维度。合起来:

维度 中文 衡量什么 典型指标
Tightness / Width 紧度 一笔小单的成交成本 报价价差、有效价差
Depth 深度 不动价位能成交的量;单位流量推动价格多少 最优档量、累计 5 档量、Kyle λ、Amihud
Immediacy 即时性 能否立即成交 是否连续撮合、市价单成交时间
Resiliency 弹性 受冲击后恢复的速度 冲击半衰期、盘口补单速率

四个维度可以独立变化。大 tick 品种上紧度几乎是常数(LTCUSDT 样本 97.7% 的更新里价差是 1 tick),深度却在变:同一天里按 30 分钟窗口平均的最优档深度在 111 到 174 LTC 之间(第四节图中 (c) 的横轴)。只盯价差的成本模型看不到这类变化。

3.1 Kyle λ:把深度量化成一个斜率

Kyle(1985)的单期模型里,知情交易者与噪声交易者同时提交市价单,做市商只看到净订单流 \(x + u\),按线性规则定价:

\[ p = \mu + \lambda (x + u), \qquad \lambda = \frac{1}{2}\frac{\sqrt{\Sigma_0}}{\sigma_u} \]

\(\Sigma_0\) 是有效价值的先验方差,\(\sigma_u\) 是噪声交易量的标准差。λ 越大,同样的净订单流推动价格越多,市场越「浅」;噪声交易越多,知情交易者越容易隐藏,λ 越小。模型里这一冲击是永久的:价格因为订单流携带的信息而改变(第五节)。

实证上常用桶化回归 \(\Delta p_t = \lambda Q_t + \varepsilon_t\) 估计,\(Q_t\) 是窗口内带符号成交量:

import polars as pl
import statsmodels.api as sm

def estimate_kyle_lambda(trades: pl.DataFrame, quotes: pl.DataFrame, bucket_ms: int = 1000) -> dict:
    """
    trades: ts, qty, side(+1 买方主动,-1 卖方主动)
    quotes: ts, bid, ask
    每个桶的中价变化对净成交量做 OLS,标准误用 HAC(Newey-West)。
    """
    w = bucket_ms * 1_000_000
    q = quotes.with_columns(
        ((pl.col("ask") + pl.col("bid")) * 0.5).alias("mid"),
        (pl.col("ts") // w * w).alias("bucket"),
    )
    mid = q.group_by("bucket").agg(pl.col("mid").sort_by("ts").last())
    flow = (
        trades.with_columns((pl.col("ts") // w * w).alias("bucket"), (pl.col("side") * pl.col("qty")).alias("signed_qty"))
        .group_by("bucket")
        .agg(pl.col("signed_qty").sum())
    )
    df = (
        mid.join(flow, on="bucket", how="left")
        .sort("bucket")
        .with_columns(pl.col("signed_qty").fill_null(0.0), pl.col("mid").diff().alias("d_mid"))
        .drop_nulls()
    )
    X = sm.add_constant(df["signed_qty"].to_numpy())
    res = sm.OLS(df["d_mid"].to_numpy(), X).fit(cov_type="HAC", cov_kwds={"maxlags": 5})
    return {"lambda": float(res.params[1]), "t_hac": float(res.tvalues[1]), "r2": float(res.rsquared), "n": int(res.nobs)}

在 LTCUSDT 样本上用 1 秒桶的实际输出:

Kyle lambda (1 s): 2.800e-05 USDT per LTC, HAC t=17.1, R2=0.473, n=54209

即 1 秒内净买入 1,000 LTC,中价平均上移约 0.028 USDT(2.8 个 tick)。几点口径:

3.2 Amihud 非流动性指标

低频研究里更常用 Amihud(Journal of Financial Markets, 5(1), 2002)的指标:

\[ \text{ILLIQ}_i = \frac{1}{N_i} \sum_{d=1}^{N_i} \frac{|r_{i,d}|}{\text{DVOL}_{i,d}} \]

\(r_{i,d}\) 是日收益率,\(\text{DVOL}_{i,d}\) 是日成交额。它和 Kyle λ 同源,都是「单位成交额对应的价格变动」,但只用日级数据就能算。Goyenko、Holden 与 Trzcinka(Journal of Financial Economics, 92(2), 2009)以 TAQ 高频数据计算的价格冲击为基准,比较多种低频流动性代理,Amihud 类指标是对价格冲击跟踪较好的一类。横截面因子与风险模型里用的「流动性」多半就是它。

3.3 弹性:冲击半衰期

弹性最常用的测量是冲击半衰期:一笔大额主动单之后,中价从冲击峰值回落一半所需的时间。做法:

  1. 筛出足够大的事件(例如成交量超过近期均值数倍的主动单簇)。
  2. 以事件时刻对齐(event time),取前后若干秒的中价序列。
  3. 对所有事件求 \(m(\tau) - m(0)\) 的均值曲线,拟合 \(m(\tau) = a + b e^{-\tau/T}\),半衰期 \(T_{1/2} = T \ln 2\)。

半衰期因品种、时段差异很大,没有可直接套用的数值,必须按自己的标的估计。Obizhaeva 与 Wang(Journal of Financial Markets, 16(1), 2013)的最优执行模型把盘口以指数速率补单写成参数,弹性在那里直接决定拆单节奏,这部分应用见第 18 篇。

3.4 即时性

在连续撮合、深度尚可的市场里,「愿意付报价价差就能马上成交」,即时性常常并入紧度讨论。以下场景里它是独立维度:

在压力情景下回测的策略需要显式建模「来不及成交」,否则会低估尾部风险。


四、订单流不平衡(OFI)

Cont、Kukanov 与 Stoikov 在 The Price Impact of Order Book Events(Journal of Financial Econometrics, 12(1), 2014)中定义了订单流不平衡(Order Flow Imbalance,OFI)。他们的核心发现是:在短时间窗口内,价格变化主要由最优档上的供需不平衡驱动,而不是由成交量驱动。限价单、市价单、撤单都会改变最优档的可见量,OFI 把三类事件放进同一个有符号的量里。

4.1 定义

对第 \(n\) 次最优报价更新,记 \(P^b_n, q^b_n\) 为最优买价和量,\(P^a_n, q^a_n\) 为最优卖价和量:

\[ e_n = \mathbb{1}_{\{P^b_n \ge P^b_{n-1}\}} q^b_n - \mathbb{1}_{\{P^b_n \le P^b_{n-1}\}} q^b_{n-1} - \mathbb{1}_{\{P^a_n \le P^a_{n-1}\}} q^a_n + \mathbb{1}_{\{P^a_n \ge P^a_{n-1}\}} q^a_{n-1} \]

窗口内 \(\text{OFI}_k = \sum_{n \in k} e_n\)。拆开看:

4.2 与中价变化的线性关系

CKS 在 50 只美股的 NYSE TAQ 数据上回归

\[ \Delta m_k = \alpha + \beta\, \text{OFI}_k + \varepsilon_k \]

发现关系近似线性、斜率与最优档平均深度成反比(\(\beta \approx c / \text{depth}\)),且结论在不同时间尺度和股票之间稳定;论文报告 10 秒窗口下的平均 \(R^2\) 约 65%。他们同时发现,价格变化对成交量的回归噪声大得多;并论证线性 OFI 冲击加上一个尺度论证,可以推出经验上的「平方根」型价格变化与成交量关系。

在 LTCUSDT 样本上复现同一回归(\(\Delta m\) 以 tick 计,OFI 以 LTC 计,同时与带符号成交量做对照):

桶长 桶数 OFI 斜率(tick/LTC) \(R^2\)(OFI) \(R^2\)(带符号成交量)
1 s 54,210 0.00234 0.600 0.473
5 s 10,845 0.00230 0.638 0.395
10 s 5,424 0.00225 0.614 0.373
30 s 1,811 0.00218 0.540 0.424
60 s 907 0.00203 0.486 0.476

按 30 分钟窗口分别估计 10 秒桶的斜率,再对窗口平均深度做双对数回归:31 个窗口,指数为 \(-0.741\),\(R^2 = 0.367\)。

币安 LTCUSDT 永续真实数据上 OFI 与中价变化:10 秒桶散点与回归、不同桶长下 OFI 与带符号成交量的解释力对比、30 分钟窗口斜率与深度的双对数关系

图 (a) 回答「OFI 与中价变化是不是线性」:中价变化是离散的半 tick 台阶,但条件均值沿回归线排列。图 (b) 回答「OFI 比成交量多解释多少」:1 到 10 秒尺度 OFI 的 \(R^2\) 比带符号成交量高 0.13 到 0.24,60 秒时两者接近,在这个样本里 OFI 的优势集中在 10 秒以内。图 (c) 回答「斜率是否随深度下降」:方向与 CKS 一致,但指数 \(-0.74\) 不等于 \(-1\),且只有 31 个点、拟合度一般,这里只能说「定性一致」。

注意这是同期关系,不是预测:同一窗口的 OFI 解释同一窗口的中价变化。把 OFI 当成下一窗口的预测信号,解释力会低很多,需要单独检验。

4.3 代码:用 polars 计算 OFI

与脚本 ofi_spread_analysis.py 中的实现一致,输入是每次最优报价更新后的一行(币安 bookTicker 正好是这个格式):

import polars as pl

def compute_ofi(book_events: pl.DataFrame, bucket_ns: int = 1_000_000_000) -> pl.DataFrame:
    """
    book_events: 每次最优报价更新一行,列 ts, bid, bid_qty, ask, ask_qty。
    返回每个桶的 OFI、中价变化、平均最优档深度和事件数。
    """
    df = book_events.sort("ts").with_columns(
        pl.col("bid").shift(1).alias("bid_p"),
        pl.col("ask").shift(1).alias("ask_p"),
        pl.col("bid_qty").shift(1).alias("bid_q_p"),
        pl.col("ask_qty").shift(1).alias("ask_q_p"),
        ((pl.col("bid") + pl.col("ask")) * 0.5).diff().alias("d_mid"),
    ).drop_nulls()

    df = df.with_columns(
        (
            (pl.col("bid") > pl.col("bid_p")).cast(pl.Float64) * pl.col("bid_qty")
            - (pl.col("bid") < pl.col("bid_p")).cast(pl.Float64) * pl.col("bid_q_p")
            + (pl.col("bid") == pl.col("bid_p")).cast(pl.Float64) * (pl.col("bid_qty") - pl.col("bid_q_p"))
        ).alias("e_bid"),
        (
            -(pl.col("ask") < pl.col("ask_p")).cast(pl.Float64) * pl.col("ask_qty")
            + (pl.col("ask") > pl.col("ask_p")).cast(pl.Float64) * pl.col("ask_q_p")
            - (pl.col("ask") == pl.col("ask_p")).cast(pl.Float64) * (pl.col("ask_qty") - pl.col("ask_q_p"))
        ).alias("e_ask"),
    ).with_columns((pl.col("e_bid") + pl.col("e_ask")).alias("ofi"))

    return (
        df.with_columns((pl.col("ts") // bucket_ns * bucket_ns).alias("bucket"))
        .group_by("bucket")
        .agg(
            pl.col("ofi").sum(),
            pl.col("d_mid").sum(),
            ((pl.col("bid_qty") + pl.col("ask_qty")) * 0.5).mean().alias("depth"),
            pl.len().alias("n_events"),
        )
        .sort("bucket")
    )

实现上三处最容易出错:

4.4 OFI 的适用边界

4.5 多档 OFI

最优档 OFI 自然可以扩展到前 \(K\) 档。记 \(e^{(k)}_n\) 为第 \(k\) 档按同样规则计算的增量:

\[ \text{OFI}^{(k)}_t = \sum_{n \in t} e^{(k)}_n, \qquad \text{OFI}^{\text{agg}}_t = \sum_{k=1}^{K} w_k\, \text{OFI}^{(k)}_t \]

两项代表工作:Xu、Gould 与 Howison 的 Multi-Level Order-Flow Imbalance in a Limit Order Book(Market Microstructure and Liquidity, 4(3–4), 2018)在 6 只 Nasdaq 流动性股票上发现,每多纳入一档,同期中价变化回归的样本外拟合度都会提高;Cont、Cucuringu 与 Zhang 的 Cross-Impact of Order Flow Imbalance in Equity Markets(Quantitative Finance, 23(10), 2023)用主成分把前几档 OFI 合成一个 integrated OFI,同期回报回归的调整 \(R^2\) 从最优档 OFI 的约 71% 提升到约 87%,并发现纳入多档信息后,同期的跨资产 OFI 不再提供额外解释力,但滞后的跨资产 OFI 能改善短期收益预测。

工程上,深档的更新延迟和聚合口径在不同数据源之间不一致,档数越多对数据质量要求越高。一个常见的折中是只用前 2 到 3 档、权重随档位递减(例如 1.0、0.5、0.25),这是工程选择,权重应在自己的数据上用样本外拟合度来定。


五、冲击:永久与临时的微结构来源

第二节的价格冲击是单笔、固定 \(\Delta\) 的口径;做执行和容量估计时关心的是一笔大单(metaorder)拆成许多子单执行后的总冲击。两者共用一套概念:永久冲击是留在价格里的部分,临时冲击是执行结束后会回吐的部分。本节只讲这两个概念在订单簿里的来源;冲击模型的函数形式与标定(平方根律、Almgren-Chriss、propagator 模型)、最优执行轨迹、Implementation Shortfall 与 TCA 报表,见《交易成本模型:冲击成本、滑点、Implementation Shortfall 与 TCA》第三到五节。

5.1 临时冲击:盘口消耗与弹性

一笔主动单吃掉最优档后,价格机械地移到下一个有挂单的价位;如果吃穿多档,成交均价沿 L2 的累计深度曲线上升。这部分由订单簿形状决定,与信息无关。之后做市商和其他挂单者补单,盘口逐步恢复,中价部分回落,恢复速度就是第三节的弹性。

5.2 永久冲击:订单流携带的信息

做市商无法区分对手方是否知情,只能从订单流里推断。买单多,说明有效价值更可能偏高,报价随之上移,并且不会因为执行结束而回落:

5.3 概念谱系

文献 冲击的含义 微结构机制 本系列哪里展开
Kyle(1985) 永久、对净订单流线性 知情交易者隐藏在噪声交易里 本文 3.1 节
Glosten-Milgrom(1985) 逐笔信念更新,价差即逆向选择成本 做市商贝叶斯定价 本文 6.1 节;第 25 篇
Glosten-Harris(1988)、Huang-Stoll(1997) 价差拆成暂时、库存、逆向选择 逐笔价格回归 本文 2.6 节
Hasbrouck(1991) 非预期订单流的长期累积响应 VAR 识别 第 18 篇
Almgren-Chriss(2000) 永久(对累计量)与临时(对交易速率)作为模型输入 不建模盘口,只给函数形式 第 18 篇、第 23 篇
Bouchaud et al.(2004) 冲击按幂律传播核衰减 订单流长记忆与价格近随机游走并存 第 18 篇
Tóth et al.(2011) metaorder 总冲击近似 \(\propto \sqrt{Q/V}\) 潜在流动性(latent liquidity)在价格附近线性增长 第 18 篇
Obizhaeva-Wang(2013) 临时冲击随盘口补单指数衰减 订单簿弹性 第 18 篇
Cont-Kukanov-Stoikov(2014) 价格变化 \(\propto\) OFI / 深度 最优档消耗 本文第四节

这张表的主线是:早期模型(Kyle、Glosten-Milgrom)从信息解释冲击,因此冲击是永久的;订单簿实证(Bouchaud 一支、CKS)从盘口消耗与补单解释,因此冲击有相当一部分会衰减;平方根律处在两者的交汇处,它描述的是大量子单累积之后的总冲击。哪种解释在什么尺度上占主导,取决于品种、时段和执行速度,这也是为什么冲击参数必须在自己的成交回报上标定(第 18 篇第三节给出拟合方法)。


六、价格发现:信息如何进入价格

6.1 Glosten-Milgrom 模型

Glosten 与 Milgrom(Journal of Financial Economics, 14(1), 1985)给出最早的逐笔信息不对称定价:

\[ a_t = \mathbb{E}[V \mid \text{buy at } t, \mathcal{F}_{t-1}], \qquad b_t = \mathbb{E}[V \mid \text{sell at } t, \mathcal{F}_{t-1}] \]

每一笔成交都把做市商的信念向成交方向更新一格,即使没有任何订单处理或库存成本,价差也严格为正:价差是信息成本。做市视角下的完整推导与 Avellaneda-Stoikov 模型,见《做市策略:Avellaneda-Stoikov 与库存管理》第二、三节。

6.2 PIN

Easley、Kiefer、O’Hara 与 Paperman(Journal of Finance, 51(4), 1996)把这一思想做成可估计的模型:每天以概率 \(\alpha\) 发生信息事件(坏消息的条件概率为 \(\delta\)),知情交易者以强度 \(\mu\) 到达,噪声买卖单各以强度 \(\varepsilon\) 到达,日买单数和卖单数服从泊松混合分布。知情交易概率为:

\[ \text{PIN} = \frac{\alpha \mu}{\alpha \mu + 2\varepsilon} \]

PIN 按日产出一个数字,适合横截面研究。工程上的两个问题:似然函数包含大量阶乘和指数项,直接计算容易数值溢出,需要在对数空间里展开;似然面有多个局部最优,需要多起点优化。另外,模型假设买卖单的到达只来自「知情」和「噪声」两类,做市商的大量双边报价成交、算法拆单的持续单边流都会被吸收到这两类里,解释 PIN 的水平时要谨慎。

6.3 VPIN 与闪崩预测之争

Easley、López de Prado 与 O’Hara(以下简称 ELO)在 Flow Toxicity and Liquidity in a High-Frequency World(Review of Financial Studies, 25(5), 2012)中提出 VPIN(Volume-Synchronized PIN)。它把时间桶换成成交量桶:每累计固定成交量 \(V\) 构成一个桶,用 bulk volume classification(BVC,按桶内价格变化的正态分布函数把成交量分成买卖两部分)得到 \(V^B_i, V^S_i\):

\[ \text{VPIN} = \frac{\sum_{i=1}^{n} |V^S_i - V^B_i|}{n V} \]

ELO 在 The Microstructure of the “Flash Crash”(Journal of Portfolio Management, 37(2), 2011)和 2012 年的 RFS 论文中报告,E-mini S&P 500 期货的 VPIN 在 2010 年 5 月 6 日闪崩之前已升到历史高位,并与未来短期波动在统计上相关,因此可以作为「订单流毒性」的实时预警。

这一结论引发了微结构领域一场有代表性的公开争论:

可以确定的事实是:VPIN 的数值依赖桶大小、BVC 的窗口和分布假设,跨品种比较必须按日均成交量归一;争论没有解决的是「在控制成交量和波动之后,VPIN 是否还有增量信息」。工程上的稳妥用法是把它当作与成交量、波动并列的监控量,而不是单独的自动减仓触发器。做市场景中按 VPIN 拉宽价差或撤单的实现,见第 25 篇第五节。

6.4 多市场的价格发现:Hasbrouck 信息份额

同一资产在多个市场交易时(交叉上市、ADR、同一币对在多个交易所),价格发现发生在哪里是一个可度量的问题。Hasbrouck(Journal of Finance, 50(4), 1995)的信息份额(Information Share,IS):

  1. 多个市场的价格协整,共享一个随机游走的有效价格;估计向量误差修正模型(VECM)。
  2. 有效价格新息的方差中,各市场价格新息贡献的比例即信息份额。
  3. 各市场新息相关时,Cholesky 分解的排序不同会给出不同结果,IS 实际给出的是上下界。

Gonzalo 与 Granger(Journal of Business & Economic Statistics, 13(1), 1995)的成分份额(component share)用误差修正系数做另一种分解。两者回答的问题不完全相同,实证上常常并列报告。


七、订单簿动力学:Hawkes 过程与临界性

把订单簿事件(成交、挂单、撤单、中价变化)看作时间点过程,自激的 Hawkes 过程(Hawkes, Biometrika, 58(1), 1971)是最常用的基线模型。

7.1 Hawkes 过程基础

指数核的一维 Hawkes 过程强度为:

\[ \lambda(t) = \mu + \sum_{t_i < t} \alpha e^{-\beta (t - t_i)} \]

\(\mu\) 是外生基线强度,\(\alpha\) 是每个事件带来的强度跳升,\(\beta\) 是衰减速率。核函数的积分

\[ n = \int_0^\infty \alpha e^{-\beta s}\, ds = \frac{\alpha}{\beta} \]

称为分支比(branching ratio):一个事件平均直接触发的后续事件数。\(n < 1\) 时过程平稳,长期平均强度为 \(\mu / (1 - n)\);\(n\) 也可以解读为事件中「由内生反馈产生」的比例。多维 Hawkes 用矩阵 \(\mathbf{A} \in \mathbb{R}^{K \times K}\) 描述 \(j\) 类事件对 \(i\) 类事件的激发。Bacry、Mastromatteo 与 Muzy 的 Hawkes Processes in Finance(Market Microstructure and Liquidity, 1(1), 2015)是金融应用的综述。

7.2 分支比接近 1 意味着什么

Hawkes 在微结构里最有争议的用法是把分支比当作「市场内生程度」的度量:

把 Filimonov-Sornette 2015 的核心论点在模拟数据上复现一遍(脚本 hawkes_fit_demo.py,全部是模拟数据):前三行是真分支比 0.8 的指数核 Hawkes(\(\mu = 0.5\)、\(\alpha = 0.8\)、\(\beta = 1.0\)),后三行是没有任何自激、强度每 50 秒在 0.5 和 5.0 之间切换的泊松过程,观测窗口都是 5,000 秒。实际运行输出:

case                          n_events  mu_hat  alpha_hat  beta_hat  n_hat
hawkes n=0.8 seed=1               12732   0.518      0.785     0.985  0.797
hawkes n=0.8 seed=2               12475   0.474      0.776     0.958  0.810
hawkes n=0.8 seed=3               11957   0.477      0.741     0.925  0.801
switching Poisson seed=1          13760   0.247      0.455     0.500  0.911
switching Poisson seed=2          13884   0.241      0.472     0.516  0.914
switching Poisson seed=3          13705   0.245      0.473     0.519  0.911

模型设定正确时估计量是准的;强度的非平稳切换却被 Hawkes 解读成了 0.91 的「自激」。把滚动分支比当作风险指标之前,至少要做两件事:对日内季节性和制度切换去趋势或分段估计;用残差的时间变换检验(compensator 变换后应接近单位速率泊松过程)检查模型设定。

7.3 代码:一维 Hawkes 的极大似然估计

import numpy as np
from scipy.optimize import minimize

def hawkes_ll(params, ts, T):
    mu, alpha, beta = params
    if mu <= 0 or alpha < 0 or beta <= 0 or alpha >= beta:
        return 1e10
    n = len(ts)
    A = np.zeros(n)
    for i in range(1, n):
        A[i] = np.exp(-beta * (ts[i] - ts[i - 1])) * (1 + A[i - 1])
    log_lambda = np.log(mu + alpha * A)
    integral = mu * T + (alpha / beta) * np.sum(1 - np.exp(-beta * (T - ts)))
    return -(np.sum(log_lambda) - integral)

def fit_hawkes(ts: np.ndarray, T: float):
    """ts: 升序事件时间(秒),T: 观测窗口长度。"""
    x0 = np.array([len(ts) / T * 0.5, 0.5, 1.0])
    res = minimize(hawkes_ll, x0, args=(ts, T), method="Nelder-Mead",
                   options={"maxiter": 4000, "xatol": 1e-6, "fatol": 1e-6})
    mu, alpha, beta = res.x
    return {"mu": mu, "alpha": alpha, "beta": beta,
            "branching_ratio": alpha / beta, "log_lik": -res.fun}

实现要点:

7.4 神经序列模型

近年的一支工作用深度网络直接从订单簿历史预测价格方向。Sirignano 与 Cont 在 Universal Features of Price Formation in Financial Markets: Perspectives from Deep Learning(Quantitative Finance, 19(9), 2019)中用美股数十亿条报价和成交训练 LSTM,发现在所有股票上联合训练的「通用」模型优于逐只股票训练的模型,并且对不在训练集里的股票同样有效;加入更长的价格和订单流历史能提高准确率,说明价格动态有路径依赖。他们的输入是原始订单簿状态序列,而不是 OFI 之类的手工特征。

可以把神经点过程看作「把 Hawkes 的固定参数核换成学出来的核」:能拟合任意衰减形状和跨事件类型的互激,代价是可解释性、数据量和推理延迟。把它接进生产路径要解决的是特征实时计算、推理延迟、模型监控与回滚,见《时间序列深度学习》。无论最后用什么模型,Hawkes 与 OFI 线性回归都应该作为基线保留,否则无法判断神经模型的增益是否来自过拟合。


八、工程实现:从原始行情到可分析数据

前面所有指标都假设本地订单簿是对的。这一节讲怎么让它对。

8.1 L2/L3 数据结构选择

数据类型 常见结构 关键操作复杂度
L1 单档 当前 best 缓存 \(O(1)\)
L2 聚合 有序映射 BTreeMap<Price, Qty>(Rust)或排序数组 \(O(\log K)\),\(K\) 为档位数
L3 完整 价格层有序映射 + 每层双向链表 + HashMap<OrderId, 节点> 插入 \(O(\log K)\)、按 id 撤单 \(O(1)\)
固定 tick L3 数组价格梯 + 每档 FIFO + best 指针 多数操作 \(O(1)\),best 档清空时需向外扫描

量化研究大多用 L1/L2;L3 主要给做市回测和撮合引擎自身用,见《撮合引擎实现》。

8.2 增量更新:snapshot + delta

主流深度行情采用「快照 + 增量」:启动时拉一份带序号的完整快照,之后每条增量带递增序号,序号不连续就重建。币安现货 depth 流的消息结构(字段名与官方文档一致,数值为示意):

{
  "e": "depthUpdate",
  "U": 157,
  "u": 160,
  "b": [["27000.10", "1.5"], ["27000.20", "0"]],
  "a": [["27001.00", "0.8"]]
}

U、u 是该消息覆盖的首末 update id,数量为 0 表示删除该价位。币安官方文档(binance-spot-api-docs, How to manage a local order book correctly)规定的流程是:先缓存增量,再拉快照;丢弃 u 小于等于快照 lastUpdateId 的增量;之后每条增量的 U 若大于本地 update id + 1,说明有丢失,必须丢弃本地簿重新开始。文档同时提醒,快照每侧最多 5,000 档,快照以外的价位在发生变化之前是看不到的。

8.3 对不上的三类常见根因

  1. 增量丢失未察觉:序号跳号但应用没有严格校验。修法:按上节规则校验,并定期与独立快照对账(8.6 节)。
  2. 时钟:用本机接收时间排序跨流事件,会把网络抖动带进事件顺序。修法:用交易所给的事件时间或撮合时间;多源合并时记录两种时间戳。
  3. 重连后未重置:断线重连后继续在旧簿上应用增量。修法:重连必清空内存簿,强制重新快照。

第 2.8 节的 allow_exact_matches 问题属于同一类:时间戳精度不够时,「先后」本身需要定义。

8.4 用 polars 回放 L2 增量

import polars as pl

def apply_l2_deltas(snapshot: pl.DataFrame, deltas: pl.DataFrame) -> pl.DataFrame:
    """
    snapshot: side(str: "B"/"A"), price(float64), qty(float64),起始状态。
    deltas:   ts(int64), side(str), price(float64), qty(float64),qty=0 表示该价位清空。
    返回每条增量之后的 (ts, bid, bid_qty, ask, ask_qty)。
    """
    book_bid, book_ask = {}, {}
    for row in snapshot.iter_rows(named=True):
        target = book_bid if row["side"] == "B" else book_ask
        if row["qty"] > 0:
            target[row["price"]] = row["qty"]

    out = []
    for row in deltas.sort("ts").iter_rows(named=True):
        target = book_bid if row["side"] == "B" else book_ask
        if row["qty"] == 0:
            target.pop(row["price"], None)
        else:
            target[row["price"]] = row["qty"]
        if not book_bid or not book_ask:
            continue
        bb, ba = max(book_bid), min(book_ask)
        out.append((row["ts"], bb, book_bid[bb], ba, book_ask[ba]))

    return pl.DataFrame(out, schema=["ts", "bid", "bid_qty", "ask", "ask_qty"], orient="row")

这个版本每条增量都在 Python 字典上操作,还每次用 max/min 扫描全部档位,只适合研究和小样本回放。价格能离散成整数 tick 下标时(A 股、港股、期货等固定 tick 市场;加密货币要先选定最小价格单位),可以用数组价格梯加 numba 编译热循环:

import numpy as np
from numba import njit

@njit(cache=True)
def apply_deltas_dense(prices_grid, qty_bid, qty_ask, ts, sides, idxs, qtys):
    """
    prices_grid: 等距价格栅格;qty_bid / qty_ask: 与栅格同长的当前挂单量(可含初始快照)
    sides: 0=买, 1=卖;idxs: 每条增量在栅格中的下标;qtys: 新数量,0 表示清空
    不处理交叉簿(bid >= ask),交叉时应触发重建。
    """
    n = len(ts)
    m = len(prices_grid)
    out_bid = np.empty(n); out_ask = np.empty(n)
    out_bq = np.empty(n); out_aq = np.empty(n)

    bb, ba = -1, m
    for i in range(m):
        if qty_bid[i] > 0:
            bb = i
    for i in range(m - 1, -1, -1):
        if qty_ask[i] > 0:
            ba = i

    for k in range(n):
        i = idxs[k]
        if sides[k] == 0:
            qty_bid[i] = qtys[k]
            if qtys[k] > 0 and i > bb:
                bb = i
            elif i == bb and qtys[k] == 0:
                while bb >= 0 and qty_bid[bb] == 0:
                    bb -= 1
        else:
            qty_ask[i] = qtys[k]
            if qtys[k] > 0 and i < ba:
                ba = i
            elif i == ba and qtys[k] == 0:
                while ba < m and qty_ask[ba] == 0:
                    ba += 1
        out_bid[k] = prices_grid[bb] if bb >= 0 else np.nan
        out_ask[k] = prices_grid[ba] if ba < m else np.nan
        out_bq[k] = qty_bid[bb] if bb >= 0 else 0.0
        out_aq[k] = qty_ask[ba] if ba < m else 0.0
    return out_bid, out_ask, out_bq, out_aq

开头两段循环从初始快照恢复 best 指针;如果省略,预先填入的快照档位不会被识别为最优档。两个版本在同一个 4 条增量的小例子上输出一致(numba 0.68.0)。加速倍数取决于 CPU、档位数和增量分布,需要在自己的数据上测。

8.5 数据治理常见坑

8.6 本地簿与独立快照对账

import polars as pl

def reconcile_book(local_book: dict, snapshot: pl.DataFrame, tol: float = 1e-9) -> dict:
    """
    local_book: {"bid": {price: qty}, "ask": {price: qty}},当前内存簿
    snapshot:   side(str: "B"/"A"), price, qty 的独立快照
    返回各类不一致的计数与样例,可接入告警。
    """
    diffs = {"missing_in_local": [], "missing_in_snap": [], "qty_mismatch": []}
    for side in ("bid", "ask"):
        snap_side = {row["price"]: row["qty"] for row in
                     snapshot.filter(pl.col("side") == side[0].upper()).iter_rows(named=True)}
        local_side = local_book[side]
        for p, q in snap_side.items():
            if p not in local_side:
                diffs["missing_in_local"].append((side, p, q))
            elif abs(local_side[p] - q) > tol:
                diffs["qty_mismatch"].append((side, p, local_side[p], q))
        for p in local_side:
            if p not in snap_side:
                diffs["missing_in_snap"].append((side, p, local_side[p]))
    return {
        "ok": all(len(v) == 0 for v in diffs.values()),
        "n_missing_local": len(diffs["missing_in_local"]),
        "n_missing_snap": len(diffs["missing_in_snap"]),
        "n_mismatch": len(diffs["qty_mismatch"]),
        "examples": {k: v[:5] for k, v in diffs.items()},
    }

对账时要注意快照只覆盖有限档位(币安每侧最多 5,000 档),本地簿里快照范围以外的价位应排除在比较之外,否则会被误报为 missing_in_snap。对账失败应触发本地簿重建,对账结果按时间落盘,长期观察失败率,作为行情链路的数据质量指标。

8.7 A 股的特殊处理

8.8 分层架构

层级 语言 职责
行情接入与本地簿 C++ / Rust 低延迟、确定性内存布局、零拷贝解析
特征计算与回放 Rust / numba + Python 向量化、跨核并行
研究与可视化 Python(polars / pandas) 快速迭代

在 Python 里直接订阅 WebSocket 并维护本地簿,单进程处理能力有限,在高流量品种上容易处理不过来、积压或丢消息,而盘口错误比模型偏差更难排查。较稳妥的做法是把「订阅、本地簿、序号校验」下沉到编译型语言,再把整理好的事件流交给 Python。低延迟系统的完整设计见《高频交易架构》。

8.9 微结构特征清单

特征 时间尺度 含义 适合的用途 本文位置
报价价差 \(S^q\) 实时 小单成交成本 所有策略的成本基线 2.1
有效价差 \(S^e\) 逐笔 实际付出成本 执行评估、TCA 2.2
已实现价差 \(S^r\) 50 ms 到 5 min 做市留存收益 做市 PnL 拆解 2.3、2.5
加权中价 \(p_w\) 实时 短期价格基准 做市报价、被动执行 1.5
最优档 OFI 1 到 10 s 同期价格变化的主要驱动 特征工程、冲击诊断 4.2
多档 OFI 1 s 以上 深档供需变化 中高频特征 4.5
Kyle λ 1 s 到分钟 即时深度 容量评估 3.1
Amihud ILLIQ 日级 横截面流动性 因子与风险模型 3.2
冲击半衰期 事件级 弹性 大额执行节奏 3.3
VPIN 成交量桶 订单流失衡(毒性之争见 6.3) 风险监控辅助 6.3
Hawkes 分支比 滚动窗口 事件聚集程度(伪临界见 7.2) 异常检测辅助 7.2
队列位置 实时 队列中的相对位置 做市、被动执行 1.3

一个低成本的监控做法:每天开盘后检查各特征相对自身历史分布的偏离和缺失情况。特征分布漂移往往比策略 PnL 更早暴露数据问题。

8.10 回测中的成交假设

高频与被动策略的回测,结果很大程度上取决于「我的限价单怎么成交」:

  1. 乐观成交:挂在最优价的单子,只要有反向成交到达就算成交。忽略排队,系统性高估成交率,并且漏掉第 1.3 节的逆向选择:真实情况下最容易成交的恰好是价格即将不利移动的时刻。
  2. 队列模拟:维护「我前面还有多少量」,反向成交吃完前面的才轮到我。需要 L3,或在 L2 上假设新挂单排在该价位已有量之后、撤单按比例从队列各处发生。
  3. 概率成交:按历史上「挂单后 N 秒内成交」的条件概率成交,介于两者之间。

市价单按报价价差加冲击估计成本,冲击参数怎么来见第 18 篇;回测引擎里如何嵌入这些假设见《回测引擎设计》;把乐观成交假设当真属于《回测陷阱》讨论的「回测结果不可信」的一类原因。


九、结论与边界

  1. 价差要先说口径再说数值。报价、有效、已实现、冲击四个量满足 \(S^e = S^r + \text{Impact}\),但后两者依赖 \(\Delta\)。LTCUSDT 样本里有效价差 1.58 bp,0.1 秒冲击 1.04 bp,5 分钟冲击 1.26 bp;短 \(\Delta\) 的冲击混有机械性的盘口消耗。
  2. 报价对齐是最容易被忽视的偏差源。毫秒时间戳下允许同一毫秒匹配,会把有效价差为负的成交比例从 0.8% 抬到 9.6%。
  3. 最优档 OFI 比成交量更能解释秒级中价变化。样本内 1 到 10 秒 \(R^2\) 0.60 到 0.64,带符号成交量 0.37 到 0.47;60 秒时两者接近。这是同期关系,不能直接当作预测信号。
  4. 永久冲击来自信息,临时冲击来自盘口消耗与补单。两者的函数形式与标定在第 18 篇,本文只负责解释它们在订单簿里从哪来。
  5. 毒性和临界性指标都有未决争论。VPIN 的预警能力在控制成交量与波动后存疑;Hawkes 分支比接近 1 既可能是结构性质,也可能是非平稳造成的伪临界(模拟中无自激过程估出 0.91)。

本文的实证只覆盖一个加密永续合约的一天,所有数值用于说明口径和量级关系,不代表其他品种、市场或时段。


十、开放问题

  1. 订单流毒性有没有稳健的增量预警信号? VPIN 之争留下的问题是:在控制当期成交量和波动之后,任何订单流失衡指标能否稳定地领先流动性危机。入口:Andersen 与 Bondarenko(Review of Finance, 2015)给出的比较框架。
  2. 市场是否真的处于临界态? 分支比的估计对核函数形式、非平稳性、时间戳精度都敏感(Filimonov 与 Sornette, 2015),而近临界又是连接微观订单流与粗糙波动率的关键假设(Jaisson 与 Rosenbaum, 2015;El Euch 等, 2018)。如何在非平稳、毫秒精度的数据上可靠估计内生程度,还没有公认方法。入口:Bacry、Mastromatteo 与 Muzy(2015)的综述。
  3. 毫秒尺度上如何区分机械冲击与信息冲击? Rule 605 修订后已实现价差要报告到 50 毫秒,但在这个尺度上,单笔吃光最优档造成的中价跳动与信息带来的价格修正在 markout 曲线里混在一起。需要 L3 数据把成交、撤单、补单分开,才能把第五节的两个概念分别测出来。
  4. 多档与跨资产 OFI 能迁移到哪些市场? Xu 等(2018)与 Cont 等(2023)的证据来自美股;加密市场多交易所并行、A 股不能改单且有涨跌停,深档与跨资产信息的价值是否同样成立,需要分市场重做。

十一、风险提示


十二、参考资料

核心论文

  1. Kyle, A. S. (1985). Continuous Auctions and Insider Trading. Econometrica, 53(6), 1315–1335. doi:10.2307/1913210
  2. Glosten, L. R., & Milgrom, P. R. (1985). Bid, Ask and Transaction Prices in a Specialist Market with Heterogeneously Informed Traders. Journal of Financial Economics, 14(1), 71–100. doi:10.1016/0304-405X(85)90044-3
  3. Huang, R. D., & Stoll, H. R. (1997). The Components of the Bid-Ask Spread: A General Approach. Review of Financial Studies, 10(4), 995–1034. doi:10.1093/rfs/10.4.995
  4. Cont, R., Kukanov, A., & Stoikov, S. (2014). The Price Impact of Order Book Events. Journal of Financial Econometrics, 12(1), 47–88. doi:10.1093/jjfinec/nbt003,arXiv:1011.6402
  5. Easley, D., López de Prado, M., & O’Hara, M. (2012). Flow Toxicity and Liquidity in a High-Frequency World. Review of Financial Studies, 25(5), 1457–1493. doi:10.1093/rfs/hhs053
  6. Andersen, T. G., & Bondarenko, O. (2014). VPIN and the Flash Crash. Journal of Financial Markets, 17, 1–46. 工作论文版 CREATES RP 2011-50
  7. Hardiman, S. J., Bercot, N., & Bouchaud, J.-P. (2013). Critical Reflexivity in Financial Markets: A Hawkes Process Analysis. European Physical Journal B, 86(10), 442. doi:10.1140/epjb/e2013-40107-3,arXiv:1302.1405
  8. Filimonov, V., & Sornette, D. (2015). Apparent Criticality and Calibration Issues in the Hawkes Self-Excited Point Process Model: Application to High-Frequency Financial Data. Quantitative Finance, 15(8), 1293–1314. doi:10.1080/14697688.2015.1032544,arXiv:1308.6756

其他论文

书籍

规范与文档

数据与脚本


系列导航

延伸阅读

读完这篇,下一步读什么

优先读同系列或同问题的下一篇,把单篇消费变成主题集群。

2026-05-01 · quant

【量化交易】量化交易全景:从信号到订单的工程链路

量化交易不是策略写得好就能赚钱,更难的是把数据、特征、因子、信号、组合、执行、风控、复盘这八段链路在工程上连成一条不漏数据、不串时间、不丢订单的流水线。本文是【量化交易】系列的总目录与读图,给出八段链路的输入输出、失败模式、不变量清单,并用研究流程图把从一个想法到一笔实盘订单之间所有该过的卡点串起来。

2026-05-01 · quant

【量化交易】风险模型:Barra 多因子、风险归因、压力测试

股票数接近样本天数时,样本协方差会把最小方差组合的风险低估到真实值的约 1-N/T 倍。本文用 Barra 式因子模型降维,对比 Ledoit-Wolf 收缩与 eigenfactor 调整,配合欧拉风险归因、VaR/ES 回测和压力测试,用可运行的模拟代码给出各方法的偏差大小与适用边界。


By .