订单簿动力学 (Limit Order Book Dynamics)
限价订单簿(LOB)结构、订单类型、价格-时间优先、撮合引擎、Iceberg/Hidden orders
日期: 2026-07-15 方向: 量化 / 微观结构 / 做市 阶段: Phase 2 - 市场微观结构与做市 (Day 75-88) 标签: #做市 #微观结构 #订单簿 #LOB #Binance
今日目标
| 类型 | 内容 |
|---|---|
| 学习 | 限价订单簿(LOB)结构、订单类型、价格-时间优先、撮合引擎、Iceberg/Hidden orders |
| 实操 | 拉取 Binance Futures L2 orderbook、解析 depth update stream、重建本地簿 |
| 产出 | lob_parser.py:增量更新合并、bid/ask 重建、深度可视化、统计指标 |
进入 Phase 2 第一天。从今天开始 14 天,目标是把 CEX 微观结构与 DEX AMM 做市的两套方法论彻底打通——这是顶级做市 desk(Wintermute / GSR / Jump)面试的核心内容。
一、限价订单簿(LOB)的解剖学
1.1 LOB 是什么
定义:限价订单簿是按价格排序的、所有未成交限价单的列表。买方挂单组成 bid book,卖方挂单组成 ask book。
Ask side (sellers, want HIGH price)
─────────────────────────────────
Price ↑ 61050 | 0.5 BTC ← Best ask (lowest)
61049 | 1.2 BTC
61048 | 3.0 BTC
───────────────────────────────── ← Spread
61045 | 2.1 BTC ← Best bid (highest)
61044 | 0.8 BTC
61043 | 5.0 BTC
─────────────────────────────────
Bid side (buyers, want LOW price)
核心字段:
| 概念 | 定义 | 公式 |
|---|---|---|
Best bid b | 买方最高出价 | max(bid_prices) |
Best ask a | 卖方最低要价 | min(ask_prices) |
Spread s | 买卖价差 | s = a - b |
Mid price m | 中间价 | m = (a + b) / 2 |
Microprice p̃ | 流量加权中间价 | p̃ = (a·V_b + b·V_a) / (V_a + V_b) |
| Depth at level k | 第 k 档累计量 | D_k = Σ_{i≤k} V_i |
Microprice 的直觉:如果 bid 上有 10 BTC、ask 上只有 1 BTC,下一笔成交大概率会"吃掉"ask,所以微观价偏向 ask。我们 Day 82 会重点讲。
1.2 订单类型
| 类型 | 行为 | 立即影响簿 | Maker/Taker |
|---|---|---|---|
| Limit (LMT) | 在指定价挂单等成交 | 加入簿 | Maker(拿返佣) |
| Market (MKT) | 立即按对手价成交 | 吃掉簿 | Taker(付费) |
| Post-Only | 必须 Maker;若立即成交则取消 | 仅加入簿 | Maker |
| IOC (ImmediateOrCancel) | 立即成交,未成交部分取消 | 不留底仓 | Taker |
| FOK (FillOrKill) | 全部立即成交,否则取消 | — | Taker |
| Iceberg | 显示一小部分,剩余隐藏 | 部分加入 | Maker |
| Hidden | 完全不显示,但仍参与撮合 | 不可见 | Maker |
| Stop / Stop-Limit | 触发后变 MKT/LMT | 触发前不在簿 | — |
1.3 价格-时间优先 (Price-Time Priority)
撮合规则(绝大多数交易所,包括 Binance / OKX / Bybit):
1. 价格优先:买方更高、卖方更低先成交
2. 时间优先:相同价格,先到先成交
3. 部分成交:剩余量留在原位置
边角案例:
- Pro-Rata 撮合(CME 部分品种):相同价格按挂单量比例分配,鼓励大额报价
- Self-trade prevention:同一账户不能撮合自己(避免洗交易)
- Cancel-on-disconnect:断线自动取消所有 open order(做市商必备)
1.4 数据形式:Snapshot vs Incremental
CEX 一般给两种 feed:
Snapshot (REST):完整簿,调用代价高,用于初始化。
GET /fapi/v1/depth?symbol=BTCUSDT&limit=1000
Incremental (WebSocket):增量 diff,bandwidth 友好。
ws://fstream.binance.com/ws/btcusdt@depth@100ms
正确重建流程(Binance 官方推荐):
1. Subscribe ws://...@depth
2. 缓冲收到的 events
3. GET 一份 snapshot,记下 lastUpdateId = U_snap
4. 丢弃所有 final_update_id <= U_snap 的 events
5. 第一个保留的 event:first_update_id <= U_snap+1 <= final_update_id
6. 之后每个 event:first_update_id == prev_final_update_id + 1
7. 任何 gap → 重新 step 3
这个步骤如果搞错,本地簿和真实簿会偏离,做市策略立刻爆掉。面试高频题。
二、LOB 的统计性质
2.1 形状:U 型 / L 型
实证(Bouchaud et al. 2009):
- 大盘币(BTC, ETH):簿形较"扁",深度衰减慢
- 小盘币 / 山寨:呈 L 型,best 档极薄,几个 tick 外断崖式下跌
- 新闻冲击:簿瞬间被吃光,spread 跳到正常的 5-10x
2.2 订单流的长记忆性
订单流方向(buy/sell)的 autocorrelation 在很多 lag 上仍显著为正: $$\rho(\tau) \sim \tau^{-\gamma}, \quad \gamma \in (0.2, 0.8)$$
直觉:大单被切片执行(VWAP/TWAP)、跟单交易、信息逐步释放——都导致流量持续。
做市启示:连续看到 buy 流时,下一笔仍是 buy 的概率高于 50%。这就是 OFI 信号的根基(Day 82)。
2.3 价格冲击 (Price Impact)
立即冲击 ≈ √(成交量): $$\Delta p \approx \kappa \sigma \sqrt{Q / V_{daily}}$$
其中 κ ≈ 0.1–1.0 取决于市场。临时冲击恢复一部分,永久冲击不恢复(信息含量)。Day 83 详细推导 Almgren-Chriss。
三、代码实现:lob_parser.py
完整可运行版本。先用合成数据演示核心逻辑,再附 Binance WebSocket 接入。
"""
lob_parser.py — 限价订单簿增量更新与重建
依赖:numpy, pandas, requests, websockets, sortedcontainers
"""
from __future__ import annotations
from sortedcontainers import SortedDict
from dataclasses import dataclass, field
from typing import Optional
import time, json, requests
# ----------------------------------------------------------
# 1. OrderBook 类:双侧 SortedDict(O(log N) 增删 / O(1) best)
# ----------------------------------------------------------
class OrderBook:
"""
bids: SortedDict, key=price, value=size, 反向迭代得最高价
asks: SortedDict, key=price, value=size, 正向迭代得最低价
"""
def __init__(self, symbol: str):
self.symbol = symbol
self.bids: SortedDict[float, float] = SortedDict()
self.asks: SortedDict[float, float] = SortedDict()
self.last_update_id: int = 0
self.snap_loaded: bool = False
# ---- snapshot 初始化 ----
def load_snapshot(self, snap: dict):
self.bids.clear(); self.asks.clear()
for p, q in snap["bids"]:
p, q = float(p), float(q)
if q > 0: self.bids[p] = q
for p, q in snap["asks"]:
p, q = float(p), float(q)
if q > 0: self.asks[p] = q
self.last_update_id = snap["lastUpdateId"]
self.snap_loaded = True
# ---- 增量更新 ----
def apply_update(self, evt: dict) -> bool:
"""
Binance Futures depth event:
U: first update id, u: final update id, pu: prev final
Returns False if a gap detected (need re-snapshot).
"""
if not self.snap_loaded:
return False
# Binance Futures 用 pu 校验
if "pu" in evt and evt["pu"] != self.last_update_id:
print(f"[GAP] pu={evt['pu']} != last={self.last_update_id}, need re-snap")
return False
# apply
for p, q in evt.get("b", []):
self._set(self.bids, float(p), float(q))
for p, q in evt.get("a", []):
self._set(self.asks, float(p), float(q))
self.last_update_id = evt["u"]
return True
@staticmethod
def _set(side: SortedDict, p: float, q: float):
if q == 0:
side.pop(p, None)
else:
side[p] = q
# ---- best / mid / spread ----
@property
def best_bid(self) -> Optional[float]:
return self.bids.peekitem(-1)[0] if self.bids else None
@property
def best_ask(self) -> Optional[float]:
return self.asks.peekitem(0)[0] if self.asks else None
@property
def mid(self) -> Optional[float]:
b, a = self.best_bid, self.best_ask
return (a + b) / 2 if b and a else None
@property
def spread(self) -> Optional[float]:
b, a = self.best_bid, self.best_ask
return a - b if b and a else None
@property
def microprice(self) -> Optional[float]:
if not (self.bids and self.asks): return None
b_p, b_v = self.bids.peekitem(-1)
a_p, a_v = self.asks.peekitem(0)
return (a_p * b_v + b_p * a_v) / (a_v + b_v)
# ---- depth profile ----
def depth(self, levels: int = 10) -> dict:
bids = list(reversed(self.bids.items()))[:levels]
asks = list(self.asks.items())[:levels]
return {"bids": bids, "asks": asks}
# ---- 累积深度(K 个 tick 内的累计量)----
def cumulative_depth(self, side: str, n_ticks: int) -> float:
if side == "bid":
ref = self.best_bid
if ref is None: return 0.0
cutoff = ref - n_ticks # 假设 tick=1,实际按 symbol
return sum(q for p, q in self.bids.items() if p >= cutoff)
else:
ref = self.best_ask
if ref is None: return 0.0
cutoff = ref + n_ticks
return sum(q for p, q in self.asks.items() if p <= cutoff)
# ----------------------------------------------------------
# 2. Binance Futures 接入(REST + WebSocket 重建)
# ----------------------------------------------------------
def fetch_binance_snapshot(symbol: str = "BTCUSDT", limit: int = 1000) -> dict:
url = "https://fapi.binance.com/fapi/v1/depth"
r = requests.get(url, params={"symbol": symbol, "limit": limit}, timeout=5)
r.raise_for_status()
return r.json() # {lastUpdateId, E, T, bids[[p,q]...], asks[[p,q]...]}
# 真实样例响应(截取):
SAMPLE_BINANCE_DEPTH = {
"lastUpdateId": 1027024,
"E": 1589436922972,
"T": 1589436922959,
"bids": [["61045.20", "0.521"], ["61045.10", "1.080"], ["61045.00", "3.500"]],
"asks": [["61046.00", "0.250"], ["61046.10", "0.700"], ["61046.20", "2.200"]],
}
# ws event 样例
SAMPLE_DEPTH_EVENT = {
"e": "depthUpdate", "E": 1589436922972, "T": 1589436922959,
"s": "BTCUSDT",
"U": 1027025, "u": 1027030, "pu": 1027024,
"b": [["61045.20", "0.000"], ["61044.90", "0.500"]], # 0 = remove
"a": [["61046.00", "0.150"]],
}
# ----------------------------------------------------------
# 3. 演示:snapshot + 增量
# ----------------------------------------------------------
if __name__ == "__main__":
ob = OrderBook("BTCUSDT")
ob.load_snapshot(SAMPLE_BINANCE_DEPTH)
print(f"After snapshot: bid={ob.best_bid}, ask={ob.best_ask}, "
f"spread={ob.spread:.2f}, mid={ob.mid:.2f}, "
f"microprice={ob.microprice:.4f}")
ok = ob.apply_update(SAMPLE_DEPTH_EVENT)
print(f"\nAfter incr update (ok={ok}):")
print(f" bid={ob.best_bid}, ask={ob.best_ask}, spread={ob.spread:.2f}")
print(f" Top 3 bids: {list(reversed(ob.bids.items()))[:3]}")
print(f" Top 3 asks: {list(ob.asks.items())[:3]}")
print(f"\nCumulative depth ±5 ticks: "
f"bid={ob.cumulative_depth('bid', 5):.3f} "
f"ask={ob.cumulative_depth('ask', 5):.3f}")
预期输出:
After snapshot: bid=61045.2, ask=61046.0, spread=0.80, mid=61045.60, microprice=61045.4304
After incr update (ok=True):
bid=61045.1, ask=61046.0, spread=0.90
Top 3 bids: [(61045.1, 1.08), (61045.0, 3.5), (61044.9, 0.5)]
Top 3 asks: [(61046.0, 0.15), (61046.1, 0.7), (61046.2, 2.2)]
注意:b=["61045.20","0.000"] 把原 best bid 删除,best 变为 61045.1。
3.1 完整 WebSocket 重建(伪 + 真)
import asyncio, websockets
async def maintain_book(symbol="btcusdt"):
ob = OrderBook(symbol.upper())
buffer = []
url = f"wss://fstream.binance.com/ws/{symbol}@depth@100ms"
async with websockets.connect(url) as ws:
# 1. 缓冲 events 同时拉 snapshot
async def bufferer():
async for raw in ws:
buffer.append(json.loads(raw))
task = asyncio.create_task(bufferer())
await asyncio.sleep(1.0) # 攒一秒事件
snap = fetch_binance_snapshot(symbol.upper())
ob.load_snapshot(snap)
# 2. 丢弃 u <= lastUpdateId 的事件
kept = [e for e in buffer if e["u"] > snap["lastUpdateId"]]
# 3. 第一条必须满足 U <= lastUpdateId+1 <= u
first = kept[0]
assert first["U"] <= snap["lastUpdateId"] + 1 <= first["u"], "first event mismatch"
# 4. 应用历史
for e in kept:
ob.apply_update(e)
buffer.clear()
# 5. 实时
async for raw in ws:
evt = json.loads(raw)
ok = ob.apply_update(evt)
if not ok:
# gap 检测:重新 snapshot
snap = fetch_binance_snapshot(symbol.upper())
ob.load_snapshot(snap)
else:
if ob.spread:
print(f"{symbol} mid={ob.mid:.2f} spread={ob.spread:.2f} "
f"micro={ob.microprice:.4f}")
# asyncio.run(maintain_book())
四、真实数据接入:交易所 API 速查
| Exchange | REST snapshot | WebSocket incr | 校验字段 |
|---|---|---|---|
| Binance Spot | GET /api/v3/depth?symbol=&limit=1000 | wss://stream.binance.com:9443/ws/<sym>@depth | U/u,需 U ≤ lastUpdateId+1 ≤ u |
| Binance Futures (USD-M) | GET /fapi/v1/depth?symbol=&limit=1000 | wss://fstream.binance.com/ws/<sym>@depth@100ms | U/u/pu,pu == prev.u |
| OKX | GET /api/v5/market/books?instId= | wss://ws.okx.com:8443/ws/v5/public books | checksum (CRC32 前 25 档) |
| Bybit | GET /v5/market/orderbook?symbol=&limit= | wss://stream.bybit.com/v5/public/linear orderbook.50 | seq 单调递增 |
| Hyperliquid | POST /info {"type":"l2Book","coin":"BTC"} | wss://api.hyperliquid.xyz/ws subscribe l2Book | seq |
Binance 真实响应字段表
// REST GET /fapi/v1/depth?symbol=BTCUSDT&limit=10 -> 真实结构
{
"lastUpdateId": 4068547139,
"E": 1709337600234, // event time ms
"T": 1709337600220, // transaction time ms
"bids": [["62150.10","2.345"], ...],
"asks": [["62150.20","1.220"], ...]
}
// WS depthUpdate
{
"e":"depthUpdate","E":1709337600234,"T":1709337600220,
"s":"BTCUSDT",
"U":4068547140, // first updateId in event
"u":4068547152, // final updateId in event
"pu":4068547139, // prev event final (用于校验)
"b":[["62149.90","0.500"],["62149.80","0"]], // 0 = remove
"a":[["62150.20","1.300"]]
}
五、CEX vs DEX 对比(核心增值)
这是接下来 14 天的主线。今天先建立对照框架:
| 维度 | CEX LOB | DEX AMM (Uni V2/V3) | DEX LOB (Hyperliquid, dYdX v3) |
|---|---|---|---|
| 流动性载体 | 显式挂单(人/算法) | 池中代币储备 | 链下挂单 + 链上结算 |
| 价格发现 | 订单流推动 + 信息流 | swap 移动 reserves | 与 CEX 类似 |
| 做市进入门槛 | API key + 余额 | 任何人 LP(无许可) | 需要 maker 身份/抵押 |
| 库存管理 | 自由对冲 | 池内被动暴露 | 自由对冲 |
| 延迟 | µs 级(colo) | block time(~12s ETH, 400ms Solana, 200ms HL) | 100-300ms |
| 抢跑/MEV | 内部秩序保护 | sandwich/JIT 公开链上 | sequencer 隐私 |
| gas / fee | 0.01% maker rebate ~ 0.05% taker | 0.05–1% pool fee + gas | 0.01–0.05% |
| 本地簿重建 | 必须 | 不存在簿 | 可选 |
| 隐藏单 | Iceberg、Hidden | 不存在(链上全透明) | 链下隐藏 |
| 撮合规则 | 价格-时间优先 | 恒定函数 (constant function) | 价格-时间优先 |
第一性原理:CEX 做市就是"挂限价单+管理库存";AMM "做市"是把代币锁进合约后被动接受对手;DEX LOB 介于两者之间。Day 84-86 我们会用数学证明:Uni V3 的"集中流动性"在数学上等价于 CEX 上的一组"带有有限上下界的限价单"。
六、风险与陷阱
-
Snapshot/Incremental Race Condition 错误顺序导致本地簿与真实簿持续偏移。结果:你的最优报价基于错误的 best,每笔成交都给市场送钱。修复:严格按官方
pu/u/U校验序列,gap 立即重新 snapshot。 -
Tick Size 没对齐 不同 symbol tick 不同(BTCUSDT=0.10, DOGEUSDT=0.00001)。如果用相同代码处理,浮点精度会让 best price 变成 "61045.0999999"。修复:用
Decimal或者把价格存为int(price / tick_size)。 -
0 quantity ≠ price level 不存在 增量里
["61045.20", "0"]是删除信号,不能当 0 quantity 留下。否则迭代时会把"已删档位"当 best。 -
Hidden Liquidity 让你以为流动性低估 Iceberg 只显示一小部分。看 best ask = 0.5 BTC,但实际可能是 50 BTC iceberg。做市启示:用历史成交反推有效深度,不只看挂单。
-
REST rate limit Binance Futures
/fapi/v1/depth?limit=1000占 20 weight,1200/min 限额。生产做市必须只在 ws gap 时才 re-snap,不能 polling。
七、关键速查
公式
mid = (best_bid + best_ask) / 2
spread = best_ask - best_bid
microprice = (ask · V_bid + bid · V_ask) / (V_ask + V_bid)
imbalance = (V_bid − V_ask) / (V_bid + V_ask) // ∈ [−1, 1]
weighted_mid = (1−I)/2 · ask + (1+I)/2 · bid 其中 I=imbalance
Binance Futures 端点
REST GET https://fapi.binance.com/fapi/v1/depth?symbol=&limit=
WS wss://fstream.binance.com/ws/<sym>@depth@100ms
WS wss://fstream.binance.com/ws/<sym>@depth5@100ms // top 5 only
WS wss://fstream.binance.com/ws/<sym>@bookTicker // best bid/ask only
八、面试题
Q1: 描述从订阅到拥有正确本地簿的完整流程,重点说明如何处理消息顺序。
A: 1) 订阅 ws 缓冲 events;2) 调用 REST snapshot 拿
lastUpdateId;3) 丢弃u ≤ lastUpdateId的事件;4) 验证第一条满足U ≤ lastUpdateId+1 ≤ u;5) 后续每条pu == prev.u,否则 gap 重启。
Q2: 为什么 microprice 比 mid price 更适合做市定价?
A: mid 假设 bid/ask 量对称,但 imbalanced book 下"下一笔成交价"偏向量小那侧。microprice 是 mid 的"无套利校正",等价于在 imbalance 信号下的 short-term fair value 估计。Stoikov 2018 证明 microprice 在 short horizon 是 unbiased fair value。
Q3: BTC 现货 spread 0.01 bps,Solana 上 SOL/USDC 池 spread 5 bps。这个差距来自什么?
A: (a) CEX maker 不付 gas,AMM LP 隐含 IL 成本必须被 fee 覆盖;(b) CEX 价格连续,AMM 离散更新,需要 fee 缓冲套利;(c) CEX 多个做市商竞价,AMM 池子缺乏定价竞争。
Q4: 什么是订单流的"长记忆性"?做市启示?
A: 同向订单流自相关在大 lag 仍正:
ρ(τ) ~ τ^(-γ)。来自大单切片、跟单、信息逐步释放。做市启示:连续买流时,调高 ask、调低 bid offset,避免 adverse selection。这是 OFI 信号的根基。
Q5: Iceberg 订单存在如何影响最优报价?
A: 真实深度 > 显示深度,使得"看似挂在 best 之后能成交"的 limit 实际有 Iceberg 在前面,永远成交不到。修正方式:用 trade size > displayed size 的事件检测 hidden,或用 OFI / queue position 模型估算有效 queue。
明日预告
Day 76:价格发现 — Kyle 模型与 Glosten-Milgrom 模型。Kyle 是单期/多期信息整合,Glosten-Milgrom 是逐笔贝叶斯更新。今天我们看到的 microprice 其实是 GM 模型的退化形式。明天严格推导 Kyle 的 lambda(市场冲击系数)从信息不对称中如何涌现,用蒙特卡洛证实理论结果。