回测框架设计原则:从零搭建你的统计套利引擎
做量化这几年,我踩过最大的坑就是回测框架设计不合理。一开始图省事,把所有逻辑塞进一个脚本里。结果呢?改个参数要翻半天代码,换个数据源得重写一半逻辑。后来我学乖了——模块化设计,才是回测框架的命根子。
模块化设计思想:拆开才能跑得快
模块化说白了就是「各司其职」。我习惯把回测框架拆成五个独立模块:数据、策略、风控、执行、分析。每个模块只干一件事,接口定义清楚就行。
举个例子,数据模块只负责提供行情数据。你换数据源,从CSV换成数据库,甚至换成实时行情,策略模块完全不用动。我在项目中遇到过最头疼的事,就是策略写死了数据格式,换数据源等于重写策略。嗯,从那以后我强制要求数据模块输出统一格式的DataFrame。
核心原则:模块之间通过事件或消息通信,不直接调用对方内部函数。
事件驱动架构 vs 向量化回测
这两个东西,说白了就是两种不同的回测思路。我刚开始做回测时,一直用向量化方式——把整个历史数据一次性算完,又快又简单。但后来做统计套利,发现不对劲了。
向量化回测:一次性计算所有信号和持仓。适合简单策略,速度快。但有个致命问题——无法模拟真实交易中的「等待」和「顺序」。比如你同时收到两个信号,先处理哪个?向量化回测没法回答。
事件驱动架构:模拟真实交易流程。每个时间点触发一个事件,框架按顺序处理。虽然慢一点,但更贴近实盘。我个人做统计套利回测,现在全用事件驱动。
| 对比维度 | 向量化回测 | 事件驱动回测 |
|---|---|---|
| 执行速度 | 快(一次性计算) | 慢(逐笔模拟) |
| 模拟精度 | 低(忽略顺序) | 高(完全模拟) |
| 适合策略 | 简单趋势、均值回归 | 统计套利、高频、多标的 |
| 代码复杂度 | 低 | 中高 |
我的建议:初期用向量化快速验证策略想法,确定可行后再用事件驱动做精细回测。别一上来就搞事件驱动,你会被调试搞疯的。
回测框架的核心组件
一个完整的回测框架,至少要有这五个组件。我按自己的习惯排了个序,你想想看是不是这个理。
1. 数据模块
数据是回测的起点。我要求数据模块必须做到三点:统一格式、支持多种数据源、能处理缺失值。曾经有一次,我用的数据源某天突然少了几根K线,回测结果直接翻倍——嗯,那是假信号。
class DataModule:
def __init__(self, source='csv'):
self.source = source
def load_data(self, symbol, start_date, end_date):
# 统一输出格式:OHLCV DataFrame
data = pd.read_csv(f'{symbol}.csv')
data['date'] = pd.to_datetime(data['date'])
data.set_index('date', inplace=True)
return data
def resample(self, data, freq='1H'):
# 支持不同时间频率
return data.resample(freq).agg({
'open': 'first',
'high': 'max',
'low': 'min',
'close': 'last',
'volume': 'sum'
})
2. 策略模块
策略模块负责生成交易信号。我习惯把策略写成「输入数据,输出信号」的纯函数。这样测试起来特别方便。
class PairTradingStrategy:
def __init__(self, entry_z=2.0, exit_z=0.5):
self.entry_z = entry_z
self.exit_z = exit_z
def generate_signals(self, price_a, price_b):
# 计算价差和Z-score
spread = price_a - price_b
z_score = (spread - spread.mean()) / spread.std()
# 生成信号:1做多价差,-1做空价差,0平仓
signals = pd.Series(0, index=z_score.index)
signals[z_score > self.entry_z] = -1
signals[z_score < -self.entry_z] = 1
signals[abs(z_score) < self.exit_z] = 0
return signals
3. 风控模块
风控是回测里最容易忽略的组件。我见过太多人回测收益漂亮,实盘直接爆仓。为什么?因为回测里没加风控。
避坑指南:我曾经在回测里不加止损,结果策略在2015年股灾中回撤80%。从那以后,我强制每个策略必须绑定风控模块。
class RiskManager:
def __init__(self, max_drawdown=0.2, max_position=0.1):
self.max_drawdown = max_drawdown
self.max_position = max_position
def check_risk(self, portfolio, signal):
# 检查最大回撤
if portfolio.drawdown > self.max_drawdown:
return 'stop_all'
# 检查仓位限制
if abs(signal) * portfolio.capital > self.max_position:
return 'reduce'
return 'proceed'
4. 执行模块
执行模块模拟真实交易。这里要注意滑点和手续费。我习惯在回测里加一个「悲观滑点」——比实际市场滑点大20%。这样回测跑出来的收益,实盘大概率能实现。
class ExecutionEngine:
def __init__(self, slippage=0.001, commission=0.0003):
self.slippage = slippage
self.commission = commission
def execute(self, signal, price, volume):
# 模拟滑点
executed_price = price * (1 + signal * self.slippage)
# 计算手续费
cost = executed_price * volume * self.commission
return executed_price, cost
5. 分析模块
分析模块负责计算回测指标。我常用的指标有:夏普比率、最大回撤、胜率、盈亏比。但说实话,我最看重的其实是「资金曲线平滑度」——一个夏普2.0但曲线像过山车的策略,我是不敢实盘的。
class PerformanceAnalyzer:
def analyze(self, equity_curve):
returns = equity_curve.pct_change().dropna()
sharpe = returns.mean() / returns.std() * np.sqrt(252)
max_dd = (equity_curve / equity_curve.cummax() - 1).min()
win_rate = len(returns[returns > 0]) / len(returns)
return {
'sharpe': sharpe,
'max_drawdown': max_dd,
'win_rate': win_rate,
'total_return': equity_curve.iloc[-1] / equity_curve.iloc[0] - 1
}
核心逻辑流程图
下面这张图,是我做统计套利回测框架的核心流程。你仔细看看,每个模块之间的箭头代表事件传递。
个人经验:模块化设计最大的好处是「可替换」。我有个策略在A股跑得好好的,想换到港股试试?只需要换数据模块和执行模块,策略和风控完全不用动。这就是模块化的威力。
回测框架设计,说白了就是「把复杂问题拆成简单模块」。你想想看,如果每个模块都只关心自己的事,调试起来是不是轻松很多?我刚开始做的时候,总想把所有功能塞进一个类里。结果呢?改一个bug引出三个新bug。后来老老实实按模块拆分,世界清净了。
嗯,这里要注意一点:模块化不是越细越好。我见过有人把数据模块拆成「获取数据」「清洗数据」「存储数据」三个子模块。说实话,过度设计反而增加维护成本。我的原则是:一个模块能独立完成一个完整功能,就够了。