一句话版本
回测是用历史数据检验策略,它很容易给出一个漂亮但虚假的结果。最常见的七种错误是:前视偏差、幸存者偏差、过拟合、数据窥探、忽略交易成本、忽略容量、以为样本外就安全。 本文用代码演示其中两个最致命的:在纯随机的价格上,只要尝试足够多的参数组合,就能"找到"一个样本内表现不错的策略,而它在样本外毫无价值;只要把信号的时间错开一天(偷看当天收盘价),一个亏钱的策略就能变成赚钱的策略。
免责
本文仅为技术科普,不构成任何投资建议。文中代码使用模拟数据。
第一层:通俗理解
1.1 事后诸葛亮
"如果我 2010 年买了比特币……"每个人都能在事后找到一条完美的路径。回测的危险在于:它就是一台系统化的事后诸葛亮生成器。
一个回测结果之所以不可信,通常不是因为代码写错了,而是因为:
- 它用到了当时不可能知道的信息;
- 它在同一份历史数据上试了太多次;
- 它忽略了现实中的摩擦。
1.2 七宗罪一览
| 罪 | 是什么 | 典型例子 |
|---|---|---|
| 1. 前视偏差 | 使用了当时还不知道的数据 | 用当天收盘价算信号、又在当天收盘价成交;用财报报告期而非公告日 |
| 2. 幸存者偏差 | 只在"活下来"的资产上回测 | 用今天的指数成分股回测过去十年,退市、被剔除的股票都不见了 |
| 3. 过拟合 | 参数调到"刚好"适合历史 | 均线周期从 2 到 200 全试一遍,挑最好的 |
| 4. 数据窥探 | 反复在同一份数据上检验不同的想法 | 整个团队几年来都在同一段历史上做研究(见《多重检验与因子动物园》) |
| 5. 忽略成本 | 不计手续费、价差、冲击 | 高频换手的策略,扣除成本后由盈转亏(见《交易成本建模》) |
| 6. 忽略容量 | 不考虑资金规模对价格的影响 | 小盘股策略用 1 亿资金回测,现实中根本买不进去 |
| 7. 迷信样本外 | 以为留了样本外数据就安全了 | 看了样本外结果后回去改策略,再看样本外……样本外变成了样本内 |
第二层:演示一:随机数据上的"好策略"
2.1 实验设计
- 生成 2000 天纯随机游走的价格:没有任何可预测性;
- 前 1000 天作为样本内,后 1000 天作为样本外;
- 用均线交叉策略(快线上穿慢线时持有),快线周期 2–48、慢线周期在快线 + 5 到 195 之间,遍历所有组合。
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 解读
- 在一个明知不可预测的序列上,试了 826 组参数,最好的一组样本内夏普 0.61;
- 到了样本外,这组参数夏普 −0.1,和所有参数的平均水平没有区别;
- 如果你只看到"快线 44、慢线 84、夏普 0.61"这一行结果,很可能会相信它;
- 真实研究中的情况更糟:尝试的往往不止参数,还有不同的指标、不同的股票池、不同的时间段,"试验次数"远超几百次,能挑出的样本内结果也更好看。如何对此进行校正,见《多重检验与因子动物园》。
第三层:演示二:前视偏差只需要一行
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 的夏普。在真实数据上,它制造的虚假收益可能更大,而且更难发现,因为它常常藏在复杂的数据处理流程中:
- 用全样本的均值和标准差做标准化(见《因子工程》);
- 用当天的成交量加权价格(VWAP)作为当天的成交价,同时又用它算信号;
- 财务数据按报告期而不是公告日对齐;
- 机器学习中随机打乱时间序列做交叉验证。
3.3 防御清单
| 罪 | 防御 |
|---|---|
| 前视偏差 | 所有信号显式 shift;用时点数据库;代码审查时专门检查"这个数据在当时能否获得" |
| 幸存者偏差 | 使用包含退市股票的数据库和历史成分股 |
| 过拟合 | 少参数;参数在合理范围内附近都应该有效(参数平原),而不是一个尖峰 |
| 数据窥探 | 记录所有尝试过的想法和次数;用 Deflated Sharpe Ratio 校正(见《多重检验与因子动物园》) |
| 忽略成本 | 回测中内置手续费、价差、冲击成本模型(见《交易成本建模》) |
| 忽略容量 | 按目标资金规模和标的成交量限制仓位 |
| 迷信样本外 | 样本外只看一次;或者用"保险箱"数据,最终确认时才打开(见《Agent挖因子》) |
3.4 一个健康的研究习惯
- 先写下假设和理由,再看数据(避免先看结果再编故事);
- 记录每一次尝试,包括失败的;
- 用模拟数据或随机打乱的数据跑一遍自己的回测框架:如果在随机数据上也能"赚钱",框架一定有前视偏差;
- 模拟盘或小资金实盘,作为最终的样本外检验。
总结
| 层次 | 要点 |
|---|---|
| 七宗罪 | 前视、幸存者、过拟合、数据窥探、忽略成本、忽略容量、迷信样本外 |
| 演示一 | 随机数据上试 826 组参数,样本内夏普 0.61,样本外归零 |
| 演示二 | 一个 shift(1) 的差别:前视让夏普从 −0.07 变成 0.57 |
| 习惯 | 先写假设;记录所有尝试;用随机数据检验回测框架;小资金实盘验证 |