土法炼钢兴趣小组的算法知识备份

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

文章导航

分类入口
quant
标签入口
#microstructure#order-book#spread#liquidity#market-impact

目录

做策略的人,最常被问的一个问题是:为什么我在回测里赚的钱,到实盘只剩三成?大多数情况下,答案不在 alpha 模型,而在微结构。回测假设你能按中价(mid price)成交,假设你的单子不会推动价格,假设盘口永远有挂单等你吃。真实市场不是这样:你交易任何一个非零数量,都要付价差、要付冲击、要承担信息不对称带来的逆向选择。这些成本加在一起,就是把”账面 alpha”折算成”净 alpha”的乘数。

市场微结构(Market Microstructure)研究的就是这个折算过程。它不是宏观的”市场为什么涨跌”,而是微观的”在 100 毫秒尺度上,价格是怎么从盘口的报价里被发现出来的、谁付了什么成本、谁拿了什么补偿”。这一篇把这套语言系统讲清楚:从限价订单簿(Limit Order Book,LOB)的数据模型出发,把价差拆成四种、把流动性拆成四维,给出可以在生产环境跑的估计代码,并把每一个估计量都连回到一句话:“它告诉你的,是市场的什么状态,又决定了你下单时的什么决策。”

上一篇 《市场结构与交易制度》 讨论的是宏观层面的市场组织:连续竞价 vs 集合竞价、Lit pool vs Dark pool、做市商义务与监管框架。本文把镜头从市场制度推到撮合引擎门口的那张订单簿上,所有讨论都是微秒到秒尺度的。

关于撮合机制本身的工程实现,参见 《撮合引擎实现》。本文重点不是”撮合怎么算”,而是”撮合留下的痕迹该怎么读”。


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

1.1 数据模型:三层抽象

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

层级 内容 典型数据源 适合做什么
L1 最优买价、最优卖价、最优档量 多数行情终端、Level-1 行情 看价、画 K 线、计算 mid
L2 全部价格档的聚合数量(每档累计 qty 与单数) 上交所/深交所 Level-2、Nasdaq TotalView 聚合视图 看深度、计算价差与不平衡
L3 每一笔单独订单(含 order id、到达时间、隐藏标志、修改撤单事件) Nasdaq ITCH、CME MDP 3.0、币安 + bookTicker 排队位置预测、Hawkes 估计、做市策略

L2 和 L3 之间不是分辨率差异,而是信息熵差异。L2 把同一价格上的所有订单聚合成一个数字,丢掉了”我排在第几位”。任何要预测排队位置(Queue Position)的策略,比如做市挂单、被动执行算法,都必须用 L3。反过来,做短期方向预测的统计套利策略,L2 通常足够。

下图展示一个典型 L2 快照。注意中间出现的”空档”:100.04 这一档没有挂单,价差跨过了一个 tick。

限价订单簿 L2 快照与 Best Bid 队列内部

1.2 价格优先、时间优先

几乎所有连续竞价市场使用 价格优先、时间优先(Price-Time Priority) 撮合:

  1. 价格更优的订单先被撮合:买方价高者先,卖方价低者先。
  2. 同价情况下,到达时间早的先被撮合(FIFO 队列)。

中国 A 股沪深两所对连续竞价时段、上海科创板、深圳创业板均采用价格优先、时间优先(参见上交所《交易规则》第 4.1 节、深交所《交易规则》第 4.4 节)。集合竞价阶段则按”所有可成交价中能产生最大成交量的价格”统一撮合,规则上属于价格优先而非时间优先。

时间优先在工程上有一个常被忽略的细节:修改订单的优先级处理。多数交易所的规则是:

这条规则直接决定做市策略的”挂单管理”逻辑:任何想”轻轻动一下报价”的尝试都要付出排队位置的代价,而排队位置的价值在某些品种上能等同于半个 tick 甚至更多。

1.3 队列位置的价值

排在队列前列的好处是确定的:当下一笔反向市价单到达时,你的概率被吃,剩余被市价单留下的”空档”则成为做市商赚取价差的来源。Moallemi 和 Yuan 在 The Value of Queue Position in a Limit Order Book(2017)中给出了一个简洁结论:

\[ V(\text{queue pos}) \approx \frac{S}{2} \cdot P(\text{fill before adverse move}) \]

其中 \(S\) 是价差,\(P(\cdot)\) 是在不利价格变动之前被成交的概率。这个公式说明两件事:

A 股一个典型现象是:高价低 tick 比例的票(如多数大盘股,0.01 元相对于 30 元价格不到 4 个 bp)队列价值很高,做市商抢前排;低价高 tick 比例的票(如部分次新股或低价股)则队列价值低,因为很容易被一个 tick 的跳动洗掉。

1.4 隐藏单与冰山单

L2 看到的”档量”未必是真实供给。两类常见的隐藏供给:

实证上识别冰山单的常用启发式:在很短的时间窗口内(如 50 毫秒),同一价格档反复出现”被吃完→立刻补上”的模式 N 次以上,就高度怀疑是冰山。Stoikov 等人在 Inferring Hidden Liquidity 这类论文里用更严谨的隐马尔可夫模型估计隐藏比例。对做市策略,遇到冰山的代价是显著的:你以为吃完了能让价格上跳一档赚 spread,结果价格被冰山压住几十次。

1.5 微观价(Microprice)

中价 \(m = (a+b)/2\) 是教科书定义,但它有一个明显缺陷:当买盘量远大于卖盘量时,下一笔成交方向更可能是买方主导,即”真实”中价应当偏向 ask。Stoikov 在 The Micro-Price(2018)中提出微观价:

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

其中 \(q_a, q_b\) 是 best ask、best bid 的挂单量。直觉是:哪一侧挂单少、哪一侧就更脆弱、价格更倾向那个方向。\(q_b\) 大说明买的需求强,权重给到 ask。这个简单加权在很多日内品种上能击败中价作为短期价格预测基准——它是后面 OFI、Cont-Stoikov 模型的雏形。


二、买卖价差:四种定义与它们的差别

价差不是一个数字,是四个数字。把它们分清楚是讨论交易成本、市场质量、做市补偿的前提。

报价价差、有效价差、已实现价差与冲击成本分解

2.1 报价价差(Quoted Spread)

最直接的定义:

\[ S^q_t = a_t - b_t \]

或归一化为相对价差 \(S^q_t / m_t\)。报价价差是”你看着的成本”,但不是”你实际付出的成本”。理由有两个:

2.2 有效价差(Effective Spread)

衡量”你实际付出的相对中价的偏离”:

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

\(D_t \in \{+1, -1\}\) 是交易方向指示(+1 为买方主动、-1 为卖方主动)。乘以 2 是为了和报价价差量纲一致:当成交恰好在 best 价位、单笔吃完,\(S^e_t = S^q_t\);当吃穿多档,\(S^e_t > S^q_t\)

实际数据中,方向 \(D_t\) 经常拿不到,需要推断。最常用的是 Lee-Ready 算法(1991):

  1. 若成交价 > 中价,标 +1;若 < 中价,标 -1。
  2. 若恰好等于中价,使用 tick test:与上一笔成交价比较,价升标 +1,价降标 -1。
  3. 仍无法判断的,留作未知。

A 股有逐笔成交方向标识(B/S 标志),相对省事;多数海外品种需要自己跑 Lee-Ready 或 Easley 等更细致的方法。

2.3 已实现价差(Realized Spread)

把”事后中价漂移”剔除掉:

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

\(\Delta\) 通常取 5 分钟(SEC 的 Rule 605 报告口径)或者一段更短的窗口(高频做市常用 30 秒)。已实现价差衡量的是”做市商真正赚到手的那部分价差”——如果你买完之后中价立刻被推上去,你付的钱里就有一部分是被对手方的信息吃掉了。

2.4 价格冲击(Price Impact)

剩下被信息吃掉的那部分,就是永久冲击:

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

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

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

这个等式有具体的经济学意义:你支付的有效价差,一部分流向做市商作为提供流动性的补偿(已实现价差),一部分流向”对面更聪明的人”作为你被逆向选择的代价(冲击)。

2.5 Roll 模型:从成交价倒推价差

Roll(1984)注意到一个有趣的现象:在没有信息冲击的理想市场上,成交价会在 bid、ask 之间随机跳动,相邻成交价的一阶自协方差应当是负的。设真实价值随机游走,方向 \(D_t\) 独立同分布,价差为 \(S\),可以推出:

\[ \text{Cov}(\Delta p_t, \Delta p_{t-1}) = -\frac{S^2}{4} \]

于是 Roll 估计量:

\[ \hat{S} = 2\sqrt{-\widehat{\text{Cov}}(\Delta p_t, \Delta p_{t-1})} \]

Roll 估计量在没有 Level-1 报价、只有成交价的回测数据上很有用——比如老旧的 EOD(End-of-Day)数据、某些另类资产。它的两个失效条件:

2.6 仅有日级数据时的价差估计

很多研究场景拿不到逐笔,只有日级 OHLC。两个常被引用的估计量:

