回测报告上年化 80%、最大回撤 5%,看起来像是发现了圣杯。上线三个月,亏了 30%。这个剧本在量化圈反复上演,原因几乎都一样:回测本身就是错的。不是代码有 bug,而是回测的假设、数据、统计方法里藏着系统性的陷阱,让你以为策略有效,实际上只是在欺骗自己。

这篇文章把量化回测中最常见的陷阱按类型拆开:数据陷阱、统计陷阱、执行陷阱、心理陷阱。每个陷阱给出错误写法和正确写法的代码对比,最后附一张自查清单。

数据陷阱

数据是回测的输入。输入有偏,输出必然有偏。数据陷阱最隐蔽,因为代码逻辑可能完全正确,但数据本身就在说谎。

幸存者偏差

用今天的股票列表回测过去十年的策略,相当于默认了一个前提:这些股票十年前就存在,而且活到了今天。现实是大量公司在这十年里退市、破产、被收购。那些消失的股票往往是表现最差的,把它们从数据中剔除,策略的回测收益会被系统性地高估。

一个直观的例子:假设 2015 年你在 A 股选了 100 只小盘股做等权组合。到 2025 年,其中 15 只退市了(大部分因为经营不善),剩下 85 只的平均收益看起来还不错。但如果你把那 15 只退市股加回来(退市损失折算为年化收益后大多在 -25% 到 -45% 之间),组合的真实收益率会大幅缩水。下面用一个简化的例子说明这个效应:

import numpy as np

# 错误:只用存活到今天的股票回测
survivors = [0.15, 0.08, -0.05, 0.22, 0.10]  # 5只存活股的年化收益
print(f"幸存者平均年化: {np.mean(survivors):.2%}")  # 10.00%

# 正确:加入退市股票(退市亏损也折算为年化)
all_stocks = [0.15, 0.08, -0.05, 0.22, 0.10,
              -0.30, -0.45, -0.25]  # 3只退市股的年化收益(含退市损失)
print(f"真实平均年化: {np.mean(all_stocks):.2%}")    # -6.25%

解决方法:使用包含退市股的全历史数据库。如果数据供应商只提供当前上市股票,这个数据源用来做回测就有先天缺陷。

前视偏差

前视偏差(Look-Ahead Bias)是在回测中使用了当时不可能获得的信息。最典型的场景是用当天的收盘价做当天的交易决策——实际交易中,收盘价要到收盘后才知道。

另一个常见的变体:财报数据的发布日期。某公司 Q1 财报的报告期是 3 月 31 日,但实际发布日期可能是 4 月 25 日。如果你在回测中 4 月 1 日就用了这份财报数据来做选股,就引入了前视偏差。

import pandas as pd

# 错误:用当天收盘价做当天的交易信号
def wrong_signal(df):
    # 当天收盘价突破20日均线就买入——但收盘价当天盘中不知道
    df['ma20'] = df['close'].rolling(20).mean()
    df['signal'] = (df['close'] > df['ma20']).astype(int)
    return df

# 正确:信号基于昨天的数据,今天执行
def correct_signal(df):
    df['ma20'] = df['close'].rolling(20).mean()
    # shift(1):用昨天的收盘价和昨天的均线产生信号,今天开盘执行
    df['signal'] = (df['close'].shift(1) > df['ma20'].shift(1)).astype(int)
    return df

关于量化因子如何避免前视偏差的更多讨论,可以参考 AlphaGPT:用大模型挖掘量化因子中对因子生成流程的说明。

复权与数据清洗错误

股票分红、拆股、合股都会导致价格跳变。如果用未复权的价格数据回测,除权日的价格断崖会产生虚假的交易信号。

A 股中最常见的问题是分红除权。某只股票从 20 元除权到 18 元,如果策略用的是未复权数据,会看到一个 -10% 的"暴跌"并可能触发止损,但实际上持有者的总收益没有变化。

