4. 回测时钟与事件驱动引擎设计
限价单超时改价规则的回测,本质上不是“按K线逐根跑一遍”的向量化计算,而是对订单生命周期中一系列离散事件的精确复现。一笔限价单从提交、挂单、部分成交、超时判定、撤单改价到最终成交或失效,每一步都由某个事件触发,而超时判定又依赖一个独立推进的时钟。因此本节的核心任务是:设计一个以事件队列为骨架、以定时器为超时来源、以订单状态机为约束的回测引擎。
为什么不能用K线循环代替事件循环
假设规则是“限价单挂出后 30 秒未成交则撤单并按最新盘口重挂”。如果回测以 1 分钟K线为步长,那么 30 秒的超时点在K线内部被完全抹平:你无法知道订单是在第 12 秒成交,还是挂满 60 秒才被撤。更严重的是,改价动作本身会改变后续的挂单价格与成交概率,K线循环无法在K线内部插入“撤单—重挂”这一中间状态。事件驱动引擎的价值就在于:它允许在任意时间戳上插入订单事件与定时器事件,并按时间顺序严格处理,使超时计时与改价调度具备秒级甚至毫秒级的保真度。
三类事件与统一时间轴
引擎中流动的事件可以归为三类。第一类是市场事件:行情快照更新、逐笔成交、盘口变化,它们来自历史数据回放,是订单能否成交的外部条件。第二类是订单事件:下单、撤单、改价请求,以及由撮合逻辑产生的成交回报、部分成交、拒单。第三类是定时器事件:订单挂出时注册的超时到期、改价后的重试间隔到期等。三类事件统一放入一个按时间戳排序的优先队列,引擎主循环每次取出时间戳最小的事件处理,并把时钟推进到该事件的时间戳。
这里必须先明确时间参照约定:本文所有时间戳均为交易所撮合时钟下的毫秒值,事件队列按时间戳升序出队;同一时间戳下,市场事件优先于定时器事件,定时器事件优先于订单动作事件,以保证“先看到行情、再判定超时、最后执行改价”的因果顺序不被颠倒。
超时计时:注册、触发与惰性删除
超时计时的实现要点在于“注册时记录起点、触发时校验状态”。当订单从 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 处理记录,说明惰性删除校验缺失,此时统计出的“改价次数”会被高估,进而扭曲对规则成本的评估。只有事件序列自洽,后续的成交率、改价频次、滑点等指标才具备解释力。