2. 盘口状态重建与订单簿维护
逐笔回测与K线回测最本质的差别,在于前者必须自己“养”出一份订单簿。交易所推送的逐笔委托与逐笔成交是增量事件,而策略看到的盘口(买一至买N、卖一至卖N)是这些事件累积后的状态。所谓盘口状态重建,就是按时间顺序把每一条增量事件应用到本地订单簿上,使任意时刻的本地盘口与真实市场一致。这一节围绕四件事展开:价格档位如何聚合、订单队列如何管理、快照如何校验、异常如何修复。
一、订单簿的数据结构选择
订单簿由两侧组成:买方(Bid)与卖方(Ask)。每一侧是若干价格档位,每个档位下挂着一个订单队列。价格档位需要支持“按价格排序取最优N档”和“按价格定位单档”两种操作,因此常用结构是有序映射:买方按价格降序、卖方按价格升序排列,取最优档即取首元素。订单队列则要支持追加、按订单号删除、按订单号查找,通常用哈希表(订单号→订单引用)加双向链表(保持时间优先顺序)组合实现。
这里要先明确一个参照约定:本文所有“最优价”均指买方最高价、卖方最低价;价差(Spread)定义为卖一价减买一价,恒为非负。后文图示与示例均遵循此定义。
二、价格档位聚合
逐笔委托事件通常带有价格与数量,但不同市场的最小价位(tick size)不同,且部分市场存在“同一价格多笔委托”的情况。聚合规则是:以价格作为档位键,同一价格的所有订单数量之和即为该档位的总量。例如某股票 tick size 为 0.01 元,收到三笔买单:10.00 元 200 股、10.00 元 300 股、9.99 元 100 股,则买方档位为 10.00→500、9.99→100。
聚合时有两个易错点。其一,价格必须做精度归一化,浮点数直接比较会出现 10.00 与 10.0000001 被当成两档的问题,工程上通常把价格乘以 100(或按 tick 放大)转为整数作为键。其二,档位数量归零后要删除该档,否则会出现“买一数量为0”的假档位,导致最优价计算错误。
三、订单队列管理与事件处理
逐笔事件主要分三类,处理逻辑各不相同:
- 新增委托(Add):在对应价格档位尾部追加订单,档位总量加上该订单数量,同时登记订单号到队列节点的映射。
- 撤单(Cancel):按订单号找到节点,从队列中摘除,档位总量减去该订单剩余数量;若订单号不存在,说明本地状态已与市场脱节,需记录异常。
- 成交(Trade):成交会消耗订单数量。若成交事件带主动方方向,则被吃掉的是对手方队列;若只带订单号,则直接按订单号扣减对应订单数量,扣到0则从队列移除。
队列管理的关键是保持时间优先:同一价格档位内,先到的订单排在前面,成交应从队首开始消耗。很多回测框架忽略这一点,直接用“档位总量”做撮合,结果在排队成交场景下高估了策略的成交概率。
四、快照校验与异常修复
仅靠增量事件重建,误差会随时间累积:丢包、乱序、重复推送都会让本地盘口偏离真实值。因此需要定期用交易所快照做校验。校验逻辑是:在快照时刻,逐档比较本地与快照的价格和数量,若所有档位完全一致则通过;若不一致,则以快照为准整体覆盖本地订单簿,并重建订单号映射(快照通常不含订单号,此时队列只能保留档位总量,无法保留订单级信息,需在框架中标注该降级状态)。
除快照校验外,还应设置若干实时异常检测:买一价大于等于卖一价(交叉盘口)、某档数量为负、订单号重复新增等。检测到异常时,可选择丢弃该事件、触发快照重同步,或标记该时段数据不可信。下图展示了一条逐笔事件从进入到落盘、再到校验修复的完整数据流。
五、一个可运行的校验示例
下面用一段伪代码说明快照校验与覆盖的核心逻辑,参数为教学示例:
def verify_and_repair(book, snapshot):
# book: 本地订单簿;snapshot: {bids:[(price, qty)...], asks:[...]}
for side in ("bids", "asks"):
local = book.levels(side) # 已按最优价排序
remote = snapshot[side]
if len(local) != len(remote):
return repair(book, snapshot) # 档数不同,直接覆盖
for (lp, lq), (rp, rq) in zip(local, remote):
if lp != rp or lq != rq:
return repair(book, snapshot)
return True # 校验通过
def repair(book, snapshot):
book.clear() # 清空档位与订单映射
for side in ("bids", "asks"):
for price, qty in snapshot[side]:
book.set_level(side, price, qty) # 仅恢复档位总量
book.mark_degraded() # 标记:订单级信息丢失
return False
注意 mark_degraded() 这一步:快照覆盖后,本地只剩档位总量,无法再按订单号处理后续撤单与成交。工程上的常见做法是:在覆盖后的一小段时间内,对无法匹配订单号的事件采取“按档位总量近似扣减”,并记录降级区间,回测报告中单独标注该时段成交概率的可信度较低。
六、工程实践中的几个判断依据
判断订单簿维护是否可靠,可以观察三个指标:校验通过率(快照比对一致的比例,正常应在99%以上)、异常事件计数(撤单找不到订单号、重复新增等)、降级时段占比(因覆盖而丢失订单级信息的时间比例)。若校验通过率持续偏低,优先排查价格归一化与事件排序;若异常计数集中在开盘集合竞价时段,则可能是事件类型定义与市场规则不匹配,而非代码缺陷。
盘口状态重建做扎实之后,后续的撮合模拟、排队估计与滑点计算才有可信的输入。订单簿不是一次性建好的静态结构,而是一个需要持续校验、持续修复的动态状态机,这一点在长周期逐笔回测中尤为关键。