3. 事件驱动回测引擎核心架构
逐笔盘口回测与传统的“按K线批量计算”有本质区别:行情是逐笔到达的,策略在每一笔行情后可能产生委托,委托又要经过撮合模块判定成交,成交回报再反过来影响策略的持仓与后续决策。如果把这些环节写成一条直线式的函数调用链,策略与撮合就会深度耦合,任何一方改动都会牵动另一方。事件驱动架构的核心思路,是把“发生了什么”抽象为事件,让策略、撮合、风控、账务等模块只与事件队列打交道,由事件循环统一调度,从而实现解耦与协同。
一、四类核心事件与队列结构
在逐笔盘口场景中,事件至少应覆盖四类:行情事件(逐笔成交、逐笔委托、盘口快照更新)、策略信号事件(策略输出的下单/撤单意图)、委托事件(订单被撮合模块接收、部分成交、全部成交、撤单确认)、时钟事件(时间推进到下一个待处理时刻)。事件队列通常采用按时间戳排序的优先队列(最小堆),同一时间戳内再按事件优先级排序,一般约定:行情事件先于策略信号,策略信号先于撮合回报,以保证“先看到行情、再决策、再成交”的因果顺序不被破坏。
一个可直接落地的事件结构示例如下(Python 伪代码,字段为教学示例):
from dataclasses import dataclass, field
from enum import IntEnum
class EventType(IntEnum):
MARKET = 0 # 逐笔行情
SIGNAL = 1 # 策略信号
ORDER = 2 # 委托/成交回报
CLOCK = 3 # 时钟推进
@dataclass(order=True)
class Event:
ts: int # 事件时间戳(纳秒或毫秒,全系统统一)
priority: int # 同时间戳内的处理优先级
etype: EventType = field(compare=False)
payload: dict = field(compare=False, default_factory=dict)
注意 order=True 让事件天然按 (ts, priority) 排序,这正是优先队列所需的比较键。时间戳单位必须在全系统统一,否则跨模块比较会出错。
二、事件循环:引擎的心跳
事件循环是一个“取事件—分发—再取事件”的循环体,直到队列为空或到达回测结束时间。它的关键职责不是业务逻辑,而是保证处理顺序与驱动时钟。典型实现如下:
class EventEngine:
def __init__(self, queue, clock, handlers):
self.queue = queue # 优先队列
self.clock = clock # 逻辑时钟
self.handlers = handlers # {EventType: [handler, ...]}
def run(self, end_ts):
while not self.queue.empty():
evt = self.queue.get()
if evt.ts > end_ts:
break
self.clock.advance_to(evt.ts) # 时钟跟随事件推进
for h in self.handlers[evt.etype]:
h(evt) # 分发,可能产生新事件入队
这里有一个容易被忽视的细节:时钟不是独立线程在“走”,而是被事件时间戳“推着走”。当循环取出一个时间戳为 T 的事件时,逻辑时钟才推进到 T。这样做的直接好处是:回测中不存在“真实等待”,一秒可以跑完数年的逐笔数据;同时所有模块看到的时间严格一致,不会出现策略用未来时间、撮合用过去时间的错位。
三、时钟推进机制与因果一致性
时钟推进有两种常见策略。事件驱动推进:时钟只在有事件时跳变,适合稀疏的逐笔数据,效率最高。固定步长推进:按固定间隔(如每 100ms)插入时钟事件,适合需要定时检查(如超时撤单、定时再平衡)的策略。实践中常将两者结合:主循环按事件推进,同时预埋周期性时钟事件,用于触发“无行情时的定时逻辑”。
因果一致性是回测可信度的底线。假设某笔行情时间戳为 10:00:00.500,策略据此发出委托,撮合模块必须用不早于该时刻的后续行情来判定成交,绝不能回头用 10:00:00.400 的盘口成交。事件队列的排序规则天然保证了这一点:新产生的委托事件时间戳 ≥ 当前事件时间戳,因此它一定排在所有更早的行情之后被处理。
四、策略与撮合的解耦协同
解耦的落地方式是共享队列 + 各自注册处理器。策略模块订阅行情事件,产出信号事件;撮合模块订阅信号事件,产出委托回报事件;账务模块订阅委托回报,更新持仓与资金。模块之间不直接持有对方引用,只通过事件类型约定通信。这样替换撮合规则(如从“对手价成交”改为“排队撮合”)时,策略代码完全不用改。
协同的关键在于状态可见性。策略需要知道“我的委托现在是什么状态”,这通过委托回报事件回传,而不是策略自己去查撮合内部变量。下面这张图展示了本节的核心结构:事件从行情源进入优先队列,经事件循环分发到各模块,模块产生的新事件再回到队列,时钟随事件时间戳推进。
五、一个可观察的协同示例
假设回测区间为 09:30:00.000 至 09:30:01.000,队列中依次有:行情事件 M1(.100,卖一价 10.02)、M2(.300,卖一价 10.01)、M3(.600,卖一价 10.00)。策略在 M1 后判断“价格触及阈值”,发出买入信号 S1(.100,限价 10.02)。撮合模块消费 S1 时,只能使用时间戳 ≥ .100 的行情判定成交:M2 的卖一价 10.01 ≤ 10.02,可成交;若策略限价为 10.00,则需等到 M3 才成交。整个过程中,时钟依次推进到 .100、.300、.600,账务模块在收到成交回报后更新持仓。运行结束后,可通过“事件计数 + 各模块处理耗时 + 成交明细”三项观察引擎是否正常:事件计数应等于入队总数,成交时间戳不得早于信号时间戳,持仓变化应与成交明细逐笔对得上。
把以上结构落地后,策略与撮合各自独立演进:策略只关心行情与回报,撮合只关心信号与盘口,事件循环与时钟则作为公共基础设施稳定不变。这正是逐笔盘口回测引擎能够既贴近真实交易时序、又保持代码可维护性的根本原因。