Caloogy 文章
English

回测的七宗罪

日期
2026 年 2 月 16 日
系列
量化
阅读
4 分钟
主题
QuantBacktestOverfittingLook ahead bias

一句话版本

回测是用历史数据检验策略,它很容易给出一个漂亮但虚假的结果。最常见的七种错误是:前视偏差、幸存者偏差、过拟合、数据窥探、忽略交易成本、忽略容量、以为样本外就安全。 本文用代码演示其中两个最致命的:在纯随机的价格上,只要尝试足够多的参数组合,就能"找到"一个样本内表现不错的策略,而它在样本外毫无价值;只要把信号的时间错开一天(偷看当天收盘价),一个亏钱的策略就能变成赚钱的策略。

免责

本文仅为技术科普,不构成任何投资建议。文中代码使用模拟数据。


第一层:通俗理解

1.1 事后诸葛亮

"如果我 2010 年买了比特币……"每个人都能在事后找到一条完美的路径。回测的危险在于:它就是一台系统化的事后诸葛亮生成器。

一个回测结果之所以不可信,通常不是因为代码写错了,而是因为:

1.2 七宗罪一览

罪 是什么 典型例子
1. 前视偏差 使用了当时还不知道的数据 用当天收盘价算信号、又在当天收盘价成交;用财报报告期而非公告日
2. 幸存者偏差 只在"活下来"的资产上回测 用今天的指数成分股回测过去十年,退市、被剔除的股票都不见了
3. 过拟合 参数调到"刚好"适合历史 均线周期从 2 到 200 全试一遍,挑最好的
4. 数据窥探 反复在同一份数据上检验不同的想法 整个团队几年来都在同一段历史上做研究(见《多重检验与因子动物园》)
5. 忽略成本 不计手续费、价差、冲击 高频换手的策略,扣除成本后由盈转亏(见《交易成本建模》)
6. 忽略容量 不考虑资金规模对价格的影响 小盘股策略用 1 亿资金回测,现实中根本买不进去
7. 迷信样本外 以为留了样本外数据就安全了 看了样本外结果后回去改策略,再看样本外……样本外变成了样本内

第二层:演示一:随机数据上的"好策略"

2.1 实验设计

import numpy as np, pandas as pd
rng = np.random.default_rng(7)
n = 2000
ret = pd.Series(rng.normal(0, 0.01, n))          # 纯随机游走:不存在任何可预测性
price = 100 * np.exp(ret.cumsum())
ins, oos = slice(0, 1000), slice(1000, n)

def sharpe(x):
    return x.mean() / x.std() * np.sqrt(252) if x.std() > 0 else 0.0

def ma_strategy(price, fast, slow):
    sig = (price.rolling(fast).mean() > price.rolling(slow).mean()).astype(float)
    return sig.shift(1) * price.pct_change()        # 用昨天的信号交易今天,避免前视

results = []
for fast in range(2, 50, 2):
    for slow in range(fast + 5, 200, 5):
        r = ma_strategy(price, fast, slow)
        results.append((fast, slow, sharpe(r.iloc[ins].dropna()), sharpe(r.iloc[oos].dropna())))
res = pd.DataFrame(results, columns=["fast", "slow", "is_sharpe", "oos_sharpe"])
best = res.loc[res["is_sharpe"].idxmax()]
print("尝试的参数组合数:", len(res))
print("样本内最佳:", best.round(2).to_dict())
print("所有组合样本外夏普均值:", round(res["oos_sharpe"].mean(), 2))

输出:

尝试的参数组合数: 826
样本内最佳: {'fast': 44.0, 'slow': 84.0, 'is_sharpe': 0.61, 'oos_sharpe': -0.1}
所有组合样本外夏普均值: -0.13

2.2 解读


第三层:演示二:前视偏差只需要一行

3.1 同一个策略,两种写法

sig = (price.rolling(5).mean() > price.rolling(20).mean()).astype(float)
cheat = sig * price.pct_change()                     # 用今天收盘算出的信号,去赚今天的收益
honest = sig.shift(1) * price.pct_change()           # 用昨天的信号,赚今天的收益
print("前视版本夏普:", round(sharpe(cheat.dropna()), 2), " 正确版本夏普:", round(sharpe(honest.dropna()), 2))

输出:

前视版本夏普: 0.57  正确版本夏普: -0.07

3.2 为什么差这么多

今天的收盘价同时影响了"今天的均线"(信号)和"今天的收益"。如果今天大涨,均线更可能上穿,信号更可能为 1,于是"恰好"持有了大涨的这一天。这种关联完全来自同一个数据点被用了两次,现实中不可能实现:你在收盘前不知道收盘价。

在纯随机数据上,前视偏差凭空制造出了 0.57 的夏普。在真实数据上,它制造的虚假收益可能更大,而且更难发现,因为它常常藏在复杂的数据处理流程中:

3.3 防御清单

罪 防御
前视偏差 所有信号显式 shift;用时点数据库;代码审查时专门检查"这个数据在当时能否获得"
幸存者偏差 使用包含退市股票的数据库和历史成分股
过拟合 少参数;参数在合理范围内附近都应该有效(参数平原),而不是一个尖峰
数据窥探 记录所有尝试过的想法和次数;用 Deflated Sharpe Ratio 校正(见《多重检验与因子动物园》)
忽略成本 回测中内置手续费、价差、冲击成本模型(见《交易成本建模》)
忽略容量 按目标资金规模和标的成交量限制仓位
迷信样本外 样本外只看一次;或者用"保险箱"数据,最终确认时才打开(见《Agent挖因子》)

3.4 一个健康的研究习惯

  1. 先写下假设和理由,再看数据(避免先看结果再编故事);
  2. 记录每一次尝试,包括失败的;
  3. 用模拟数据或随机打乱的数据跑一遍自己的回测框架:如果在随机数据上也能"赚钱",框架一定有前视偏差;
  4. 模拟盘或小资金实盘,作为最终的样本外检验。

总结

层次 要点
七宗罪 前视、幸存者、过拟合、数据窥探、忽略成本、忽略容量、迷信样本外
演示一 随机数据上试 826 组参数,样本内夏普 0.61,样本外归零
演示二 一个 shift(1) 的差别:前视让夏普从 −0.07 变成 0.57
习惯 先写假设;记录所有尝试;用随机数据检验回测框架;小资金实盘验证