Corwin-Schultz(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(2017) 进一步引入收盘价:

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

其中 \(\eta_t = (H_t + L_t)/2\)。它在低流动性品种上比 Corwin-Schultz 偏差更小。

这些估计在做跨国、跨年代的横截面研究时非常有用——比如做 90 年代港股流动性研究,根本没有 tick data,只能靠 OHLC 反推。日内策略不要用,因为它们的尺度精度只到日级。

2.7 一段可运行的价差估计代码

import polars as pl
import numpy as np

def estimate_spreads(trades: pl.DataFrame, quotes: pl.DataFrame, delta_s: int = 300) -> pl.DataFrame:
    """
    trades: ts(int64 ns), price(float64), qty(float64)
    quotes: ts(int64 ns), bid(float64), ask(float64)
    返回每笔成交对应的 quoted/effective/realized/impact。
    delta_s: 已实现价差与冲击的窗口(秒)。
    """
    quotes = quotes.with_columns([(pl.col("ask") - pl.col("bid")).alias("S_q"),
                                  ((pl.col("ask") + pl.col("bid")) * 0.5).alias("mid")])
    trades = trades.sort("ts")
    quotes = quotes.sort("ts")

    aligned = trades.join_asof(quotes, on="ts", strategy="backward")
    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")
    )
    fwd = quotes.select([pl.col("ts").alias("ts_fwd"), pl.col("mid").alias("mid_fwd")])
    aligned = aligned.with_columns((pl.col("ts") + delta_s * 1_000_000_000).alias("ts_target"))
    aligned = aligned.sort("ts_target").join_asof(
        fwd.sort("ts_fwd"),
        left_on="ts_target", right_on="ts_fwd", strategy="backward",
    )

    return aligned.with_columns([
        pl.col("S_q").alias("quoted_spread"),
        (2 * pl.col("dir_lr") * (pl.col("price") - pl.col("mid"))).alias("effective_spread"),
        (2 * pl.col("dir_lr") * (pl.col("price") - pl.col("mid_fwd"))).alias("realized_spread"),
        (2 * pl.col("dir_lr") * (pl.col("mid_fwd") - pl.col("mid"))).alias("price_impact"),
    ])


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

这段代码两个细节值得注意:


三、流动性的四个维度

把”流动性”折成一个数字一直是不靠谱的事。Kyle(1985)把它拆成三件事,后人补了第四件,于是有了经典四维:

维度 中文 衡量什么 典型指标
Tightness 紧度 一笔小单的成交成本 报价价差、有效价差
Depth 深度 不动价位能成交的最大量 best 档量、累计 5 档量
Immediacy 即时性 立即成交的难度 撮合延迟、市价单成交时间
Resiliency 弹性 受冲击后回复的速度 冲击半衰期、报价回归速率

四个维度可以独立变化,甚至矛盾。一个典型场景:A 股大盘蓝筹在午盘前后,价差紧(紧度好)但盘口薄(深度差);ETF 做市商被动报价时,盘口厚(深度好)但回归慢(弹性差)。任何把流动性折成单一指标的做法,都会在策略上线后被某一个维度的退化打脸。

3.1 Kyle’s lambda:把深度量化成一个斜率

Kyle(1985)在他著名的 informed trader 模型里推出,价格冲击对净订单流呈线性关系:

\[ \Delta p_t = \lambda \cdot Q_t + \varepsilon_t \]

其中 \(Q_t\) 是窗口内净成交量(带方向),\(\lambda\) 是 Kyle’s lambda,单位是”价格变动 / 净成交量”。\(\lambda\) 越小,市场越深;\(\lambda\) 越大,少量净流量就把价格推得很远。

实际估计的常见做法:

  1. 把每秒(或每 5 秒)的净成交量与该窗口内的中价变动配对。
  2. 做 OLS 回归得 \(\hat\lambda\)
  3. 按日聚合,比较不同股票或不同时段的 \(\hat\lambda\)
import polars as pl
import numpy as np
import statsmodels.api as sm

