回测框架设计原则:从零搭建你的统计套利引擎

做量化这几年,我踩过最大的坑就是回测框架设计不合理。一开始图省事,把所有逻辑塞进一个脚本里。结果呢?改个参数要翻半天代码,换个数据源得重写一半逻辑。后来我学乖了——模块化设计,才是回测框架的命根子。

模块化设计思想:拆开才能跑得快

模块化说白了就是「各司其职」。我习惯把回测框架拆成五个独立模块:数据、策略、风控、执行、分析。每个模块只干一件事,接口定义清楚就行。

举个例子,数据模块只负责提供行情数据。你换数据源,从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。后来老老实实按模块拆分,世界清净了。

嗯,这里要注意一点:模块化不是越细越好。我见过有人把数据模块拆成「获取数据」「清洗数据」「存储数据」三个子模块。说实话,过度设计反而增加维护成本。我的原则是:一个模块能独立完成一个完整功能,就够了。

专注资料整理