用后复权价格可以保证历史价格的连续性,历史数据一旦确定就不会再变,适合做长期回测。前复权则以最新价格为基准向前调整,每次新的分红都会改变历史价格,所以不同时间运行回测结果可能略有差异,但前复权适合跟当前市价对比。

统计陷阱

数据没问题,逻辑也对,但统计方法本身在骗你。

过拟合:参数越多回测越好看

过拟合是回测中最核心的陷阱。一个策略有 N 个可调参数,在有限的历史数据上优化这些参数,总能找到一组让回测曲线很漂亮的值。问题是这组参数只是拟合了历史噪声,而不是捕捉了市场的真实规律。

判断过拟合有一个简单的经验法则:参数数量和回测时间的关系。如果一个策略有 10 个参数,但只在 2 年的日线数据(约 500 个交易日)上优化,过拟合几乎是必然的。

Bailey 和 López de Prado 提出的 Deflated Sharpe Ratio(DSR)给出了一个量化检测过拟合的框架。核心思想是:你测试过的策略越多,偶然发现高夏普比率策略的概率就越大。DSR 的计算考虑了测试次数、样本量、收益的偏度和峰度:

$$DSR(\widehat{SR}^*) = \Phi\left[\frac{(\widehat{SR}^* - \widehat{SR}_0)\sqrt{T-1}}{\sqrt{1 - \hat{\gamma}_3 \widehat{SR}^* + \frac{\hat{\gamma}_4 - 1}{4}\widehat{SR}^{*2}}}\right]$$

其中 $\widehat{SR}^*$ 是观测到的最大夏普比率,$\widehat{SR}_0$ 是在零假设下(所有策略都无效时)期望看到的最大夏普比率(取决于测试次数 $K$),$T$ 是样本量,$\hat{\gamma}_3$ 和 $\hat{\gamma}_4$ 分别是收益的偏度和超额峰度。DSR 输出一个概率值:值越低,过拟合的可能性越大。

这个公式看起来复杂,本质上是一个修正版的 z 检验:分子衡量观测到的最大 SR 超过随机期望值的幅度,分母用偏度和峰度修正了标准误。如果收益分布是完美的正态分布(偏度为 0,超额峰度为 0),分母退化为 1,公式就变成了普通的 SR 显著性检验。

实际操作中,最直接的防过拟合方法是样本外测试(以下为伪代码框架,simulate 函数代表具体的策略模拟逻辑):

import numpy as np
import pandas as pd

def backtest_with_validation(prices, param_grid):
    n = len(prices)
    train_end = int(n * 0.6)
    val_end = int(n * 0.8)

    # 60% 训练,20% 验证,20% 测试
    train = prices[:train_end]
    val = prices[train_end:val_end]
    test = prices[val_end:]

    best_sharpe = -np.inf
    best_param = None

    # 在训练集上搜索参数
    for param in param_grid:
        returns = simulate(train, param)
        sharpe = returns.mean() / returns.std() * np.sqrt(252)
        if sharpe > best_sharpe:
            best_sharpe = sharpe
            best_param = param

    # 在验证集上确认(不再调参)
    val_returns = simulate(val, best_param)
    val_sharpe = val_returns.mean() / val_returns.std() * np.sqrt(252)

    # 最终在测试集上评估
    test_returns = simulate(test, best_param)
    test_sharpe = test_returns.mean() / test_returns.std() * np.sqrt(252)

    print(f"训练集 Sharpe: {best_sharpe:.2f}")
    print(f"验证集 Sharpe: {val_sharpe:.2f}")
    print(f"测试集 Sharpe: {test_sharpe:.2f}")
    # 如果验证集和测试集的 Sharpe 远低于训练集,大概率过拟合

    return best_param

训练集 Sharpe 2.5,验证集 1.2,测试集 0.3——这就是过拟合的典型症状。关于夏普比率等指标的具体计算方法,详见量化投资常用指标大全

一个有用的自我检验:能不能用一两句话解释策略的核心逻辑?如果需要一整页来描述所有的特殊规则和条件,大概率是过拟合了。这条经验法则对参数较少的系统化策略尤其适用;机器学习策略可能天然复杂,但即便如此,核心的 alpha 来源也应该能简洁表述。