def estimate_kyle_lambda(trades: pl.DataFrame, quotes: pl.DataFrame, bucket_ms: int = 1000):
    """
    用桶化的净成交量与中价变动估计 Kyle's lambda。
    """
    q = quotes.with_columns([((pl.col("ask") + pl.col("bid")) * 0.5).alias("mid"),
                             (pl.col("ts") // (bucket_ms * 1_000_000) * (bucket_ms * 1_000_000)).alias("bucket")])
    mid_per_bucket = q.group_by("bucket").agg(pl.col("mid").last())

    t = trades.with_columns([
        (pl.col("ts") // (bucket_ms * 1_000_000) * (bucket_ms * 1_000_000)).alias("bucket"),
        (pl.col("dir_lr") * pl.col("qty")).alias("signed_qty"),
    ])
    flow = t.group_by("bucket").agg(pl.col("signed_qty").sum())

    df = mid_per_bucket.join(flow, on="bucket", how="left").sort("bucket")
    df = df.with_columns([
        pl.col("signed_qty").fill_null(0),
        pl.col("mid").diff().alias("d_mid"),
    ]).drop_nulls()

    X = sm.add_constant(df["signed_qty"].to_numpy())
    y = df["d_mid"].to_numpy()
    model = sm.OLS(y, X).fit()
    return {"lambda": float(model.params[1]),
            "tstat": float(model.tvalues[1]),
            "r2": float(model.rsquared)}

注意几件事:

3.2 Amihud 流动性指标

低频研究里,更常用的是 Amihud(2002)的指标:

\[ \text{ILLIQ}_d = \frac{1}{N_d} \sum_{t=1}^{N_d} \frac{|r_{t,d}|}{V_{t,d}} \]

其中 \(r\) 是收益率、\(V\) 是成交额。它本质上和 Kyle’s lambda 同源——单位成交额带来的绝对收益率——但用日级数据就能算,不需要逐笔。Amihud 指标在跨股票截面上和 Kyle’s lambda 高度相关,是横截面流动性研究里跑得最远的一个。

3.3 弹性的测量

弹性最常用的测量方式是冲击半衰期(Impact Half-Life):在一笔大额市价单后,中价从冲击峰值回落到一半所需的时间。它把”市场修复速度”折成一个数。

实证上做法:

  1. 筛出”足够大”的事件样本(例如成交量超过当日 10 分钟均值 5 倍)。
  2. 按事件对齐时间(event time),取事件前后各 60 秒的中价序列。
  3. 对每个事件求 \(m(\tau) - m(0)\) 的均值曲线,拟合 \(m(\tau) = a + b \cdot e^{-\tau/T_{1/2}}\)
  4. \(T_{1/2}\) 即半衰期。

不同品种半衰期差异极大:美股大盘 ETF 通常 5 秒以内、A 股大盘股几十秒、低流动性票分钟级、加密永续合约则受费率与杠杆机制影响呈跳跃式回归。

3.4 即时性:从撮合视角的理解

即时性(Immediacy)的传统定义是”立即成交的难度”。在连续撮合、深度尚可的市场上,即时性几乎等于”愿意付报价价差就能马上成交”,所以它常常被并入 tightness 一起讨论。但在以下场景下,即时性是独立的维度:

即时性的下降通常领先紧度与深度的恶化。任何在压力情景下回测的策略,必须显式建模即时性,否则会出现”因为来不及成交而损失越滚越大”的尾部风险。


四、订单流不平衡(OFI):日内最强的短期信号之一

订单流不平衡(Order Flow Imbalance,OFI)由 Cont、Kukanov、Stoikov 在 The Price Impact of Order Book Events(2014)中正式定义。它的核心观察是:短期价格变动并不主要由成交驱动,而由限价订单簿事件驱动。挂单、撤单、市价单——这三类事件以加性方式影响 best 档的可见量,而 best 档量的变化才是价格漂移的真正先导。

4.1 定义

设时间 \(t-1\)\(t\) 之间,best bid 与 best ask 的状态变化为:

\[ e_t = \mathbb{1}_{P^b_t \ge P^b_{t-1}} q^b_t - \mathbb{1}_{P^b_t \le P^b_{t-1}} q^b_{t-1} - \mathbb{1}_{P^a_t \le P^a_{t-1}} q^a_t + \mathbb{1}_{P^a_t \ge P^a_{t-1}} q^a_{t-1} \]

读起来吓人,其实是把所有可能的 best 档变化(价格上移、价格不变量增加、价格下移、价格不变量减少,分买卖两侧)枚举,写成一个有符号增量。把若干个 \(e_t\) 求和就是窗口 OFI。

直观解释:

4.2 与短期回报的关系

Cont-Stoikov 模型给出近似线性关系:

\[ \Delta m_t = \beta \cdot \frac{e_t}{\bar q} + \eta_t \]

\(\bar q\) 是 best 档的平均深度,作为归一化因子。\(\beta\) 在多数 NMS 股票上稳定在 \(0.5 \sim 1\) 个 tick 量级,\(R^2\) 在秒级窗口上 0.6 以上——远高于”成交流量解释中价”的回归。

实际效果:把 OFI 简单地外推到下一个窗口,已经能跑出一个像样的短期方向预测器。它不是 alpha,是一种 latent state,但对 alpha 模型的特征工程价值极高。

4.3 一段可运行的 OFI 计算

import polars as pl

def compute_ofi(book_events: pl.DataFrame) -> pl.DataFrame:
    """
    book_events: 包含每次 best 档变化后的 (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"),
    ]).drop_nulls()

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

    bucket = (pl.col("ts") // 1_000_000_000) * 1_000_000_000
    return df.with_columns(bucket.alias("bucket")).group_by("bucket").agg([
        pl.col("ofi").sum().alias("ofi_1s"),
        pl.col("bid_qty").last(),
        pl.col("ask_qty").last(),
    ]).sort("bucket")

这一段实现里有两点经常出问题:

4.4 OFI 的失效场景

OFI 不是免费午餐,它的失效场景值得记住:

  1. 高频做市商主导的票(如美股大盘指数 ETF):盘口剧烈翻转、绝大多数挂撤瞬时抵消,OFI 信号被噪声淹没。
  2. 集合竞价时段:盘口不持续撮合,OFI 没有定义。
  3. 极薄盘票:单笔大单造成 best 档剧变,OFI 看似巨大但只是单点事件,不构成”流”。

4.5 多层 OFI

原始 OFI 只考虑 best 档。把它扩展到前 K 档自然而然——记 \(e_t^{(k)}\) 为第 \(k\) 档的 OFI 增量,按某种衰减权重组合:

\[ \text{OFI}^K_t = \sum_{k=1}^K w_k \cdot e_t^{(k)}, \quad w_k = e^{-\kappa(k-1)} \]

Xu 和 Cont(2020)的实证显示:使用前 5 档的加权 OFI 在多数美股上对 1 秒回报的解释力比 best-only OFI 提升 30%~50%,且对深度变化更鲁棒。代价是 K 越大对数据质量要求越高——L2 行情中较深档位的更新延迟、聚合方式都不一致,错误处理会引入信号噪声。

实务上一个简化方案:

这个权重已经足够拿到大部分边际信号,且对脏数据的鲁棒性最高。

五、冲击成本:永久冲击与临时冲击的分解

回到最开始那个问题:你的回测在实盘中折算了多少。冲击成本是核心折算项。

5.1 物理直觉

把订单簿想成一个弹簧加阻尼系统。一次大额市价单像突然推一下:

经验观察(Almgren et al., 2005,Citi Equity Research 内部数据)给出近似:

\[ \text{Impact}^{\text{perm}} \propto \sigma \cdot \left(\frac{X}{V}\right)^{\alpha_p}, \quad \alpha_p \approx 1 \]

\[ \text{Impact}^{\text{temp}} \propto \sigma \cdot \left(\frac{X}{V \cdot T}\right)^{\alpha_t}, \quad \alpha_t \approx 0.6 \]

其中 \(X\) 是要交易的总量、\(V\) 是日均成交量、\(T\) 是执行时长、\(\sigma\) 是日波动率。永久冲击近似线性、临时冲击呈次线性凹函数——这是后续 Almgren-Chriss 框架的基础。

5.2 Almgren-Chriss 框架

Almgren 和 Chriss 在 Optimal Execution of Portfolio Transactions(2000)中给出经典最优执行问题:在给定波动率 \(\sigma\)、临时冲击 \(\eta\)、永久冲击 \(\gamma\) 与风险厌恶 \(\lambda\) 下,最小化执行成本与方差的加权和:

\[ \min_{\{x_k\}} \mathbb{E}[\text{cost}] + \lambda \cdot \text{Var}[\text{cost}] \]

得到指数衰减的最优交易轨迹:剩余持仓 \(x_k\)\(\sinh\) 曲线衰减,衰减速率由 \(\kappa = \sqrt{\lambda \sigma^2 / \eta}\) 决定。

定性结论比公式重要:

实际算法落地里,几乎所有”VWAP/TWAP/IS”算法都是 Almgren-Chriss 在不同极限条件下的特化:

5.3 真实校准的工程现实

学术框架很优雅,工程上 calibrate \(\eta, \gamma\) 是另一回事。常见做法:

工业界经常退而求其次,用 J.P. Morgan、Citi、ITG 等卖方提供的冲击模型作为 prior,再在自家流量上微调。


5.4 平方根冲击律

近十年的实证研究(Almgren et al. 2005,Tóth et al. 2011,Bouchaud-Bonart-Donier-Gould《Trades, Quotes and Prices》2018)汇总出一个跨市场普适规律:永久冲击近似与待执行量的平方根成正比

\[ \text{Impact}(Q) \approx Y \cdot \sigma_d \cdot \sqrt{\frac{Q}{V_d}} \]

其中 \(\sigma_d\) 是日波动率、\(V_d\) 是日均成交量、\(Q\) 是 metaorder 总量、\(Y\) 是与品种相关的常数(典型量级 \(0.5\sim1\))。这条规律被称作 平方根冲击律(Square-Root Impact Law),从美股到欧洲指数期货、再到加密 BTC 永续,跨四个数量级的成交量分布上保持得相当稳定。

平方根律和 Almgren-Chriss 的指数函数族冲击假设并不冲突:把平方根律看成大量 metaorder 在不同执行时长下的混合结果,对单一 metaorder 仍然可以用 Almgren-Chriss 优化执行轨迹。但它对策略容量(capacity)估算非常关键:如果你的策略在 1 倍 ADV 下还能赚 5 bp,把规模放到 4 倍 ADV,平方根律预测冲击成本翻倍而不是翻 4 倍——这是 capacity scaling 时唯一不容易出错的近似。

5.5 Bouchaud propagator 模型

平方根律给出的是一笔 metaorder 的总冲击,但策略关心的是每一笔子单(child order)发出后对未来价格的边际影响。Bouchaud 等人提出 propagator 模型:

\[ m_t - m_0 = \sum_{i: t_i \le t} G(t - t_i) \cdot \varepsilon_i \cdot v_i^\delta + \text{noise} \]

\(G(\tau)\) 是冲击传播核(propagator),实证上呈幂律衰减 \(G(\tau) \propto \tau^{-\beta}\)\(\beta \approx 0.4 \sim 0.6\)\(\varepsilon_i\) 是子单方向、\(v_i^\delta\) 是子单大小的次线性变换。

把幂律核与”订单流自身长程相关”两个事实组合起来(订单流的自相关同样是幂律衰减),就能在数学上得到一个”看起来像平方根”的总冲击曲线——这是当前对平方根律最被广泛接受的解释。

六、价格发现:信息是怎么进入价格的

价格发现(Price Discovery)研究的是”在交易序列里,多少信息含量是新的、多少是噪声”。这是把微结构与资产定价连起来的桥。

6.1 信息驱动的成交:Glosten-Milgrom 模型

Glosten 和 Milgrom(1985)给出最早的信息不对称定价:

在贝叶斯均衡下,做市商的最优 bid/ask 是:

\[ b_t = \mathbb{E}[V \mid \text{sell}], \quad a_t = \mathbb{E}[V \mid \text{buy}] \]

每一笔成交都把做市商的信念向”成交方向暗示的真实价值”更新一格。价差是信息成本,不只是订单处理成本。

6.2 PIN 模型

Easley、Kiefer、O’Hara、Paperman(1996)提出 PIN(Probability of Informed-based Trading):

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

其中 \(\alpha\) 是”今天有信息事件”的概率、\(\mu\) 是知情交易者到达率、\(\varepsilon\) 是噪声交易者每方向到达率。PIN 直观上是”任意一笔成交里,对手是知情者的概率”。

PIN 的估计依赖每天的买卖单数(不是金额)服从泊松过程的假设。最大似然估计在样本量大时数值不稳定,开源实现里 pinmodel 包的 EM 算法常被引用。它的好处是”按日”产生一个数字,能用作横截面研究;缺点是对高频做市占主导的现代美股几乎失效——因为做市商占据了大量”看似有方向”的单子,被 PIN 错判为信息流。

6.3 VPIN:成交量 toxicity

Easley、López de Prado、O’Hara(2012)提出 VPIN(Volume-Synchronized PIN)作为高频版本。它把时间桶换成 成交量桶(Volume Bucket):每凑齐一个固定成交量 \(V\),就计算这一桶里的买卖不平衡:

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

VPIN 在 2010 年 5 月 6 日 Flash Crash 之前几小时显著上升,被作为”流动性毒性”的领先指标受到关注。后续学术界对 VPIN 的预测能力有争论(Andersen-Bondarenko 2014 提出了反驳),但作为一个监控信号,VPIN 在很多卖方风险系统里仍然被采用。

要注意 VPIN 的几个工程细节:


6.4 Hasbrouck 信息份额

在多市场交易(cross-listing、ADR 与本土双重上市、不同交易所同股竞争)场景下,“价格发现到底发生在哪个市场”是一个具体问题。Hasbrouck(1995)的 信息份额(Information Share,IS) 给出量化答案:

  1. 把多市场的中价序列作为协整向量,估计 VECM。
  2. 求 VECM 的方差分解,得到永久冲击的方差中,每个市场的贡献占比。
  3. 该占比即该市场的信息份额,所有市场加起来为 1。

经典应用:A 股与港股 H 股之间的价格发现归属(多数研究认为 A 股贡献更大)、美股主交易所与 ATS 池之间的信息份额变化、加密 BTC 在 Binance 与 Coinbase 之间的发现优势随时段变化。

Gonzalo-Granger(1995)的”成分份额”(Component Share)提供另一种分解,与 Hasbrouck IS 在多数情况下定性一致、定量略有差别。两个口径并用通常更稳妥。

七、订单簿动力学:自激与互激

把订单簿事件看成时间标记点过程,自然引入 Hawkes 过程作为建模工具。

7.1 Hawkes 过程基础

强度函数自激衰减:

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

\(\mu\) 是基线强度、\(\alpha\) 是激发强度、\(\beta\) 是衰减速率。直观上:每一次事件触发都临时抬高未来事件强度;间隔越短、影响越大。

订单簿事件的实证特征恰好符合:

多维 Hawkes 过程把这些跨类型互激写成强度矩阵 \(\mathbf{A} \in \mathbb{R}^{K\times K}\):第 \((i,j)\) 项表示 \(j\) 类事件激发 \(i\) 类事件的强度。Bacry-Mastromatteo-Muzy 在 Hawkes processes in finance(2015)里给出了完整综述。

7.2 用途

Hawkes 过程在量化里的常见用途:

7.3 LSTM 与神经序列模型

近五年的微结构论文很大一部分把 Hawkes 替换成 RNN/LSTM/Transformer 来预测下一档变动。Sirignano-Cont 在 Universal features of price formation in financial markets(2019)展示了一个跨股票训练的 LSTM 在 OFI、价差、深度等输入下,对未来 mid 方向的预测准确率显著高于线性基线,并且在跨品种迁移上保留大部分性能——这是”微结构是普适规律”的一个工程证据。

但务实地说:把 LSTM 接到生产路径里,要做的事远多于建模本身——特征实时计算、模型在线推理延迟、撮合反馈回路、模型监控与回滚——任何一环掉链子都比模型本身的边际收益大。所以即使你最后不打算用 LSTM,也至少要把 OFI、价差、深度这些手工特征流水线建好。


7.4 一段最大似然估计 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")
    mu, alpha, beta = res.x
    return {"mu": mu, "alpha": alpha, "beta": beta,
            "branching_ratio": alpha / beta,
            "log_lik": -res.fun}

这里有几个工程上要注意的点:

7.5 神经序列模型与 Hawkes 的关系

把 LSTM/Transformer 看作”把 Hawkes 的固定指数核换成神经网络学到的核”:神经核能拟合任意衰减形状(不仅是单一指数),且能跨事件类型自动学到互激矩阵。代价是可解释性、训练数据量、生产推理延迟都比 Hawkes 高一个台阶。

实际选择经验:

八、工程实现:从 raw feed 到可分析数据

最后一节是最容易被理论文章跳过、却最重要的部分:把上面所有理论挂到生产数据上,需要写哪些代码。

8.1 L2/L3 数据结构选择

数据类型 推荐结构 关键操作复杂度
L1 单档 双 deque + 当前 best 缓存 O(1) 全部操作
L2 聚合 BTreeMap<Price, (qty, count)>(Rust)或排序数组 O(log K) K 为档位数
L3 完整 价格层 = BTreeMap<Price, DLList<Order>> + HashMap<OrderId, ListNode*> O(log K) 插入、O(1) 撤单
固定 tick L3 数组价格梯 + per-level FIFO + best 指针 O(1) 全部操作

Quant 侧大多数研究用 L2 或 L1 就够。L3 主要给做市策略和撮合引擎自身用,详见 《撮合引擎实现》 的”数据结构选型”一节。

8.2 增量更新:snapshot + delta

主流深度行情都采用 “snapshot + delta” 模式:

币安、OKX 等加密交易所的 WebSocket depth stream 是这个模式的典型例子。币安 spot 的 depth@100ms 推送结构(节选):

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

U 是该消息覆盖的首个 update id、u 是末尾 update id。客户端必须保证本地簿的 last update id +1 = 收到消息的 U,否则状态不可信。币安官方文档明确建议丢弃所有 u <= 本地 last update id 的消息,并按 Uu 的连续性维护状态。

8.3 快照对账与时钟同步

行情系统在生产里最难调的不是性能,是 “为什么我和别人看到的不一样”。三类常见根因:

  1. 增量丢包未察觉:seq 跳号但应用没做严格校验。修法:定期 cross-check 你算出来的 best vs 一份独立的 snapshot 源。
  2. 时钟漂移:自家机器与交易所时间偏差导致事件被错排。修法:用交易所给的时间戳,而不是接收时间戳。
  3. 数据源重连未重置状态:reconnect 后继续 apply 旧的 delta。修法:reconnect 必清空 in-memory book、强制重新 snapshot。

8.4 polars 处理 L2 增量的样例

import polars as pl

def apply_l2_deltas(snapshot: pl.DataFrame, deltas: pl.DataFrame) -> pl.DataFrame:
    """
    snapshot: side(str), price(float64), qty(float64) —— 起始状态。
    deltas:   ts(int64), side(str), price(float64), qty(float64)
              qty=0 表示该价格档清空。
    返回每个 ts 后的 (best_bid, bid_qty, best_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 = max(book_bid)
        ba = 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 解释器里的字典操作,单核大约 50 万条/秒。生产环境要么用 Rust/C++ 写底层、Python 仅做分析,要么用 numba/Cython 把热点 loop JIT 掉。下面给一个 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: 与 prices_grid 同长度的当前挂单量
    sides: 0=B, 1=A
    idxs: 每条增量对应价格在 prices_grid 中的下标
    """
    n = len(ts)
    out_bid = np.empty(n, dtype=np.float64)
    out_ask = np.empty(n, dtype=np.float64)
    out_bq  = np.empty(n, dtype=np.float64)
    out_aq  = np.empty(n, dtype=np.float64)

    bb = -1
    ba = len(prices_grid)
    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 < len(prices_grid) 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 < len(prices_grid) else np.nan
        out_bq[k]  = qty_bid[bb] if bb >= 0 else 0.0
        out_aq[k]  = qty_ask[ba] if ba < len(prices_grid) else 0.0
    return out_bid, out_ask, out_bq, out_aq

numba 版在我自己机器上能到 4000 万条/秒(M1 Pro,单核,全栈在 cache 内),相对纯 Python 提升约 80 倍。前提是价格能离散化成整数下标——对 A 股、港股、期货这类固定 tick 的市场天然适用;加密货币这种没有强制 tick 的市场要先选一个最小价格单位归一。

8.5 数据治理常见坑


8.6 一段快照对账代码

下面是一个常用的”自家维护的盘口与独立 snapshot 对账”的简化实现:

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:   同 schema 的独立 snapshot
    返回各类不一致的统计与样例,可接入告警。
    """
    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()},
    }

生产环境里,这个对账逻辑应当:

很多团队把”对账失败率”作为行情系统的 SLO(Service Level Objective)之一:99.9% 以上的对账成功率是基本要求,95% 以下基本意味着上游某个环节坏了。

8.7 A 股的特殊处理

A 股微结构有几个工程上必须显式处理的特殊点:

8.8 跨语言架构建议

成熟的微结构数据栈通常分三层:

层级 语言 职责
行情接入与撮合本地簿 C++ / Rust 极致延迟、确定性内存布局、零拷贝解析
特征计算与回放 Rust / numba+Python 中等延迟、向量化、跨核并行
策略研究与可视化 Python (polars / pandas) 灵活迭代、Jupyter 友好

一个常见的反模式是在 Python 里直接订阅 WebSocket 然后维护本地簿:单线程的 GIL 会在中等流量品种上掉包,而掉包的盘口噪声远比模型偏差更难调试。即使预算紧张,也建议至少把 “WebSocket 订阅 + 本地簿维护 + 增量校验” 这一段下沉到 Rust 或 C++,再通过共享内存或 ZeroMQ 把整理好的事件流交给 Python。

8.9 微结构特征清单

把上面所有可计算量整理成一张清单,方便策略团队对齐特征工程的”基线集合”:

特征 时间尺度 含义 适合的策略
报价价差 \(S^q\) 实时 直接交易成本 所有策略基线
有效价差 \(S^e\) 逐笔 实际付出成本 执行算法、TCA 报告
已实现价差 \(S^r\) 5 分钟 做市纯收益 做市 PnL 拆解
微观价 \(p_w\) 实时 短期预测基准 高频做市、被动执行
OFI(best) 100 ms~1 s 短期方向预测 高频统计套利、被动执行
OFI(多档) 1 s 增强方向预测 中高频策略
Kyle’s lambda 1~5 s 即时深度 容量评估、冲击模型
Amihud ILLIQ 日级 横截面流动性 因子模型、风险模型
冲击半衰期 事件级 弹性 大额执行算法
VPIN 成交量桶 流动性毒性 风险监控、做市退避
Hawkes 分支比 滚动 30 分钟 市场内生程度 异常检测
排队位置 实时 队列前的相对位置 做市、被动执行

每个量都有”何时不能用”的边界。一个工程上常被忽视的检查:每天开盘后跑一份”特征健康度”报告——哪些特征当天偏离历史分布超过 3 个标准差、哪些品种特征缺失。这比策略本身的监控更早暴露问题。

8.10 回测中的 fill 假设

最后一个高频策略反复踩坑的环节:回测里”我的单子怎么成交”的假设。常见的几种 fill model:

  1. 乐观 fill:挂在 best 价上的单子,下一笔有反向市价单到达就成交。这种假设忽略了排队,回测过于乐观,是新手最常见的 bug。
  2. 队列模拟 fill:维护”我前面还有多少 qty”,反向市价吃完前面的才轮到我。需要 L3 数据或对 L2 做近似(假设你新挂的单全部排到当时该档已有量之后)。
  3. 基于历史成交分布的 fill:根据历史上”挂在 best 后 N 秒内成交概率”建立条件分布,按概率成交。介于乐观和队列模拟之间。

对应的自我执行成本估计:

这一节没有公式,但它决定了回测出来的”alpha”在实盘里折算多少。所有微结构指标的最终用途,都是把这个折算系数估准——这也是为什么这一篇要把价差、流动性、冲击讲到逐项可计算的程度。


九、风险提示


十、本篇收束

如果只能记住一句话:所有”alpha 折算”的根都在微结构里。这一篇给出的不是赚钱公式,是一组让”折算系数”可估计的工具:

下一篇 《订单类型与订单生命周期》 会从”我能用哪些类型的订单去实施这些决策”切入,把限价单、市价单、条件单、隐藏单、IOC/FOK/GTC 时效,连同它们在不同交易所的实现细节系统讲清楚。理解订单类型,是把本文的微结构判断落到具体交易动作上的第一步。


十一、参考资料

论文

书籍

规范与文档


上下篇导航

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-05-01 · quant

【量化交易】交易成本模型:冲击成本、滑点、TCA

把交易成本从「报表上的一行手续费」拆成显性成本、滑点、冲击、机会成本四层结构,给出 Almgren-Chriss 最优执行、平方根律拟合、Implementation Shortfall 归因与 TCA 报表的可运行 Python 骨架,以及 A 股、美股、CME、币安四个市场的成本口径差异。

2026-05-01 · quant

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

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

2026-05-01 · quant

【量化交易】市场结构:交易所、做市商、暗池、ECN

系统梳理全球市场结构(Market Structure)的工程图景:从证券交易所、衍生品交易所、加密交易所,到做市商、暗池、ECN/ATS,再到 Maker-Taker 收费、PFOF、Reg NMS 与 MiFID II 的监管影响;给出量化策略选择交易场所的判断框架与基于 ccxt 的多交易所行情聚合代码。

2026-05-01 · quant

【量化交易】订单类型与执行语义:限价、市价、IOC、FOK、冰山

把 Limit、Market、IOC、FOK、Iceberg、Stop、MOO/MOC 这些常被混为一谈的订单类型还原为价格、数量、时效、可见性、触发五个独立维度,并对照 A 股、港股、美股、CME、Binance 五个市场的实际语义差异,给出量化系统中的订单工厂、状态机与风控前置校验的工程实现。


By .