3. 事件驱动回测原理

事件驱动回测原理 学习卡片

在向量化回测中,我们通常把整段历史数据当作矩阵一次性计算;而事件驱动回测则像一台按时间推进的模拟器:每来一根K线、每产生一个订单、每完成一次成交,都会触发相应的事件,由引擎分发给关心它的组件。理解这套机制,是写出可复现、可扩展、贴近实盘回测的关键。

一、核心原理:事件循环是回测的心跳

Backtrader 的引擎(Cerebro)本质上是一个事件循环。它维护一条按时间排序的数据流,并在每个时间步上依次完成“推进数据 → 计算指标 → 调用策略 → 处理订单 → 更新经纪商”的闭环。这个闭环不断重复,直到数据耗尽。

事件循环中有四类核心角色:

  • 数据源(Data Feed):按时间顺序提供 OHLCV 等行情,是事件的起点。
  • 策略(Strategy):在 next() 中读取当前数据与指标,产生买卖信号。
  • 经纪商(Broker):接收订单、撮合成交、计算现金与持仓。
  • 观察器/分析器(Observer/Analyzer):记录权益曲线、回撤、交易统计等。

关键点在于:策略不会直接修改持仓,它只提交订单;订单是否成交、以什么价格成交,由经纪商根据当前行情和撮合规则决定。这种“策略与执行分离”的设计,正是事件驱动回测贴近真实交易的地方。

一句话理解:事件循环负责“什么时候发生”,数据流负责“看到什么”,策略负责“想做什么”,经纪商负责“实际做了什么”。

二、方法流程:一个时间步里发生了什么

下面用伪代码描述 Backtrader 在一个 bar 上的典型执行顺序。注意顺序非常重要,它决定了策略看到的是“当前bar收盘”还是“下一bar开盘”。

for each bar in data_feed:
    # 1. 数据源推进到当前时间
    data.advance()

    # 2. 计算指标(基于当前及历史bar)
    indicators.compute()

    # 3. 调用策略的 next()
    strategy.next()
    # 策略中可调用 self.buy()/self.sell() 提交订单

    # 4. 经纪商处理订单
    broker.process_orders()
    # 根据撮合规则决定是否成交、成交价、手续费

    # 5. 更新经纪商状态
    broker.update_cash_and_positions()

    # 6. 观察器/分析器记录快照
    observers.record()
    analyzers.update()

这个顺序解释了为什么在 next() 中下单,默认会在下一根bar成交:当前bar的行情已经用于计算指标和策略判断,订单只能等待下一个时间步的行情来撮合。若希望以当前bar收盘价成交,需要显式设置 cheat_on_close 或使用市价单配合相应配置。

三、订单生命周期:从提交到成交的完整链路

订单是策略与经纪商之间的契约。理解订单状态流转,能帮你排查“为什么没成交”“为什么成交价不对”等问题。

状态 含义 触发条件
Submitted 订单已提交给经纪商 策略调用 buy/sell
Accepted 经纪商接受订单 通过基本校验
Partial 部分成交 成交量不足或限价单部分撮合
Completed 全部成交 撮合完成
Canceled 已取消 策略撤单或超时
Rejected 被拒绝 资金不足、标的不合法等
Expired 已过期 有效期结束未成交

在策略中,可以通过 notify_order() 监听订单状态变化。下面是一个典型用法:

def notify_order(self, order):
    if order.status in [order.Submitted, order.Accepted]:
        return
    if order.status == order.Completed:
        self.log(f"成交: 价格={order.executed.price}, 数量={order.executed.size}")
    elif order.status in [order.Canceled, order.Rejected, order.Expired]:
        self.log(f"订单未成交: {order.getstatusname()}")
实战建议:在 notify_order() 中只做状态记录和轻量逻辑,不要在这里直接下新单,否则容易造成递归下单或状态混乱。新单应在 next() 中根据持仓和信号统一决策。

四、案例场景:多资产数据流与策略交互

假设我们同时回测股票与ETF两个标的。Backtrader 会为每个数据源维护独立的时间线,但事件循环会按时间对齐推进。策略中可以通过 self.datas[0]、self.datas[1] 访问不同标的。

class MultiAssetStrategy(bt.Strategy):
    def __init__(self):
        self.sma0 = bt.indicators.SMA(self.datas[0].close, period=20)
        self.sma1 = bt.indicators.SMA(self.datas[1].close, period=20)

    def next(self):
        # 标的0:金叉买入
        if not self.getposition(self.datas[0]):
            if self.datas[0].close[0] > self.sma0[0]:
                self.buy(data=self.datas[0], size=100)

        # 标的1:死叉卖出
        if self.getposition(self.datas[1]):
            if self.datas[1].close[0] < self.sma1[0]:
                self.sell(data=self.datas[1], size=100)

这里的关键是:每个 next() 调用时,两个数据源都已经推进到同一时间点。如果某个标的当天停牌或缺失数据,Backtrader 会按配置进行对齐或跳过,策略需要处理 len(data) 不一致的情况。

常见误区:在多资产回测中,直接使用 self.data.close[0] 而不指定具体数据源,容易在多个数据源时产生歧义。建议始终显式写出 self.datas[i] 或为数据源命名。

五、复盘与误区:事件驱动回测的典型陷阱

  • 未来函数:在 next() 中使用 close[0] 判断信号,却期望以同一根bar的收盘价成交。默认情况下这是不可能的,必须理解撮合时点。
  • 订单堆积:未检查 self.order 或持仓状态,导致每根bar重复下单,最终仓位远超预期。
  • 忽略手续费与滑点:事件驱动回测的优势之一就是能精确模拟成本,若配置为0,回测结果会过于乐观。
  • 数据对齐错误:多资产时间线未对齐时,策略可能看到“未来”数据,导致虚高收益。
  • 混淆 notify_order 与 next:订单状态变化是异步事件,不应假设它一定在下一根bar的 next() 之前发生。

理解事件循环、数据流、策略与经纪商交互以及订单生命周期,本质上是在建立一种“时间切片”的思维方式:每个时间步只做当前允许做的事,把执行结果交给经纪商,再在下一个时间步根据新状态决策。掌握这一点,后续的多资产、多策略、多周期回测都会变得有章可循。