多重检验偏差

跑了 1000 个策略变体,挑出表现最好的那个,宣称发现了 Alpha。这跟扔 1000 次硬币选出连续正面最多的那次没有本质区别。

这就是多重检验偏差(Multiple Testing Bias),也叫数据窥探偏差(Data Snooping Bias)。在 5% 的显著性水平下,测试 100 个随机策略,期望有 5 个会显示"显著"的超额收益,但这些收益完全是随机产生的。

修正方法是对 p 值做多重检验校正,最简单的是 Bonferroni 校正:把显著性阈值除以测试次数。如果测试了 100 个策略,显著性阈值从 0.05 降到 0.0005。Bonferroni 比较保守,实际中也可以用 Holm 校正,它和 Bonferroni 一样控制族错误率(FWER,即错误地拒绝至少一个真零假设的概率),但统计功效更高。如果更关心"在所有声称有效的策略中有多少比例是假的",可以用 BH(Benjamini-Hochberg)控制假发现率(FDR)。关键不是用哪种方法,而是必须做校正。

样本量不足

一个策略在 6 个月的数据上回测表现很好,能说明什么?几乎什么都说明不了。6 个月的日线数据大约 120 个交易日,可能只经历了一种市场环境(比如持续上涨)。策略在牛市里赚钱不代表它在震荡市或熊市里也能赚钱。

经验法则:回测数据至少覆盖 2-3 个完整的市场周期。对于日线级别策略,5 年以上的数据是基本要求;对于分钟级高频策略,至少需要 1 年的 tick 数据。

还有一个容易忽略的点:样本量不仅指时间长度,还指独立样本的数量。一个月度调仓的策略,5 年只有 60 个独立决策点。这个样本量做统计推断,置信度很有限。

执行陷阱

回测中的交易是瞬间完成的、零摩擦的。真实市场不是这样。

交易成本建模不真实

最基础的错误是完全忽略交易成本。稍微进阶一点的错误是只算佣金不算滑点。

一个日内高频策略,单次交易利润可能只有 2-3 个 BP(万分之二到三)。如果滑点和手续费加起来就要吃掉 1-2 个 BP,策略的净收益直接腰斩。

# 错误:零成本回测
def backtest_no_cost(returns, signals):
    strategy_returns = returns * signals
    return strategy_returns

# 正确:加入分层交易成本
def backtest_with_cost(returns, signals, 
                       commission=0.0003,   # 万三佣金
                       slippage=0.001,      # 千一滑点
                       tax=0.001):          # 千一印花税(卖出)
    strategy_returns = returns * signals
    
    # 每次调仓产生交易成本,区分买入和卖出
    position_change = signals.diff().fillna(0)  # 正=买入,负=卖出
    buy_cost = position_change.clip(lower=0) * (commission + slippage)
    sell_cost = position_change.clip(upper=0).abs() * (commission + slippage + tax)
    total_cost = buy_cost + sell_cost
    
    net_returns = strategy_returns - total_cost
    return net_returns

一个残酷的事实:很多量化策略在加入真实交易成本后,收益从正变负。在正式回测之前,先跑一遍成本敏感性分析——如果交易成本翻倍策略就不赚钱了,这个策略的安全边际太薄。

滑点与市场冲击

滑点是你下单的价格和实际成交价格之间的差异。对于小资金和高流动性标的,滑点可能微不足道。但当资金规模上去之后,市场冲击成本会急剧增加。

一个管理 1 亿资金的策略,如果要在一只日均成交额 5000 万的股票上建 10% 的仓位(1000 万),这 1000 万的买单可能要吃掉好几个价位的卖盘,实际成交均价会显著高于回测中使用的价格。

回测中的常见做法是假设固定滑点(比如千分之一),但更准确的方法是用成交量加权的滑点模型:滑点与(订单大小 / 日均成交量)成正比。

流动性假设

