返回 Expert 笔记
Expert Day 75

订单簿动力学 (Limit Order Book Dynamics)

限价订单簿(LOB)结构、订单类型、价格-时间优先、撮合引擎、Iceberg/Hidden orders

2026-07-15
Phase 2 - 市场微观结构与做市 (Day 75-88)
做市微观结构订单簿LOBBinance

日期: 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̃ = (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 速查

ExchangeREST snapshotWebSocket incr校验字段
Binance SpotGET /api/v3/depth?symbol=&limit=1000wss://stream.binance.com:9443/ws/<sym>@depthU/u,需 U ≤ lastUpdateId+1 ≤ u
Binance Futures (USD-M)GET /fapi/v1/depth?symbol=&limit=1000wss://fstream.binance.com/ws/<sym>@depth@100msU/u/pupu == prev.u
OKXGET /api/v5/market/books?instId=wss://ws.okx.com:8443/ws/v5/public bookschecksum (CRC32 前 25 档)
BybitGET /v5/market/orderbook?symbol=&limit=wss://stream.bybit.com/v5/public/linear orderbook.50seq 单调递增
HyperliquidPOST /info {"type":"l2Book","coin":"BTC"}wss://api.hyperliquid.xyz/ws subscribe l2Bookseq

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 LOBDEX 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 / fee0.01% maker rebate ~ 0.05% taker0.05–1% pool fee + gas0.01–0.05%
本地簿重建必须不存在簿可选
隐藏单Iceberg、Hidden不存在(链上全透明)链下隐藏
撮合规则价格-时间优先恒定函数 (constant function)价格-时间优先

第一性原理:CEX 做市就是"挂限价单+管理库存";AMM "做市"是把代币锁进合约后被动接受对手;DEX LOB 介于两者之间。Day 84-86 我们会用数学证明:Uni V3 的"集中流动性"在数学上等价于 CEX 上的一组"带有有限上下界的限价单"。


六、风险与陷阱

  1. Snapshot/Incremental Race Condition 错误顺序导致本地簿与真实簿持续偏移。结果:你的最优报价基于错误的 best,每笔成交都给市场送钱。修复:严格按官方 pu/u/U 校验序列,gap 立即重新 snapshot。

  2. Tick Size 没对齐 不同 symbol tick 不同(BTCUSDT=0.10, DOGEUSDT=0.00001)。如果用相同代码处理,浮点精度会让 best price 变成 "61045.0999999"。修复:用 Decimal 或者把价格存为 int(price / tick_size)

  3. 0 quantity ≠ price level 不存在 增量里 ["61045.20", "0"] 是删除信号,不能当 0 quantity 留下。否则迭代时会把"已删档位"当 best。

  4. Hidden Liquidity 让你以为流动性低估 Iceberg 只显示一小部分。看 best ask = 0.5 BTC,但实际可能是 50 BTC iceberg。做市启示:用历史成交反推有效深度,不只看挂单。

  5. 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(市场冲击系数)从信息不对称中如何涌现,用蒙特卡洛证实理论结果。