做策略的人,最常被问的一个问题是:为什么我在回测里赚的钱,到实盘只剩三成?大多数情况下,答案不在 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、币安 depth@stream + bookTicker | 排队位置预测、Hawkes 估计、做市策略 |
L2 和 L3 之间不是分辨率差异,而是信息熵差异。L2 把同一价格上的所有订单聚合成一个数字,丢掉了”我排在第几位”。任何要预测排队位置(Queue Position)的策略,比如做市挂单、被动执行算法,都必须用 L3。反过来,做短期方向预测的统计套利策略,L2 通常足够。
下图展示一个典型 L2 快照。注意中间出现的”空档”:100.04 这一档没有挂单,价差跨过了一个 tick。
1.2 价格优先、时间优先
几乎所有连续竞价市场使用 价格优先、时间优先(Price-Time Priority) 撮合:
- 价格更优的订单先被撮合:买方价高者先,卖方价低者先。
- 同价情况下,到达时间早的先被撮合(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 看到的”档量”未必是真实供给。两类常见的隐藏供给:
- 隐藏单(Hidden Order):上交所、深交所、Nasdaq 等都允许部分订单完全不显示在公开簿上,但参与撮合。它们对 OFI 信号是噪声源——你看到 best 档量没变,但成交确实发生了。
- 冰山单(Iceberg Order):只显示一部分(display size),剩余隐藏。每次显示部分被吃完后,下一片隐藏部分自动刷新到队尾。冰山单在公开簿上看像”被吃完后立刻有人重新挂上同一档”的现象。
实证上识别冰山单的常用启发式:在很短的时间窗口内(如 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\)。报价价差是”你看着的成本”,但不是”你实际付出的成本”。理由有两个:
- 你的单子不一定恰好在 best 价位成交,可能吃穿多档(slippage)。
- 你也可能用限价单耐心等到 better-than-mid 的成交。
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。
- 若恰好等于中价,使用 tick test:与上一笔成交价比较,价升标 +1,价降标 -1。
- 仍无法判断的,留作未知。
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)数据、某些另类资产。它的两个失效条件:
- 自协方差为正:成交价同向连跳,意味着有信息冲击或趋势,超出 Roll 的零均值方向假设。
- 价差时变剧烈:Roll 假设 \(S\) 在窗口内常数,需要按短窗口分段估计。
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)这段代码两个细节值得注意:
- 用
join_asof把每笔成交对齐到”成交时刻最近的一份报价”——前向对齐(backward)保证不偷看未来。 - 已实现价差需要用”成交时刻 + Δ”对应的中价,再做一次前向 asof,等同于”该时刻最近的可观察到的报价”。如果直接做 forward 对齐,就把”还没发生的报价”用进来了,会偏低估冲击、高估做市利润。
三、流动性的四个维度
把”流动性”折成一个数字一直是不靠谱的事。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\) 越大,少量净流量就把价格推得很远。
实际估计的常见做法:
- 把每秒(或每 5 秒)的净成交量与该窗口内的中价变动配对。
- 做 OLS 回归得 \(\hat\lambda\)。
- 按日聚合,比较不同股票或不同时段的 \(\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)}注意几件事:
- 桶化窗口决定 \(\lambda\) 的口径。1 秒的 \(\lambda\) 衡量的是”快速冲击”,5 分钟的 \(\lambda\) 衡量”中期吸收能力”。两者数量级会差几个量级,不能直接比较。
- 高频数据下的回归常常异方差严重,标准 OLS 的 t 统计量不可靠,用 HAC(Newey-West)方差或自助法替代。
- \(\lambda\) 在新闻事件、开盘/收盘前后会暴涨,做截面研究时要剔除这些时段。
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):在一笔大额市价单后,中价从冲击峰值回落到一半所需的时间。它把”市场修复速度”折成一个数。
实证上做法:
- 筛出”足够大”的事件样本(例如成交量超过当日 10 分钟均值 5 倍)。
- 按事件对齐时间(event time),取事件前后各 60 秒的中价序列。
- 对每个事件求 \(m(\tau) - m(0)\) 的均值曲线,拟合 \(m(\tau) = a + b \cdot e^{-\tau/T_{1/2}}\)。
- \(T_{1/2}\) 即半衰期。
不同品种半衰期差异极大:美股大盘 ETF 通常 5 秒以内、A 股大盘股几十秒、低流动性票分钟级、加密永续合约则受费率与杠杆机制影响呈跳跃式回归。
3.4 即时性:从撮合视角的理解
即时性(Immediacy)的传统定义是”立即成交的难度”。在连续撮合、深度尚可的市场上,即时性几乎等于”愿意付报价价差就能马上成交”,所以它常常被并入 tightness 一起讨论。但在以下场景下,即时性是独立的维度:
- 集合竞价、做市报价制度:A 股开盘 9:25、深交所收盘集合竞价、港股 POS(Pre-Open Session)等阶段不连续撮合,即时性等于 0,必须等到撮合时刻。
- 暗池与 RFQ:暗池可能要等对手匹配、RFQ 协议要等多家做市商报价回来——即时性以秒到分钟计。
- 熔断后:A 股个股 5%/10% 临停后短期不能成交,即时性骤降。
即时性的下降通常领先紧度与深度的恶化。任何在压力情景下回测的策略,必须显式建模即时性,否则会出现”因为来不及成交而损失越滚越大”的尾部风险。
四、订单流不平衡(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。
直观解释:
- best bid 的量增加(不论是因为新挂单还是 ask 被吃后下移到此),OFI +。
- best ask 的量增加(同理)OFI -。
- best bid 价格上移(原 bid 被新更高的 bid 取代),OFI +。
- best ask 价格下移,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")这一段实现里有两点经常出问题:
e_bid与e_ask的符号方向:写错了符号,OFI 与方向反向,回测呈现”完美的负相关”,会被人当成 alpha;这是常见的实习生陷阱。- 价格”不变”分支同时存在:当价格相等时,量增量本身是连续的,必须用
(qty_now - qty_prev),不能简单按”>=” “<=” 分。
4.4 OFI 的失效场景
OFI 不是免费午餐,它的失效场景值得记住:
- 高频做市商主导的票(如美股大盘指数 ETF):盘口剧烈翻转、绝大多数挂撤瞬时抵消,OFI 信号被噪声淹没。
- 集合竞价时段:盘口不持续撮合,OFI 没有定义。
- 极薄盘票:单笔大单造成 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 行情中较深档位的更新延迟、聚合方式都不一致,错误处理会引入信号噪声。
实务上一个简化方案:
- best 档(\(k=1\))权重 1.0;
- 第 2 档 0.5;
- 第 3 档及之后忽略。
这个权重已经足够拿到大部分边际信号,且对脏数据的鲁棒性最高。
五、冲击成本:永久冲击与临时冲击的分解
回到最开始那个问题:你的回测在实盘中折算了多少。冲击成本是核心折算项。
5.1 物理直觉
把订单簿想成一个弹簧加阻尼系统。一次大额市价单像突然推一下:
- 弹簧立即被压缩——这是 临时冲击(Temporary Impact),反映即时流动性消耗。
- 撮合完成后,做市商重新挂单、套利者补充流动性,价格部分回弹。
- 但弹簧没有完全恢复原位——这部分留下来的位移是 永久冲击(Permanent Impact),反映”市场认为你携带了信息”。
经验观察(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}\) 决定。
定性结论比公式重要:
- \(\lambda\) 越大(越怕风险),\(\kappa\) 越大,越倾向于尽快交易完。
- \(\eta\) 越大(临时冲击越贵),\(\kappa\) 越小,越倾向于慢慢交易。
- \(\gamma\)(永久冲击)只影响绝对成本,不影响交易速率——因为永久冲击与你怎么切片无关,是路径独立的。
实际算法落地里,几乎所有”VWAP/TWAP/IS”算法都是 Almgren-Chriss 在不同极限条件下的特化:
- \(\lambda \to 0\):纯 TWAP,匀速交易。
- \(\lambda \to \infty\):尽快交易完,纯 IS(Implementation Shortfall)。
- 用历史成交分布替换匀速:VWAP。
5.3 真实校准的工程现实
学术框架很优雅,工程上 calibrate \(\eta, \gamma\) 是另一回事。常见做法:
- 用自家历史订单(含撤单与中途修改)回归 post-trade markout:\(m_{t+\Delta} - m_t\) vs \(X/V\) 的曲线斜率。
- 按品种、按市值分桶,跨品种参数差异可达一个量级。
- 严防数据泄漏:用未来 mid 作为 markout 时,不能让”自己后续单子”造成的中价偏移污染估计——必须按”如果不交易”反事实重建中价。
工业界经常退而求其次,用 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)给出最早的信息不对称定价:
- 市场上有比例 \(\mu\) 的”知情交易者”(informed),他们知道资产的真实价值 \(V \in \{V_H, V_L\}\)。
- 剩下 \(1-\mu\) 是”噪声交易者”,方向随机。
- 做市商不知道谁是谁,只看到买卖订单序列。
在贝叶斯均衡下,做市商的最优 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 的几个工程细节:
- 成交量桶的大小 \(V\) 直接影响 VPIN 的尺度,跨品种比较时必须按 ADV(日均成交量)归一化。
- 买卖分类用 BVC(Bulk Volume Classification)或 tick rule,逐笔分类不可行。
- 在 Flash Crash 这类极端事件外,VPIN 的”提前警报”在常态市场上信噪比并不高。
6.4 Hasbrouck 信息份额
在多市场交易(cross-listing、ADR 与本土双重上市、不同交易所同股竞争)场景下,“价格发现到底发生在哪个市场”是一个具体问题。Hasbrouck(1995)的 信息份额(Information Share,IS) 给出量化答案:
- 把多市场的中价序列作为协整向量,估计 VECM。
- 求 VECM 的方差分解,得到永久冲击的方差中,每个市场的贡献占比。
- 该占比即该市场的信息份额,所有市场加起来为 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 过程在量化里的常见用途:
- 高频做市的 “下一笔市价单何时到” 预测。
- 测量市场内生程度:分支比 \(n = \alpha / \beta\) 接近 1 表示几乎所有事件都由前序事件激发,市场处于高度自反馈状态,是 Flash Crash 前兆之一(参见 Filimonov-Sornette 2012)。
- 撮合系统的负载预测:把订单簿事件强度建成 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}这里有几个工程上要注意的点:
alpha < beta的稳定性约束必须检查,否则强度会发散,似然爆炸。- 用递推式累计
A[i]把复杂度从 \(O(n^2)\) 降到 \(O(n)\);超过几万个事件的样本必须用这个递推。 - 高频订单簿事件经常带”同时刻多笔”——必须先给重复时间戳加上微小扰动,否则似然项 \(\log(0)\)。
branching_ratio = alpha / beta是关键诊断量。它接近 1 说明市场处于自激临界态,拥挤交易、Flash Crash 风险升高。
7.5 神经序列模型与 Hawkes 的关系
把 LSTM/Transformer 看作”把 Hawkes 的固定指数核换成神经网络学到的核”:神经核能拟合任意衰减形状(不仅是单一指数),且能跨事件类型自动学到互激矩阵。代价是可解释性、训练数据量、生产推理延迟都比 Hawkes 高一个台阶。
实际选择经验:
- 单品种、单事件类型预测:1-D Hawkes 足够,参数稳定、可解释。
- 跨事件类型互激分析(市价单、限价单、撤单之间):多维 Hawkes,约 4~6 类事件已是估计稳定的上限。
- 跨品种、跨市场:神经模型胜出,但要把 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” 模式:
- 启动时拉一份完整快照(snapshot),含 sequence number(seq)。
- 之后的每条 delta 消息都带递增 seq。
- 应用 delta 时校验 seq 连续;不连续就触发重新拉 snapshot。
币安、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 的消息,并按
U、u 的连续性维护状态。
8.3 快照对账与时钟同步
行情系统在生产里最难调的不是性能,是 “为什么我和别人看到的不一样”。三类常见根因:
- 增量丢包未察觉:seq 跳号但应用没做严格校验。修法:定期 cross-check 你算出来的 best vs 一份独立的 snapshot 源。
- 时钟漂移:自家机器与交易所时间偏差导致事件被错排。修法:用交易所给的时间戳,而不是接收时间戳。
- 数据源重连未重置状态: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_aqnumba 版在我自己机器上能到 4000 万条/秒(M1 Pro,单核,全栈在 cache 内),相对纯 Python 提升约 80 倍。前提是价格能离散化成整数下标——对 A 股、港股、期货这类固定 tick 的市场天然适用;加密货币这种没有强制 tick 的市场要先选一个最小价格单位归一。
8.5 数据治理常见坑
- 撤单与减量:L2 delta 不区分”撤了 100 手”和”被吃了 100 手”。如果你的微结构特征依赖这个区分(如 OFI 的细化版本),必须用 L3 或 trades + book 联合解析。
- 跨品种时钟:跨股票做截面研究时,每个股票的事件时间不一致,做对齐要用桶化时间,而不是直接 join。
- 盘前盘后:A 股开盘集合竞价(9:15-9:25)期间 best bid/ask 不连续,所有 OFI/价差指标必须屏蔽这一段。
- 熔断、临停:临时停牌期间订单簿会被冻结但 seq 仍可能递增(不同交易所策略不同),处理逻辑要能识别”虚假静止”。
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()},
}生产环境里,这个对账逻辑应当:
- 每分钟跑一次,全链路覆盖每个订阅的品种。
- 对账失败立即告警,并触发本地簿重建(重新拉 snapshot)。
- 把对账历史落到时序数据库,长期观察”对账失败率”作为数据质量指标。
很多团队把”对账失败率”作为行情系统的 SLO(Service Level Objective)之一:99.9% 以上的对账成功率是基本要求,95% 以下基本意味着上游某个环节坏了。
8.7 A 股的特殊处理
A 股微结构有几个工程上必须显式处理的特殊点:
- 逐笔成交带方向:上交所、深交所的 Level-2 成交里有 BS 字段,不需要 Lee-Ready 推断。这个字段的口径是”撮合时主动方”,与海外的”trade direction”语义一致,但对集合竞价时段的成交字段是空,需特殊处理。
- 撤单是独立事件:与海外的”order modification”统一事件不同,A 股 Level-2 行情把撤单作为单独消息发出(沪市的”撤单”标记、深市的”取消”消息)。计算 OFI 时要把它作为减档事件处理。
- 跨交易所同标的:A+H 股、ETF 与成分股的跨市场关系需要分别维护订单簿,再做 Hasbrouck 信息份额分析。
- 涨跌停板:触及涨停后,挂卖单可立即被吃,挂买单只能排队等待跌出涨停;价差结构与未触板情况完全不同,所有微结构估计在涨跌停期间应当屏蔽或单独建模。
- 科创板与创业板:注册制下涨跌幅放宽到 20%,新股前 5 日不设涨跌幅。冲击模型在这些品种上要单独 fit,参数与主板差异显著。
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:
- 乐观 fill:挂在 best 价上的单子,下一笔有反向市价单到达就成交。这种假设忽略了排队,回测过于乐观,是新手最常见的 bug。
- 队列模拟 fill:维护”我前面还有多少 qty”,反向市价吃完前面的才轮到我。需要 L3 数据或对 L2 做近似(假设你新挂的单全部排到当时该档已有量之后)。
- 基于历史成交分布的 fill:根据历史上”挂在 best 后 N 秒内成交概率”建立条件分布,按概率成交。介于乐观和队列模拟之间。
对应的自我执行成本估计:
- 市价单:报价价差 + 由模型预测的临时冲击。不要假设零冲击。
- 限价单:用历史 fill 概率折扣 alpha,剩下的部分作为期望收益。
- 跨期套利等需要”两腿都成交”:必须用条件概率,不能简单相乘。
这一节没有公式,但它决定了回测出来的”alpha”在实盘里折算多少。所有微结构指标的最终用途,都是把这个折算系数估准——这也是为什么这一篇要把价差、流动性、冲击讲到逐项可计算的程度。
九、风险提示
- 本文所有公式与代码用于教学与研究。任何把它们直接接入实盘策略的尝试,都需要在独立的回测与撮合仿真环境中充分验证。
- Kyle’s lambda、OFI 系数等都是历史拟合量,在市场制度切换、交易品种规则变化(如最小变动单位调整、涨跌停板修改)后会失效,必须重新校准。
- 平方根冲击律是大量 metaorder 平均的统计结果,单笔订单的实际冲击可能在 \(0.5\times\) 到 \(3\times\) 之间剧烈波动;用它做 capacity 估计时,一定要叠加方差项而不是只用均值。
- VPIN、PIN 这类信息流毒性指标在常态市场上的”提前预警”信噪比并不高,把它们用作”自动减仓触发器”很容易产生大量误触发,更适合作为人工监控辅助。
- Hawkes 分支比、神经序列模型在制度切换前后的训练样本分布漂移最严重,模型上线后必须有滚动重训练与冷启动 fallback。
- 本文不构成任何投资建议。市场微结构是工程问题,不是赚钱保证。
十、本篇收束
如果只能记住一句话:所有”alpha 折算”的根都在微结构里。这一篇给出的不是赚钱公式,是一组让”折算系数”可估计的工具:
- 价差告诉你单笔小单的成本下界;
- 流动性四维告诉你成本随订单规模、时段、品种的形状;
- 冲击模型把”我打算执行多大、用多久”映射成期望成本与方差;
- OFI、Hawkes、VPIN 这些日内信号告诉你”现在是不是适合下手”;
- 工程实现保证上面所有量在生产环境里数值可信。
下一篇 《订单类型与订单生命周期》 会从”我能用哪些类型的订单去实施这些决策”切入,把限价单、市价单、条件单、隐藏单、IOC/FOK/GTC 时效,连同它们在不同交易所的实现细节系统讲清楚。理解订单类型,是把本文的微结构判断落到具体交易动作上的第一步。
十一、参考资料
论文
- Roll, R. (1984). A Simple Implicit Measure of the Effective Bid-Ask Spread in an Efficient Market. Journal of Finance.
- Glosten, L., & Milgrom, P. (1985). Bid, Ask and Transaction Prices in a Specialist Market with Heterogeneously Informed Traders. Journal of Financial Economics.
- Kyle, A. (1985). Continuous Auctions and Insider Trading. Econometrica.
- Lee, C., & Ready, M. (1991). Inferring Trade Direction from Intraday Data. Journal of Finance.
- Easley, D., Kiefer, N., O’Hara, M., & Paperman, J. (1996). Liquidity, Information, and Infrequently Traded Stocks. Journal of Finance.
- Almgren, R., & Chriss, N. (2000). Optimal Execution of Portfolio Transactions. Journal of Risk.
- Amihud, Y. (2002). Illiquidity and Stock Returns. Journal of Financial Markets.
- Almgren, R., Thum, C., Hauptmann, E., & Li, H. (2005). Direct Estimation of Equity Market Impact. Risk.
- Easley, D., López de Prado, M., & O’Hara, M. (2012). Flow Toxicity and Liquidity in a High-Frequency World. Review of Financial Studies.
- Cont, R., Kukanov, A., & Stoikov, S. (2014). The Price Impact of Order Book Events. Journal of Financial Econometrics.
- Bacry, E., Mastromatteo, I., & Muzy, J.-F. (2015). Hawkes Processes in Finance. Market Microstructure and Liquidity.
- Moallemi, C., & Yuan, K. (2017). The Value of Queue Position in a Limit Order Book. Working Paper.
- Stoikov, S. (2018). The Micro-Price: A High-Frequency Estimator of Future Prices. Quantitative Finance.
- Sirignano, J., & Cont, R. (2019). Universal Features of Price Formation in Financial Markets. Quantitative Finance.
书籍
- O’Hara, M. (1995). Market Microstructure Theory. Blackwell.
- Hasbrouck, J. (2007). Empirical Market Microstructure. Oxford.
- Cartea, Á., Jaimungal, S., & Penalva, J. (2015). Algorithmic and High-Frequency Trading. Cambridge.
规范与文档
- 上海证券交易所《交易规则》;深圳证券交易所《交易规则》。
- SEC Rule 605 / 606 disclosures.
- Binance Spot API: Web Socket Streams: Diff. Depth Stream。
- Nasdaq TotalView-ITCH 5.0 Specification.
上下篇导航
- 上一篇:《市场结构与交易制度》
- 下一篇:《订单类型与订单生命周期》
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【量化交易】交易成本模型:冲击成本、滑点、TCA
把交易成本从「报表上的一行手续费」拆成显性成本、滑点、冲击、机会成本四层结构,给出 Almgren-Chriss 最优执行、平方根律拟合、Implementation Shortfall 归因与 TCA 报表的可运行 Python 骨架,以及 A 股、美股、CME、币安四个市场的成本口径差异。
【量化交易】量化交易全景:从信号到订单的工程链路
量化交易不是策略写得好就能赚钱,更难的是把数据、特征、因子、信号、组合、执行、风控、复盘这八段链路在工程上连成一条不漏数据、不串时间、不丢订单的流水线。本文是【量化交易】系列的总目录与读图,给出八段链路的输入输出、失败模式、不变量清单,并用研究流程图把从一个想法到一笔实盘订单之间所有该过的卡点串起来。
【量化交易】市场结构:交易所、做市商、暗池、ECN
系统梳理全球市场结构(Market Structure)的工程图景:从证券交易所、衍生品交易所、加密交易所,到做市商、暗池、ECN/ATS,再到 Maker-Taker 收费、PFOF、Reg NMS 与 MiFID II 的监管影响;给出量化策略选择交易场所的判断框架与基于 ccxt 的多交易所行情聚合代码。
【量化交易】订单类型与执行语义:限价、市价、IOC、FOK、冰山
把 Limit、Market、IOC、FOK、Iceberg、Stop、MOO/MOC 这些常被混为一谈的订单类型还原为价格、数量、时效、可见性、触发五个独立维度,并对照 A 股、港股、美股、CME、Binance 五个市场的实际语义差异,给出量化系统中的订单工厂、状态机与风控前置校验的工程实现。