回测默认你可以在任何时候以任何数量买入或卖出。真实市场中,小盘股的买卖盘口可能很薄,大单成交需要时间。

最极端的例子是 A 股的涨跌停板。如果策略在某只股票跌停时发出买入信号,回测中可以直接成交,但实际上跌停板上可能有几亿的封单排在你前面,你根本买不到。

回测中至少应该加两个限制:一是当日成交量限制(策略成交量不超过当日成交量的一定比例,通常 5-10%),二是涨跌停过滤(涨停不追买,跌停不追卖)。

心理陷阱

前面三类陷阱是技术性的,可以通过更好的代码和数据来解决。心理陷阱更难处理,因为它们根植于人的认知偏差。

确认偏差与选择性展示

你有一个直觉:动量策略在 A 股小盘股上特别有效。于是你回测了这个策略,发现 2019-2021 年效果很好。你选择展示这段数据,忽略了 2022 年策略大幅回撤的事实。这就是确认偏差——你只看到支持自己预设的证据。

另一个变体是选择性报告回测指标。策略的夏普比率不高,但最大回撤很小,于是你在报告中强调回撤控制能力,淡化收益不足的问题。

解决方法没有什么技巧,就是纪律:每个策略必须报告完整的指标集(收益、风险、风险调整指标),必须展示全时段的表现,包括表现差的时期。

数据窗口挑选

跟确认偏差紧密相关的是数据窗口挑选(Cherry-Picking)。策略在 2019-2021 牛市表现优异,2022 年回撤严重,你选择只展示牛市那段。更隐蔽的做法是把回测起始日期设在某个有利的时间点,比如刚好避开一次大跌。

防范方法是固定回测窗口的选择规则:要么用全量可用数据,要么用预定义的标准周期(比如最近 5 年、10 年),不要回测完了再挑最好看的窗口。

量化回测陷阱自查清单

陷阱类别症状检查方法严重程度
幸存者偏差数据历史收益系统性偏高确认数据包含退市股
前视偏差数据使用了未来数据检查数据 shift 和时间戳极高
复权错误数据除权日出现异常信号确认使用复权价格
过拟合统计样本内外表现差异大,策略逻辑无法简述样本外测试 + Walk-Forward + 一句话检验极高
多重检验统计测试大量变体选最好的Bonferroni 校正
样本量不足统计仅覆盖单一市场环境≥ 2-3 个完整周期
交易成本忽略执行净收益远低于回测加入真实成本模型
滑点低估执行大资金实盘偏差大用成交量加权滑点模型
流动性假设执行小盘股/涨跌停无法成交加成交量和涨跌停限制
确认偏差心理只展示好看的时段强制报告全时段全指标
数据窗口挑选心理回测起止日期选择性有利固定窗口选择规则

从回测到实盘的正确流程

避免回测陷阱不是靠某一个技巧,而是靠一套完整的验证流程。

第一步,样本内训练。在 60% 的历史数据上开发和优化策略,产出候选参数。

第二步,样本外验证。在 20% 的保留数据上测试候选参数,不做任何调整。如果样本外表现显著下降,回到第一步重新设计,而不是调整参数来适应验证集。

第三步,Walk-Forward 测试。用滚动窗口的方式,在每个时间窗口内训练并在下一个窗口测试,模拟策略在真实时间中的表现。这是最接近实盘的回测方式。需要注意的是,金融时间序列存在自相关性,训练集和测试集之间应该留出间隔期(embargo),并清除训练集中标签时间跨越到测试集的样本(purging),防止信息泄漏。

第四步,纸上交易(Paper Trading)。用真实行情数据,按策略逻辑生成信号,但不实际下单。运行至少 1-3 个月,对比纸上交易结果和回测预期。

第五步,小资金实盘。用策略总资金的 5-10% 试运行,观察实际滑点、成交率、系统延迟等回测无法模拟的因素。

只有每一步的结果都跟预期一致,才值得逐步加大资金。任何一步出现显著偏差,都应该停下来找原因,而不是带着问题往前走。