4、订单簿数据结构:订单簿的构成(bids/asks)、深度图绘制、订单簿快照与增量更新。

做市交易的核心,说白了就是跟订单簿打交道。你每天盯着屏幕看的那一列列价格和数量,背后就是订单簿在支撑。我个人习惯把订单簿比作一个「实时拍卖场」——买单和卖单在这里不断碰撞,最终形成成交。

4.1 订单簿的构成:bids 和 asks

订单簿由两部分组成:bids(买单)asks(卖单)。bids 按价格从高到低排列,asks 按价格从低到高排列。为什么这样排?因为买方想用更低的价格买,卖方想用更高的价格卖,所以价格越高的买单越优先成交,价格越低的卖单越优先成交。

我举个例子,你打开任何一个交易所的 BTC/USDT 交易对,看到的买一、卖一就是订单簿的「第一层」。买一价格最高,卖一价格最低,两者之间的差值就是「价差」(spread)。

核心数据结构:订单簿本质上是一个有序的键值对集合。键是价格,值是数量。在 Python 中,我通常用 sortedcontainers 库或者直接维护两个列表。

来看一个简单的订单簿结构定义:

from dataclasses import dataclass
from typing import List, Tuple

@dataclass
class OrderBookLevel:
    price: float
    quantity: float

@dataclass
class OrderBook:
    bids: List[OrderBookLevel]  # 买单,按价格降序
    asks: List[OrderBookLevel]  # 卖单,按价格升序
    timestamp: int              # 快照时间戳

    def best_bid(self) -> float:
        return self.bids[0].price if self.bids else 0.0

    def best_ask(self) -> float:
        return self.asks[0].price if self.asks else 0.0

    def spread(self) -> float:
        return self.best_ask() - self.best_bid()

嗯,这里要注意:价格精度是个坑。我在项目中遇到过,不同交易所对价格的小数位数要求不一样。比如 Binance 的 BTC/USDT 价格精度是 2 位小数,但有些山寨币可能是 8 位。如果你用浮点数直接比较,很容易出问题。我建议统一用 Decimal 类型,或者至少做一次四舍五入。

4.2 深度图绘制:把订单簿变成可视化

光看数字不够直观,深度图(Depth Chart)能让你一眼看出市场的买卖力量对比。深度图的核心逻辑是:累计数量。从最优价格开始,向两侧累加每个价格档位的数量。

绘制步骤其实很简单:

  1. 从买一价格开始,向左(更低价格)累加 bids 的数量
  2. 从卖一价格开始,向右(更高价格)累加 asks 的数量
  3. 把价格作为 X 轴,累计数量作为 Y 轴,画两条曲线

我曾经用 Matplotlib 画过一张深度图,但后来发现 Plotly 更适合做交互式展示。这里给一个简单的实现:

import plotly.graph_objects as go

def plot_depth(order_book: OrderBook, symbol: str = "BTC/USDT"):
    # 计算累计深度
    bid_prices = [level.price for level in order_book.bids]
    bid_volumes = []
    cum_vol = 0
    for level in order_book.bids:
        cum_vol += level.quantity
        bid_volumes.append(cum_vol)

    ask_prices = [level.price for level in order_book.asks]
    ask_volumes = []
    cum_vol = 0
    for level in order_book.asks:
        cum_vol += level.quantity
        ask_volumes.append(cum_vol)

    fig = go.Figure()
    fig.add_trace(go.Scatter(
        x=bid_prices, y=bid_volumes,
        mode='lines', name='Bids',
        fill='tozeroy', line=dict(color='green')
    ))
    fig.add_trace(go.Scatter(
        x=ask_prices, y=ask_volumes,
        mode='lines', name='Asks',
        fill='tozeroy', line=dict(color='red')
    ))
    fig.update_layout(
        title=f"{symbol} 深度图",
        xaxis_title="价格",
        yaxis_title="累计数量",
        template="plotly_white"
    )
    return fig
