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 的盘口成交。事件队列的排序规则天然保证了这一点:新产生的委托事件时间戳 ≥ 当前事件时间戳,因此它一定排在所有更早的行情之后被处理。

常见错误:策略在行情回调中直接调用撮合函数“立即成交”,绕过了事件队列。这会让成交发生在行情处理的中途,破坏“行情→信号→撮合”的顺序,导致回测结果偏乐观。正确做法是策略只把信号事件入队,由撮合模块在后续循环中消费。

四、策略与撮合的解耦协同

解耦的落地方式是共享队列 + 各自注册处理器。策略模块订阅行情事件,产出信号事件;撮合模块订阅信号事件,产出委托回报事件;账务模块订阅委托回报,更新持仓与资金。模块之间不直接持有对方引用,只通过事件类型约定通信。这样替换撮合规则(如从“对手价成交”改为“排队撮合”)时,策略代码完全不用改。

协同的关键在于状态可见性。策略需要知道“我的委托现在是什么状态”,这通过委托回报事件回传,而不是策略自己去查撮合内部变量。下面这张图展示了本节的核心结构:事件从行情源进入优先队列,经事件循环分发到各模块,模块产生的新事件再回到队列,时钟随事件时间戳推进。

逐笔行情源 成交/委托/盘口 事件优先队列 按 (时间戳, 优先级) 排序 MARKET → SIGNAL → ORDER 最小堆 / 有序容器 事件循环 取事件 → 分发 → 新事件入队 循环至队列空/结束 逻辑时钟 advance_to(ts) 随事件时间戳 单调推进 策略模块 订阅 MARKET 产出 SIGNAL 撮合模块 订阅 SIGNAL 产出 ORDER 回报 账务/风控 订阅 ORDER 更新持仓与资金 SIGNAL ORDER 新事件回流入队(时间戳 ≥ 当前) 解耦要点:模块之间不直接调用,只通过事件类型约定通信;时钟由事件时间戳驱动,保证因果顺序 同时间戳优先级:MARKET(0) → SIGNAL(1) → ORDER(2) → CLOCK(3)

五、一个可观察的协同示例

假设回测区间为 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,账务模块在收到成交回报后更新持仓。运行结束后,可通过“事件计数 + 各模块处理耗时 + 成交明细”三项观察引擎是否正常:事件计数应等于入队总数,成交时间戳不得早于信号时间戳,持仓变化应与成交明细逐笔对得上。

工程建议:为事件循环加一个可选的“事件轨迹日志”,记录每个事件的类型、时间戳、来源模块与处理结果。回测结果异常时,按时间戳回放轨迹,往往能快速定位是排序错误、时钟跳变还是模块漏订阅。

把以上结构落地后,策略与撮合各自独立演进:策略只关心行情与回报,撮合只关心信号与盘口,事件循环与时钟则作为公共基础设施稳定不变。这正是逐笔盘口回测引擎能够既贴近真实交易时序、又保持代码可维护性的根本原因。