1. 逐笔盘口数据解析与事件化
逐笔盘口回测与K线回测最本质的差别,在于输入不再是“每根K线四个价格”,而是一串带时间戳、带方向、带订单编号的离散消息。要让回测引擎能处理这类数据,第一步不是写撮合逻辑,而是把原始行情文件转换成引擎能消费的事件流。本节围绕“数据结构—事件类型—转换实现”这条主线展开,目标是产出一个可复用的解析器骨架。
两类原始数据:逐笔成交与逐笔委托
国内A股Level-2行情中,逐笔成交(Transaction)与逐笔委托(Order)是两条独立通道,字段含义不同,不能混为一谈。逐笔成交描述“已经发生的撮合”,典型字段包括:成交时间(精确到毫秒或十毫秒)、证券代码、成交编号、买方委托编号、卖方委托编号、成交价格、成交数量、成交方向(主动买/主动卖/未知)。逐笔委托描述“新进入撮合队列的指令”,典型字段包括:委托时间、证券代码、委托编号、委托价格、委托数量、买卖方向、订单类型(限价/市价/本方最优等)。
这里有一个容易被忽略的细节:逐笔成交中的买方/卖方委托编号,是连接成交与委托的桥梁。有了它,才能把一笔成交归因到具体的挂单,进而判断这笔成交是“吃掉已有挂单”还是“新单立即成交”。如果解析时丢弃了委托编号,后续就无法重建订单生命周期,事件流的信息量会大幅缩水。
另一个关键约定是时间戳的参照系。交易所逐笔数据的时间通常只到秒或毫秒,且同一时间戳内可能有多条消息。解析时必须保持原始文件中的行序作为同时间戳内的次序依据,不能自行排序,否则会破坏真实的到达顺序。这一点在回测中直接影响成交判定的公平性。
四类事件的设计
把原始记录映射为事件,核心是定义一套稳定的事件类型。对本框架而言,最小可用集合是四类:
- 订单到达事件(OrderArrival):由逐笔委托记录生成,携带委托编号、方向、价格、数量、订单类型、时间戳。
- 成交事件(Trade):由逐笔成交记录生成,携带成交编号、买/卖委托编号、价格、数量、主动方向、时间戳。
- 撤单事件(Cancel):部分行情源以独立撤单记录给出,或以“委托数量减少/状态变更”体现;需统一为携带委托编号与撤单量的显式事件。
- 快照更新事件(Snapshot):由周期性盘口快照生成,携带十档价格与数量,用于校验订单簿重建结果或作为兜底状态。
四类事件共享一个基类字段:event_id(自增序号)、ts(统一为整数毫秒)、symbol、type。这样引擎侧只需按 ts 升序、同 ts 按 event_id 升序消费,就能保证确定性回放。
转换实现的关键步骤
下面给出一个可运行的解析器骨架,用Python表达,重点展示字段映射与事件构造,而非完整工程代码。假设输入是已按时间排序的CSV,每行带一个 msg_type 字段区分成交(T)与委托(O)。
from dataclasses import dataclass, field
from typing import List, Dict
@dataclass
class Event:
event_id: int
ts: int # 统一毫秒
symbol: str
type: str
@dataclass
class OrderArrival(Event):
order_id: str
side: str # 'B' / 'S'
price: float
qty: int
order_type: str # 'LIMIT' / 'MARKET' / 'BEST'
@dataclass
class Trade(Event):
trade_id: str
buy_order_id: str
sell_order_id: str
price: float
qty: int
aggressor: str # 'B' / 'S' / 'UNKNOWN'
@dataclass
class Cancel(Event):
order_id: str
cancel_qty: int
def parse_row(row: dict, seq: int) -> Event:
ts = int(row['time_ms'])
if row['msg_type'] == 'O':
return OrderArrival(seq, ts, row['symbol'], 'OrderArrival',
row['order_id'], row['side'],
float(row['price']), int(row['qty']),
row['order_type'])
elif row['msg_type'] == 'T':
return Trade(seq, ts, row['symbol'], 'Trade',
row['trade_id'], row['buy_order_id'],
row['sell_order_id'], float(row['price']),
int(row['qty']), row['aggressor'])
elif row['msg_type'] == 'C':
return Cancel(seq, ts, row['symbol'], 'Cancel',
row['order_id'], int(row['cancel_qty']))
raise ValueError(f"未知消息类型: {row['msg_type']}")
这段代码里有两个设计决定值得说明。第一,事件编号在解析阶段就分配,而不是留给引擎,这样即使后续做多品种合并,也能通过 (symbol, event_id) 唯一定位事件。第二,时间戳统一为整数毫秒,避免浮点比较带来的同时间戳判定歧义;若原始数据精度为十毫秒,乘以10即可,但要在文档中写明换算关系,全文保持一致。
撤单事件的识别难点
并非所有行情源都提供独立的撤单记录。有的数据商把撤单编码为“委托数量为负的委托记录”,有的则在快照中体现为某档数量减少。解析器需要针对数据源做适配:若存在显式撤单字段,直接映射为 Cancel;若只能从委托流推断,则维护一个 order_id → 剩余数量 的字典,当同一委托编号出现数量减少且无对应成交时,生成一条 Cancel 事件。这个字典同时也是后续订单簿重建的基础,建议在解析阶段就建好,而不是等到撮合模块再补。
order_id,累计的成交数量加撤单数量不应超过原始委托数量。若超出,说明数据源存在重复记录或字段错位,应将该订单标记为异常并输出到日志,而不是静默丢弃。从事件流到可回放序列
解析完成后,事件流还需经过一次稳定排序:以 ts 为主键升序,以 event_id 为次键升序。由于 event_id 按原始行序分配,这次排序实际上等价于“保持原始顺序、仅按时间归并”,不会打乱同时间戳内的真实到达次序。排序后的列表即可直接交给回测引擎逐条消费。
一个常见的验证方法是:用解析出的事件流重建订单簿,在若干快照时间点上与原始快照文件比对十档价格与数量。若某档数量偏差持续存在,通常指向三类问题——委托编号未正确关联、撤单未被识别、或同时间戳内成交与委托的处理顺序有误。把这三类问题逐一排除,事件流的正确性就有了可检验的依据,后续的撮合与绩效计算才站得住脚。