4. 策略事件接口与回调机制

在逐笔盘口事件驱动回测框架中,策略并不是主动去“拉取”行情,而是被动地等待框架把事件推送到它面前。这种“框架驱动、策略响应”的设计,核心就是策略基类与事件回调接口。理解这一层,才能明白为什么同一份策略代码既能在回测中运行,也能在实盘或仿真中复用——因为策略只关心“发生了什么事件”,而不关心事件从哪里来。

策略基类:定义回调契约

策略基类的作用是规定一套回调契约。框架在事件循环中遇到某类事件时,会调用策略对象上对应的方法。最典型的四类回调是:on_tick 处理逐笔行情(成交、委托、快照更新),on_order 处理订单状态变化(已报、部成、全成、已撤、废单),on_trade 处理成交回报,on_timer 处理定时事件(如每 500ms 检查一次挂单、收盘前平仓)。基类通常提供空实现,子类按需覆盖,避免每个策略都被迫实现全部方法。

class StrategyBase:
    def __init__(self, ctx):
        self.ctx = ctx          # 上下文:下单、撤单、查询持仓
        self.symbols = []       # 订阅标的

    def on_start(self):  pass
    def on_tick(self, tick):  pass
    def on_order(self, order): pass
    def on_trade(self, trade): pass
    def on_timer(self, now):   pass
    def on_stop(self):   pass

这里的 ctx 是策略与框架之间的唯一通道。策略不直接持有撮合引擎或行情源,而是通过 ctx.buy()、ctx.cancel()、ctx.position() 等接口发出意图,由框架负责路由。这种隔离让策略逻辑保持纯粹,也便于在回测与实盘之间切换实现。

事件分发:从事件队列到回调

框架内部维护一个按时间戳排序的事件队列。事件循环每次取出最早的事件,判断其类型,再分发给所有已注册且订阅了相关标的的策略。分发时要注意两点:一是事件顺序的确定性,同一时间戳下必须约定固定优先级(通常为:行情 → 订单回报 → 成交回报 → 定时器),否则回测结果不可复现;二是订阅过滤,策略只应收到自己订阅标的的事件,避免无关事件干扰逻辑。

行情源 逐笔成交 / 委托 撮合引擎 订单 / 成交回报 定时器 周期 / 收盘触发 事件队列 按时间戳升序 t1 行情 t1 订单回报 t2 成交回报 t3 定时器 事件分发器 类型判定 订阅过滤 顺序保证 策略 A 订阅 600000 策略 B 订阅 000001 回调接口 on_tick on_order on_trade on_timer 同一时间戳的固定分发优先级 ① 行情事件 → ② 订单回报 → ③ 成交回报 → ④ 定时器 策略只收到自己订阅标的的事件;未覆盖的回调由基类空实现兜底。 分发顺序一旦确定,回测结果才可复现,实盘与回测行为才一致。

策略注册与生命周期

策略注册发生在回测启动之前。框架通常提供 add_strategy(strategy, symbols) 这样的入口,把策略实例与其订阅标的登记到分发器的路由表中。注册完成后,框架依次调用 on_start,进入事件循环,最后在队列耗尽时调用 on_stop。一个常见误区是把初始化逻辑写在 __init__ 里——此时上下文尚未就绪,查询持仓或订阅行情可能拿到空值。正确做法是把依赖上下文的初始化放到 on_start。

多策略共存时,路由表以标的为键、策略列表为值。若策略 A 与策略 B 都订阅了 600000,则 600000 的每笔 tick 会依次分发给两者。这里要特别注意策略间的状态隔离:每个策略应持有独立的持仓视图与订单簿缓存,不能共享可变对象,否则一个策略的撤单会污染另一个策略的挂单记录。

一个可运行的回调示例

下面这段示例演示一个简单的盘口挂单策略:当买一量突增时挂出限价买单,并在定时器中检查超时撤单。参数为教学假设值,非实测结果。

class QueueProbe(StrategyBase):
    def on_start(self):
        self.last_bid1_vol = 0
        self.pending = {}          # order_id -> 下单时间
        self.timeout_ms = 3000

    def on_tick(self, tick):
        if tick.bid1_vol > self.last_bid1_vol * 1.5 and tick.bid1_vol > 500:
            oid = self.ctx.buy(tick.symbol, tick.bid1, 100)
            self.pending[oid] = tick.ts
        self.last_bid1_vol = tick.bid1_vol

    def on_order(self, order):
        if order.status in ("FILLED", "CANCELLED", "REJECTED"):
            self.pending.pop(order.order_id, None)

    def on_timer(self, now):
        for oid, ts in list(self.pending.items()):
            if now - ts > self.timeout_ms:
                self.ctx.cancel(oid)

这段代码体现了回调机制的几个要点。on_tick 中只做判断与下单,不阻塞等待;订单的最终状态由 on_order 异步告知,因此 pending 字典的清理必须放在 on_order 而非下单之后立即执行。on_timer 负责超时管理,它不依赖行情到达,即使盘口静止也能触发撤单。若把超时检查写进 on_tick,在无成交的冷清时段就会失效——这是事件驱动框架中很典型的陷阱。

回调中的常见错误与排查

第一类是在回调中做重计算。逐笔行情频率高,若在 on_tick 里做全量指标重算,回测会慢到无法接受。应把增量更新与周期计算分离,重计算交给 on_timer。第二类是回调重入:在 on_order 中又调用下单接口,若框架未做保护,可能在同一事件处理链中递归触发新的回报。稳妥做法是把“回报触发的后续动作”放入待处理队列,在本轮回调结束后统一执行。第三类是时间基准不一致:on_tick 的 ts 是行情时间,on_timer 的 now 是框架时钟,两者必须来自同一时间轴,否则超时判断会漂移。

排查回调问题时,可在分发器中加一行日志,打印“事件类型 + 标的 + 时间戳 + 命中的策略名”。多数“策略没反应”的问题,都能从这行日志看出是事件没进来、订阅没匹配,还是回调方法名拼写错误。

把策略基类、事件分发与注册生命周期这三件事理清后,策略代码就变成了一组纯粹的回调函数集合。它不关心数据从文件读还是从网络来,也不关心撮合是模拟还是真实,只对“事件到达”作出响应。这正是事件驱动回测框架能够支撑逐笔盘口级策略复用的根本原因。