小技巧:深度图的「陡峭程度」能反映市场流动性。如果 bids 侧曲线很陡,说明买盘集中在少数价格档位;如果曲线平缓,说明买盘分散,流动性好。

你想想看,深度图其实就是一个「压力测试」工具。比如你想卖 100 个 BTC,从深度图上就能看出价格会滑多少点。我在做市策略里,经常用深度图来估算「市场冲击成本」。

4.3 订单簿快照与增量更新

这是做市系统里最容易踩坑的地方。交易所通常提供两种数据:快照(Snapshot)增量(Incremental Update)

快照就是某一时刻订单簿的完整状态。增量则是后续的变化——哪些价格档位被新增、修改或删除了。

为什么需要增量?因为全量推送太费带宽了。一个深度 100 层的订单簿,每秒推送 10 次快照,数据量相当可观。所以交易所一般让你先拿一次快照,然后只推送增量。

我曾经踩过的坑:增量更新是有顺序的。如果你漏掉了一条增量,或者顺序乱了,整个订单簿就会「漂移」。比如你本地维护的 bids 和 asks 跟交易所实际数据对不上,做市策略就会出大问题。我建议在每次增量更新后,定期跟快照做一次「对账」。

增量更新的数据结构通常长这样:

@dataclass
class OrderBookDelta:
    bids: List[Tuple[float, float]]  # (价格, 数量),数量为0表示删除
    asks: List[Tuple[float, float]]
    timestamp: int

def apply_delta(order_book: OrderBook, delta: OrderBookDelta) -> OrderBook:
    # 处理 bids
    for price, qty in delta.bids:
        if qty == 0:
            # 删除该价格档位
            order_book.bids = [l for l in order_book.bids if l.price != price]
        else:
            # 更新或新增
            found = False
            for i, level in enumerate(order_book.bids):
                if level.price == price:
                    order_book.bids[i].quantity = qty
                    found = True
                    break
            if not found:
                order_book.bids.append(OrderBookLevel(price, qty))
    # 重新排序
    order_book.bids.sort(key=lambda x: x.price, reverse=True)

    # 处理 asks(逻辑类似,按价格升序)
    for price, qty in delta.asks:
        if qty == 0:
            order_book.asks = [l for l in order_book.asks if l.price != price]
        else:
            found = False
            for i, level in enumerate(order_book.asks):
                if level.price == price:
                    order_book.asks[i].quantity = qty
                    found = True
                    break
            if not found:
                order_book.asks.append(OrderBookLevel(price, qty))
    order_book.asks.sort(key=lambda x: x.price)

    order_book.timestamp = delta.timestamp
    return order_book

这里有个细节:数量为 0 表示删除该价格档位。为什么不是直接传一个「删除指令」?因为交易所为了简化协议,统一用数量来标识——数量为 0 就是删除,大于 0 就是新增或修改。

4.4 知识体系总览

为了让你更直观地理解本章的知识结构,我画了一张流程图:

订单簿数据结构知识体系 订单簿 (OrderBook) Bids (买单) Asks (卖单) 深度图绘制 (Depth Chart) 快照 + 增量更新 核心操作:排序、累计、更新、对账 注意:价格精度、增量顺序、数据漂移

这张图把本章的核心内容串起来了。你从订单簿出发,左边是 bids 和 asks 的构成,右边是深度图的绘制方法,下面是快照与增量更新的维护逻辑。三者缺一不可。

我的建议:刚开始做订单簿模块时,先实现一个「纯内存版本」,用 Python 的 dict 或 sortedcontainers 来管理。等逻辑稳定了,再考虑用 Redis 或数据库做持久化。别一上来就搞分布式,容易把自己绕进去。

好了,订单簿的数据结构就讲到这里。记住三个关键词:有序累计增量。下次你看到深度图的时候,脑子里应该能浮现出背后那一行行代码是怎么跑的。


公众号:蓝海资料掘金营,微信deep3321