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 条增量,就跟最新快照对比一下,确保数据一致。
下面这张图,是我自己总结的订单簿模拟整体流程,你可以参考一下:
说白了,订单簿模拟就是三个步骤:收订单 → 分类处理 → 更新状态。快照和增量更新只是这个流程的「输出格式」而已。你只要把核心逻辑写对了,输出什么格式都是顺手的事。
嗯,这一节的内容就到这里。代码量不大,但细节很多。建议你动手写一个简单的订单簿模拟器,跑几个测试用例,感受一下限价单和市价单的交互过程。只有亲手踩过坑,才能真正理解这些东西。