4. 回测时钟与事件驱动引擎设计

限价单超时改价规则的回测,本质上不是“按K线逐根跑一遍”的向量化计算,而是对订单生命周期中一系列离散事件的精确复现。一笔限价单从提交、挂单、部分成交、超时判定、撤单改价到最终成交或失效,每一步都由某个事件触发,而超时判定又依赖一个独立推进的时钟。因此本节的核心任务是:设计一个以事件队列为骨架、以定时器为超时来源、以订单状态机为约束的回测引擎。

为什么不能用K线循环代替事件循环

假设规则是“限价单挂出后 30 秒未成交则撤单并按最新盘口重挂”。如果回测以 1 分钟K线为步长,那么 30 秒的超时点在K线内部被完全抹平:你无法知道订单是在第 12 秒成交,还是挂满 60 秒才被撤。更严重的是,改价动作本身会改变后续的挂单价格与成交概率,K线循环无法在K线内部插入“撤单—重挂”这一中间状态。事件驱动引擎的价值就在于:它允许在任意时间戳上插入订单事件与定时器事件,并按时间顺序严格处理,使超时计时与改价调度具备秒级甚至毫秒级的保真度。

三类事件与统一时间轴

引擎中流动的事件可以归为三类。第一类是市场事件:行情快照更新、逐笔成交、盘口变化,它们来自历史数据回放,是订单能否成交的外部条件。第二类是订单事件:下单、撤单、改价请求,以及由撮合逻辑产生的成交回报、部分成交、拒单。第三类是定时器事件:订单挂出时注册的超时到期、改价后的重试间隔到期等。三类事件统一放入一个按时间戳排序的优先队列,引擎主循环每次取出时间戳最小的事件处理,并把时钟推进到该事件的时间戳。

这里必须先明确时间参照约定:本文所有时间戳均为交易所撮合时钟下的毫秒值,事件队列按时间戳升序出队;同一时间戳下,市场事件优先于定时器事件,定时器事件优先于订单动作事件,以保证“先看到行情、再判定超时、最后执行改价”的因果顺序不被颠倒。

事件驱动回测引擎:事件队列与订单状态机 行情回放器 快照 / 逐笔成交 策略下单接口 下单 / 撤单 / 改价 定时器注册表 超时 / 重试间隔 优先事件队列 按时间戳升序 同刻:行情>定时器>订单 t=1000 行情快照 t=1030 超时到期 回测时钟 clock 推进至当前事件时间戳 引擎主循环 出队 → 分发 → 处理 → 回写 → 注册新定时器 订单状态机 PENDING 待挂 ACTIVE 挂单中 PARTIAL 部分成交 REPRICE 改价中 FILLED / CANCELLED 关键约束 ① 超时计时以订单进入 ACTIVE 的时间戳为起点,改价后重新计时,不得沿用旧起点。 ② 定时器到期事件必须校验订单当前状态,若已成交或已撤单则丢弃该事件(惰性删除)。 ③ 改价动作产生新的订单事件,须重新入队,禁止在处理函数内直接递归调用撮合。

超时计时:注册、触发与惰性删除

超时计时的实现要点在于“注册时记录起点、触发时校验状态”。当订单从 PENDING 进入 ACTIVE(即真正挂到盘口)时,引擎向定时器注册表写入一条记录:(order_id, expire_at = active_ts + timeout_ms, version)。其中 version 是订单的改价轮次,每次改价后 version 加一,旧定时器即使到期也因 version 不匹配而被丢弃。这种惰性删除策略避免了在改价时遍历并删除旧定时器的开销,也避免了并发场景下的竞态问题。

定时器到期事件被取出后,处理函数先查订单当前状态:若为 FILLED 或 CANCELLED,直接忽略;若为 ACTIVE 且 version 与定时器记录一致,则触发超时逻辑——撤单、按最新盘口计算新价格、发出改价订单事件。这里有一个容易被忽略的细节:撤单本身也需要时间,在真实撮合中撤单请求到撤单确认之间存在延迟。回测中若简化为瞬时撤单,需在文档中明确标注该假设;若建模延迟,则应把“撤单确认”作为独立事件入队,改价动作在确认之后才发出。

订单状态机的转移约束

状态机的作用是拒绝非法转移,防止回测逻辑在异常路径上产生虚假成交。以超时改价规则为例,合法转移包括:PENDING→ACTIVE(挂单成功)、ACTIVE→PARTIAL(部分成交)、ACTIVE→REPRICE(超时撤单改价)、PARTIAL→REPRICE(剩余量超时改价)、REPRICE→ACTIVE(新单挂出)、ACTIVE/PARTIAL→FILLED(全部成交)、ACTIVE/PARTIAL→CANCELLED(主动撤单或规则终止)。任何未列出的转移,例如 FILLED→REPRICE,都应抛出断言并记录到回测日志,而不是静默跳过——静默跳过会让统计结果悄悄失真。

一个可运行的最小实现骨架

下面给出引擎主循环与超时处理的伪代码,参数为示例假设值,用于说明结构而非实测结果:

timeout_ms = 30000      # 示例:超时阈值 30 秒
reprice_ticks = 1       # 示例:改价时向盘口内侧偏移 1 个最小变动价位

def run(events):
    clock = 0
    while events:
        ev = events.pop_min()          # 按 (ts, priority) 排序
        clock = ev.ts                  # 时钟推进
        if ev.type == "TIMEOUT":
            o = orders[ev.order_id]
            if o.state not in ("ACTIVE", "PARTIAL"): continue
            if o.version != ev.version: continue
            cancel(o)                  # 撤单,产生撤单事件
            new_px = best_bid() + reprice_ticks * tick   # 示例改价逻辑
            events.push(OrderEvent(clock, "REPRICE", o.id, new_px))
        elif ev.type == "REPRICE":
            o = orders[ev.order_id]
            assert o.state in ("ACTIVE", "PARTIAL")
            o.version += 1
            o.state = "REPRICE"
            events.push(OrderEvent(clock, "PLACE", o.id, ev.price))
        elif ev.type == "PLACE":
            o = orders[ev.order_id]
            o.state = "ACTIVE"
            o.active_ts = clock
            events.push(TimerEvent(clock + timeout_ms, "TIMEOUT", o.id, o.version))
        elif ev.type == "FILL":
            apply_fill(ev)             # 更新持仓、现金、成交记录

这段骨架体现了三个设计决策:时钟只由事件时间戳推进,不自行步进;改价通过“撤单事件 + 新下单事件”两步完成,而非原地修改价格;每次进入 ACTIVE 都重新注册定时器并携带 version。读者可在此基础上补充撮合判定:当行情快照的买一价不低于卖单限价时触发成交事件,并按盘口数量决定全部成交还是部分成交。

结果观察与正确性校验

引擎跑通后,不能只看最终收益曲线,而应先校验事件序列本身。建议输出一份订单事件流水,字段包括:订单号、事件类型、时间戳、状态迁移前后、价格、数量、version。检查要点有三:同一订单的时间戳是否严格非降;每次 REPRICE 前是否确有对应的 TIMEOUT 且 version 匹配;是否存在状态机未定义的转移。若流水显示某订单在 FILLED 之后仍出现 TIMEOUT 处理记录,说明惰性删除校验缺失,此时统计出的“改价次数”会被高估,进而扭曲对规则成本的评估。只有事件序列自洽,后续的成交率、改价频次、滑点等指标才具备解释力。