1. 逐笔盘口数据解析与事件化

逐笔盘口回测与K线回测最本质的差别,在于输入不再是“每根K线四个价格”,而是一串带时间戳、带方向、带订单编号的离散消息。要让回测引擎能处理这类数据,第一步不是写撮合逻辑,而是把原始行情文件转换成引擎能消费的事件流。本节围绕“数据结构—事件类型—转换实现”这条主线展开,目标是产出一个可复用的解析器骨架。

两类原始数据:逐笔成交与逐笔委托

国内A股Level-2行情中,逐笔成交(Transaction)与逐笔委托(Order)是两条独立通道,字段含义不同,不能混为一谈。逐笔成交描述“已经发生的撮合”,典型字段包括:成交时间(精确到毫秒或十毫秒)、证券代码、成交编号、买方委托编号、卖方委托编号、成交价格、成交数量、成交方向(主动买/主动卖/未知)。逐笔委托描述“新进入撮合队列的指令”,典型字段包括:委托时间、证券代码、委托编号、委托价格、委托数量、买卖方向、订单类型(限价/市价/本方最优等)。

这里有一个容易被忽略的细节:逐笔成交中的买方/卖方委托编号,是连接成交与委托的桥梁。有了它,才能把一笔成交归因到具体的挂单,进而判断这笔成交是“吃掉已有挂单”还是“新单立即成交”。如果解析时丢弃了委托编号,后续就无法重建订单生命周期,事件流的信息量会大幅缩水。

另一个关键约定是时间戳的参照系。交易所逐笔数据的时间通常只到秒或毫秒,且同一时间戳内可能有多条消息。解析时必须保持原始文件中的行序作为同时间戳内的次序依据,不能自行排序,否则会破坏真实的到达顺序。这一点在回测中直接影响成交判定的公平性。

四类事件的设计

把原始记录映射为事件,核心是定义一套稳定的事件类型。对本框架而言,最小可用集合是四类:

  • 订单到达事件(OrderArrival):由逐笔委托记录生成,携带委托编号、方向、价格、数量、订单类型、时间戳。
  • 成交事件(Trade):由逐笔成交记录生成,携带成交编号、买/卖委托编号、价格、数量、主动方向、时间戳。
  • 撤单事件(Cancel):部分行情源以独立撤单记录给出,或以“委托数量减少/状态变更”体现;需统一为携带委托编号与撤单量的显式事件。
  • 快照更新事件(Snapshot):由周期性盘口快照生成,携带十档价格与数量,用于校验订单簿重建结果或作为兜底状态。

四类事件共享一个基类字段:event_id(自增序号)、ts(统一为整数毫秒)、symbol、type。这样引擎侧只需按 ts 升序、同 ts 按 event_id 升序消费,就能保证确定性回放。

原始逐笔数据 → 事件流转换结构 逐笔成交文件 成交编号 / 买卖委托编号 价格 / 数量 / 主动方向 时间戳(毫秒) CSV / 二进制 逐笔委托文件 委托编号 / 买卖方向 价格 / 数量 / 订单类型 时间戳(毫秒) CSV / 二进制 盘口快照文件 十档价格 / 数量 解析器 字段映射 时间戳归一化 行序保持 事件类型判定 委托编号索引 撤单识别 事件编号分配 同时间戳排序 异常记录标记 统一事件流 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 按原始行序分配,这次排序实际上等价于“保持原始顺序、仅按时间归并”,不会打乱同时间戳内的真实到达次序。排序后的列表即可直接交给回测引擎逐条消费。

一个常见的验证方法是:用解析出的事件流重建订单簿,在若干快照时间点上与原始快照文件比对十档价格与数量。若某档数量偏差持续存在,通常指向三类问题——委托编号未正确关联、撤单未被识别、或同时间戳内成交与委托的处理顺序有误。把这三类问题逐一排除,事件流的正确性就有了可检验的依据,后续的撮合与绩效计算才站得住脚。