4、设计回测引擎(一):事件驱动架构设计、回测引擎的模块划分

好,咱们今天正式进入回测引擎的核心设计环节。

说实话,我见过不少量化新手,一上来就急着写策略、调参数。结果呢?回测跑出来的曲线漂亮得不行,一上实盘就崩。问题出在哪?多半是回测引擎本身有漏洞。你想想看,如果地基没打牢,上面盖再高的楼也是白搭。

我个人习惯,在设计任何系统之前,先想清楚两件事:数据怎么流动,以及模块怎么划分。今天我们就围绕这两点展开。

4.1 事件驱动架构:回测的“心跳”

回测引擎的核心,说白了就是一个循环——模拟市场的时间流逝,然后在每个时间点上处理各种“事件”。

什么是事件?简单来说,就是“发生了什么事”。比如:

  • 一根新的K线到了(市场数据事件)
  • 策略发出了买入信号(信号事件)
  • 订单被交易所成交了(成交事件)

事件驱动架构,就是让这些事件像多米诺骨牌一样,一个触发另一个,推动整个回测流程往前走。

核心思想:回测引擎不主动“轮询”状态,而是被动响应事件。这样做的好处是——逻辑清晰、扩展性强、容易模拟真实交易环境。

我在项目中遇到过一个问题:有人把回测写成了一个大循环,里面塞满了if-else判断。结果想加一个新功能(比如模拟滑点),得改好几处代码。改用事件驱动后,每个模块只关心自己感兴趣的事件,耦合度大大降低。

事件驱动的工作流程

我习惯把它画成一张图,你一看就明白:

事件驱动回测架构流程图 数据模块 推送市场数据事件 策略模块 生成信号事件 风控模块 过滤/校验信号 执行模块 生成订单事件 事件队列(Event Queue) 按时间顺序排列所有待处理事件 市场数据 信号 通过校验 订单事件 循环处理

嗯,这里要注意:事件队列是整个架构的“调度中心”。所有模块都不直接调用对方,而是把事件丢进队列里。引擎从队列头部取出事件,分发给对应的模块处理。这样做的好处是——你可以随时往队列里插入新事件,比如模拟延迟成交、模拟滑点,都不需要改核心逻辑。

4.2 模块划分:各司其职

回测引擎我一般拆成四个核心模块。每个模块只做一件事,但要做到极致。

模块名称 核心职责 我踩过的坑
数据模块 加载历史数据、推送市场事件、处理数据对齐 我曾经在数据对齐上吃过亏——不同时间频率的数据混在一起,回测结果完全失真。后来强制要求所有数据先统一到同一时间戳基准上。
策略模块 接收市场数据、计算信号、生成交易指令 策略模块最容易犯的错是“未来函数”。比如用当天的收盘价计算当天的信号,这在实盘里根本做不到。我后来加了一个数据延迟检查器。
风控模块 校验信号合法性、限制仓位、控制杠杆 很多人觉得风控是“锦上添花”,其实它是“保命符”。我见过一个策略回测年化50%,加了风控后只剩15%,但实盘反而赚钱了——因为风控拦住了几次爆仓风险。
执行模块 模拟订单成交、计算滑点、管理订单状态 执行模块最容易被低估。你以为市价单一定能成交?在流动性差的品种上,滑点能吃掉你所有利润。我习惯在回测里加一个“滑点模拟器”。

我的建议:这四个模块之间,通过事件传递数据,不要共享全局变量。每个模块都应该是“无状态”的——给它一个输入,它返回一个输出,不依赖外部环境。这样后期调试、扩展、甚至并行化都会轻松很多。

4.3 核心类设计:BacktestEngine, Strategy, Portfolio

模块划分清楚了,接下来就是落地成代码。我一般会设计三个核心类,它们构成了回测引擎的骨架。

4.3.1 BacktestEngine:总指挥

这个类负责整个回测的生命周期管理。说白了,它就是那个“事件循环”的拥有者。

