4、设计回测引擎(一):事件驱动架构设计、回测引擎的模块划分
好,咱们今天正式进入回测引擎的核心设计环节。
说实话,我见过不少量化新手,一上来就急着写策略、调参数。结果呢?回测跑出来的曲线漂亮得不行,一上实盘就崩。问题出在哪?多半是回测引擎本身有漏洞。你想想看,如果地基没打牢,上面盖再高的楼也是白搭。
我个人习惯,在设计任何系统之前,先想清楚两件事:数据怎么流动,以及模块怎么划分。今天我们就围绕这两点展开。
4.1 事件驱动架构:回测的“心跳”
回测引擎的核心,说白了就是一个循环——模拟市场的时间流逝,然后在每个时间点上处理各种“事件”。
什么是事件?简单来说,就是“发生了什么事”。比如:
- 一根新的K线到了(市场数据事件)
- 策略发出了买入信号(信号事件)
- 订单被交易所成交了(成交事件)
事件驱动架构,就是让这些事件像多米诺骨牌一样,一个触发另一个,推动整个回测流程往前走。
核心思想:回测引擎不主动“轮询”状态,而是被动响应事件。这样做的好处是——逻辑清晰、扩展性强、容易模拟真实交易环境。
我在项目中遇到过一个问题:有人把回测写成了一个大循环,里面塞满了if-else判断。结果想加一个新功能(比如模拟滑点),得改好几处代码。改用事件驱动后,每个模块只关心自己感兴趣的事件,耦合度大大降低。
事件驱动的工作流程
我习惯把它画成一张图,你一看就明白:
嗯,这里要注意:事件队列是整个架构的“调度中心”。所有模块都不直接调用对方,而是把事件丢进队列里。引擎从队列头部取出事件,分发给对应的模块处理。这样做的好处是——你可以随时往队列里插入新事件,比如模拟延迟成交、模拟滑点,都不需要改核心逻辑。
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_data 和 on_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 不关心策略为什么买卖。这种“单一职责”的设计,后期维护和扩展会非常舒服。
好,今天的内容就到这里。事件驱动架构和模块划分是回测引擎的“骨架”,核心类设计是“血肉”。骨架搭好了,后面往里面填功能就顺理成章了。
一个小练习:你可以试着用今天讲的三个核心类,手写一个最简单的回测引擎,只支持单股票、单策略、无风控。跑通之后,再慢慢往里面加模块。我当年就是这么过来的。
公众号:蓝海资料掘金营,微信deep3321