4. 订单簿模拟:订单簿数据结构、限价单与市价单模拟、订单簿快照与增量更新

做市策略的核心,说白了就是跟订单簿打交道。你下的每一个单子,别人下的每一个单子,最后都会汇聚到订单簿里。所以,怎么在回测里把这个东西模拟好,直接决定了你的策略回测靠不靠谱。

我个人习惯把订单簿想象成一个「排队系统」。买盘这边,谁出价高谁排前面;卖盘那边,谁出价低谁排前面。价格相同的话,先来的先成交。嗯,这就是最经典的「价格优先、时间优先」原则。

订单簿的数据结构

先聊聊数据结构。我见过不少新手直接用列表来存订单簿,结果一跑回测,慢得让人崩溃。为什么?因为订单簿的核心操作是「插入」和「删除」,而且频率极高。列表的插入是 O(n) 的,量一大就扛不住。

我建议用 红黑树 或者 跳表 来实现。Python 里没有现成的红黑树,但我们可以用 sortedcontainers 库里的 SortedDict,它底层就是跳表,插入和删除都是 O(log n),够用了。

一个典型的订单簿结构长这样:

class OrderBook:
    def __init__(self):
        self.bids = SortedDict()  # 买盘,key是价格,value是订单列表
        self.asks = SortedDict()  # 卖盘,key是价格,value是订单列表
        self.order_map = {}       # 订单ID到订单对象的映射,方便撤销

每个订单对象,我一般会包含这些字段:

字段 类型 说明
order_id str 唯一标识
side str 'buy' 或 'sell'
price float 限价单的价格,市价单为 None
quantity float 剩余未成交数量
timestamp int 纳秒级时间戳,用于时间优先排序
小技巧: 时间戳一定要用纳秒级。我在项目里吃过亏,用毫秒级的话,同一毫秒内多个订单进来,顺序就乱了。回测结果跟实盘对不上,排查了好久才发现是这个问题。

限价单与市价单模拟

限价单和市价单,是订单簿里最基础的两种订单类型。它们的处理逻辑完全不同。

限价单 的逻辑很简单:挂上去,等着被吃。如果价格跟对面盘口有交叉,那就先成交一部分或全部,剩下的继续挂着。

举个例子,卖一价是 100.5,你挂一个 100.6 的买单。这时候价格有交叉,你的单子会立刻跟卖一成交。成交价是 100.5,不是 100.6。嗯,这里要注意:限价单的成交价,永远是对手盘的价格

市价单 就不一样了。它不指定价格,只指定数量。系统会从最优价格开始,一路吃过去,直到吃够数量为止。

我曾经在模拟市价单时犯过一个错:没考虑「滑点」。实盘里,市价单吃穿多个档位,成交均价会越来越差。回测里如果忽略这个,结果会偏乐观。所以模拟时一定要记录每一笔成交的价格和数量,算加权平均成交价。

核心代码逻辑大概是这样:

def process_market_order(self, side, quantity):
    filled_quantity = 0
    filled_price = 0.0
    # 根据买卖方向,选择对手盘
    if side == 'buy':
        levels = self.asks  # 吃卖盘
    else:
        levels = self.bids  # 吃买盘

    for price, orders in levels.items():
        for order in orders:
            trade_qty = min(quantity - filled_quantity, order.quantity)
            # 成交
            filled_quantity += trade_qty
            filled_price += trade_qty * price
            order.quantity -= trade_qty
            if order.quantity == 0:
                # 订单完全成交,移除
                self.order_map.pop(order.order_id)
            if filled_quantity == quantity:
                break
        # 如果当前价格档位全部吃光,移除该档位
        if filled_quantity == quantity:
            break
        del levels[price]

    avg_price = filled_price / filled_quantity
    return avg_price, filled_quantity
注意: 上面的代码是简化版。实际生产环境里,还要处理「冰山订单」、「隐藏订单」这些特殊情况。回测时如果忽略它们,结果会有偏差。

订单簿快照与增量更新

做回测时,我们不可能每笔订单都记录完整订单簿。那样数据量太大了,存不下。所以业界通用的做法是:定期拍快照,中间用增量更新

快照就是某一时刻订单簿的完整状态。增量更新就是「删掉哪些单子,新增哪些单子,修改哪些单子」。

我个人习惯每 100 毫秒拍一次快照。为什么是 100 毫秒?因为大多数高频策略的决策周期都在这个量级。太频繁了数据量太大,太稀疏了又捕捉不到细节。

增量更新的消息格式,我一般设计成:

{
    "type": "snapshot" | "delta",
    "timestamp": 1234567890,
    "bids": [
        [100.5, 1000],  # [价格, 数量]
        [100.4, 500]
    ],
    "asks": [
        [100.6, 800],
        [100.7, 300]
    ]
}

增量更新时,数量为 0 表示删除该价格档位。这样设计的好处是,你只需要维护一个当前订单簿的副本,每次收到增量就更新它。回测时,策略只需要订阅这个增量流,就能实时感知市场变化。

我记得有一次做回测,发现策略在某个时间点突然表现异常。排查了半天,原来是增量更新时,某个价格档位的数量没更新对,导致订单簿深度算错了。从那以后,我每次写增量更新逻辑,都会加一个「快照校验」:每收到 100 条增量,就跟最新快照对比一下,确保数据一致。

核心要点: 订单簿模拟的精度,直接决定了回测的可靠性。限价单和市价单的处理逻辑要严格对齐交易所规则,快照和增量更新的设计要兼顾性能和准确性。

下面这张图,是我自己总结的订单簿模拟整体流程,你可以参考一下:

订单簿模拟核心流程 原始订单流 限价单 vs 市价单 分类处理 订单簿数据结构(SortedDict) 买盘:价格降序 | 卖盘:价格升序 快照 100ms 增量 实时 回测引擎消费数据

说白了,订单簿模拟就是三个步骤:收订单 → 分类处理 → 更新状态。快照和增量更新只是这个流程的「输出格式」而已。你只要把核心逻辑写对了,输出什么格式都是顺手的事。

嗯,这一节的内容就到这里。代码量不大,但细节很多。建议你动手写一个简单的订单簿模拟器,跑几个测试用例,感受一下限价单和市价单的交互过程。只有亲手踩过坑,才能真正理解这些东西。

专注资料整理