class BacktestEngine:
    def __init__(self, data_module, strategy_module, 
                 risk_module, execution_module):
        self.data = data_module
        self.strategy = strategy_module
        self.risk = risk_module
        self.execution = execution_module
        self.event_queue = []
        self.current_time = None
    
    def run(self, start_date, end_date):
        """启动回测"""
        # 1. 初始化所有模块
        self.data.load_data(start_date, end_date)
        # 2. 推送初始事件
        self.event_queue.extend(self.data.get_initial_events())
        # 3. 事件循环
        while self.event_queue:
            event = self.event_queue.pop(0)
            self.current_time = event.timestamp
            self._process_event(event)
    
    def _process_event(self, event):
        """处理单个事件"""
        if event.type == 'MARKET_DATA':
            signals = self.strategy.on_data(event)
            for sig in signals:
                if self.risk.check(sig):
                    order = self.execution.execute(sig)
                    self.event_queue.append(order)
        elif event.type == 'ORDER_FILL':
            # 更新持仓、记录交易
            pass

你看,核心逻辑其实就这么几行。事件循环不断从队列里取事件,根据事件类型分发给对应模块。我刚开始写的时候,总想把所有逻辑塞进一个类里,结果代码越写越乱。后来狠心拆开,清爽多了。

4.3.2 Strategy:策略的“大脑”

策略类是所有用户自定义策略的基类。用户只需要继承它,实现两个方法:on_dataon_order

class Strategy:
    def __init__(self, name):
        self.name = name
        self.positions = {}  # 当前持仓
        self.capital = 1000000  # 初始资金
    
    def on_data(self, event):
        """
        处理市场数据事件
        返回:信号列表 [Signal, ...]
        """
        raise NotImplementedError("子类必须实现此方法")
    
    def on_order(self, event):
        """处理订单成交事件"""
        pass
    
    def calculate_signal(self, symbol, direction, quantity):
        """生成信号"""
        return Signal(symbol, direction, quantity, 
                     timestamp=self.current_time)

注意:策略类里不要直接操作账户资金或持仓。它只负责“生成信号”,至于这个信号能不能执行、怎么执行,那是风控和执行模块的事。我曾经见过有人直接在策略里扣钱,结果回测和实盘对不上账。

4.3.3 Portfolio:账户的“管家”

Portfolio 类管理资金、持仓、交易记录。它不参与策略决策,只负责记账。

class Portfolio:
    def __init__(self, initial_capital=1000000):
        self.capital = initial_capital
        self.positions = {}  # {symbol: quantity}
        self.trades = []     # 交易记录
        self.equity_curve = []  # 权益曲线
    
    def update_on_fill(self, fill_event):
        """成交后更新持仓和资金"""
        symbol = fill_event.symbol
        quantity = fill_event.quantity
        price = fill_event.price
        cost = quantity * price
        
        if fill_event.direction == 'BUY':
            self.capital -= cost
            self.positions[symbol] = self.positions.get(symbol, 0) + quantity
        else:
            self.capital += cost
            self.positions[symbol] = self.positions.get(symbol, 0) - quantity
        
        self.trades.append(fill_event)
        self.equity_curve.append(self.get_total_equity())
    
    def get_total_equity(self):
        """计算总权益(现金 + 持仓市值)"""
        total = self.capital
        for symbol, qty in self.positions.items():
            # 这里需要从数据模块获取最新价格
            price = self.data.get_latest_price(symbol)
            total += qty * price
        return total

嗯,这里有个细节:Portfolio 需要知道当前持仓的市值,所以它得依赖数据模块提供最新价格。我习惯在初始化时把数据模块的引用传进来,而不是让 Portfolio 自己去拉数据——保持依赖方向清晰。

4.4 三者如何协作?

我画个简单的时序图,帮你理清关系:

核心类协作时序 BacktestEngine Strategy Portfolio 推送市场数据事件 返回信号列表 执行成交后,通知更新 返回更新后的权益

说白了,BacktestEngine 是“导演”,Strategy 是“编剧”,Portfolio 是“会计”。导演喊开始,编剧写剧本,会计记账。各干各的,互不干扰。

核心原则:每个类只负责自己领域内的事情。BacktestEngine 不关心策略怎么算信号,Strategy 不关心账户里有多少钱,Portfolio 不关心策略为什么买卖。这种“单一职责”的设计,后期维护和扩展会非常舒服。

好,今天的内容就到这里。事件驱动架构和模块划分是回测引擎的“骨架”,核心类设计是“血肉”。骨架搭好了,后面往里面填功能就顺理成章了。

一个小练习:你可以试着用今天讲的三个核心类,手写一个最简单的回测引擎,只支持单股票、单策略、无风控。跑通之后,再慢慢往里面加模块。我当年就是这么过来的。


公众号:蓝海资料掘金营,微信deep3321