4. 单因子回测框架搭建:回测框架设计、信号生成与交易逻辑、绩效评估指标

好了,到了真正动手的环节了。

前面我们讲了数据清洗、讲了因子计算,但那些都是「食材」。今天我们要搭一个「厨房」——也就是单因子回测框架。说白了,就是把你算好的因子值,扔进一个模拟的交易环境里,看看它到底能不能赚钱。

我个人习惯把回测框架拆成三个模块:信号生成交易逻辑绩效评估。这三个模块串起来,就是一个完整的回测流水线。

核心思路: 因子值 → 交易信号 → 模拟成交 → 绩效指标。每一步都有坑,我们一个一个来填。

4.1 回测框架的整体设计

先画个图,让你对整个框架有个直观印象。我习惯用一张流程图来梳理逻辑,这样写代码的时候不容易乱。

单因子回测框架流程图 数据输入层 信号生成层 交易逻辑层 绩效评估层 行情数据 因子值 分位数分组 阈值过滤 买入/卖出规则 仓位管理 夏普比率 最大回撤 最终报告

这张图我画了好几个版本,最后觉得这个最清晰。你写代码的时候,也可以先画个类似的图,把每个模块的输入输出理清楚。

4.2 信号生成:从因子值到交易指令

信号生成是整个框架的「大脑」。因子值本身只是一堆数字,你得告诉程序:什么情况下买入?什么情况下卖出?

最常见的做法是分位数分组法。比如,每个月末,把所有股票的因子值从大到小排序,分成10组。做多第1组(因子值最大的),做空第10组(因子值最小的)。

嗯,这里要注意:分组数不是固定的。我见过有人用5组,也有人用20组。分组越细,信号越敏感,但换手率也越高。我个人习惯用10组,这是一个比较平衡的选择。

避坑指南: 我曾经在信号生成时犯过一个低级错误——用了未来数据。具体来说,我用的是当天的因子值来生成当天的信号,但实际上因子值在收盘后才能算出来。正确的做法是:用T-1日的因子值,生成T日的信号。

除了分组法,还有阈值法。比如,因子值大于2就买入,小于-2就卖出。这种方法更直接,但阈值怎么定?我建议你先看看因子值的分布,别拍脑袋定。

# 信号生成示例代码(伪代码)
def generate_signals(factor_df, method='quantile', n_groups=10):
    """
    factor_df: 包含日期、股票代码、因子值的DataFrame
    """
    if method == 'quantile':
        # 每月分组
        factor_df['group'] = factor_df.groupby('date')['factor'].transform(
            lambda x: pd.qcut(x, n_groups, labels=False)
        )
        # 做多第0组(最大),做空第n_groups-1组(最小)
        factor_df['signal'] = 0
        factor_df.loc[factor_df['group'] == 0, 'signal'] = 1   # 买入
        factor_df.loc[factor_df['group'] == n_groups-1, 'signal'] = -1  # 卖出
    return factor_df

4.3 交易逻辑:模拟真实成交

信号生成之后,就是交易逻辑了。这一步最容易出问题,因为真实交易和理想情况差太多了。

你想想看,信号说「买入」,但你真的能买到吗?不一定。比如涨停了买不进去,或者流动性太差,一买就把价格拉上去了。所以,交易逻辑里必须考虑滑点交易成本

我一般这样处理:

  • 滑点:假设每次交易都有0.1%的滑点成本。这个数字是我根据历史数据统计出来的,不同市场不一样。
  • 手续费:A股一般是万分之三,加上印花税(卖出时千分之一)。
  • 涨跌停限制:如果股票涨停,当天不能买入;跌停不能卖出。

警告: 千万别忽略交易成本!我见过一个因子,不考虑成本时年化收益20%,考虑成本后直接变成-5%。这种因子就是「纸面富贵」,实盘会亏死你。

交易逻辑的代码实现,我习惯用一个循环来模拟每一天的操作:

# 交易逻辑伪代码
def run_backtest(signals, price_df, initial_capital=1e6):
    capital = initial_capital
    position = 0  # 当前持仓
    trade_log = []

    for date in sorted(signals['date'].unique()):
        today_signals = signals[signals['date'] == date]
        today_prices = price_df.loc[date]

        # 先处理卖出信号
        sell_stocks = today_signals[today_signals['signal'] == -1]
        for stock in sell_stocks['stock']:
            if position > 0:
                # 考虑滑点和手续费
                sell_price = today_prices[stock] * (1 - 0.001)  # 0.1%滑点
                capital += position * sell_price * (1 - 0.0003)  # 手续费
                position = 0
                trade_log.append((date, stock, 'sell', sell_price))

        # 再处理买入信号
        buy_stocks = today_signals[today_signals['signal'] == 1]
        for stock in buy_stocks['stock']:
            if capital > 0:
                buy_price = today_prices[stock] * (1 + 0.001)  # 0.1%滑点
                position = capital / buy_price * (1 - 0.0003)  # 手续费
                capital = 0
                trade_log.append((date, stock, 'buy', buy_price))

    return capital, trade_log

这段代码很简化,但核心逻辑都在了。实际项目中,你还要处理多只股票同时持仓的情况,那就需要引入仓位管理了。

4.4 绩效评估指标:用数字说话

回测跑完了,拿到了一串净值曲线。然后呢?你得用指标来量化它的表现。我最常用的三个指标是:年化收益率夏普比率最大回撤

4.4.1 年化收益率

这个最简单,就是把总收益率换算成年化形式。假设回测了3年,总收益是50%,那年化就是:

年化收益率 = (1 + 0.5)^(1/3) - 1 ≈ 14.5%

4.4.2 夏普比率

夏普比率衡量的是「每承担一单位风险,能获得多少超额收益」。公式是:

夏普比率 = (策略年化收益率 - 无风险利率) / 年化波动率

一般来说,夏普比率大于1算不错,大于2算优秀。我见过最夸张的一个因子,夏普比率做到了3.5,但后来发现是数据有误……

个人经验: 夏普比率高不一定代表因子好。如果回测时间短(比如只有1年),或者市场处于单边牛市,夏普比率会虚高。我一般要求回测至少3年,并且包含一段熊市。

4.4.3 最大回撤

最大回撤就是「从最高点跌到最低点,最多亏了多少」。这个指标很直观,也很残酷。一个年化收益30%的策略,如果最大回撤是40%,你敢用吗?反正我不敢。

我给自己定了个规矩:最大回撤不能超过年化收益的一半。比如年化20%,最大回撤最好控制在10%以内。

下面是一个绩效评估的汇总表,方便你对照:

指标名称 计算公式 优秀标准 注意事项
年化收益率 (1+总收益)^(1/年数)-1 > 15% 需考虑无风险利率
夏普比率 (策略收益 - 无风险利率) / 波动率 > 1.5 回测期需包含熊市
最大回撤 max(峰值 - 谷值) / 峰值 < 10% 越低越好
胜率 盈利交易次数 / 总交易次数 > 50% 需结合盈亏比看
盈亏比 平均盈利 / 平均亏损 > 2 越高越好

最后,我想说一句:回测只是起点,不是终点。一个因子在回测里表现再好,也不代表实盘能赚钱。但如果你连回测这关都过不了,那实盘就更没戏了。

好了,这一章的内容就到这。框架搭好了,下一步就是往里面填因子,看看哪些是真金,哪些是沙子。


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