回测报告上年化 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% 试运行,观察实际滑点、成交率、系统延迟等回测无法模拟的因素。
只有每一步的结果都跟预期一致,才值得逐步加大资金。任何一步出现显著偏差,都应该停下来找原因,而不是带着问题往前走。