90%的量化回测都有bug?手把手教你避开三大偏差陷阱
想自己写一个量化策略的回测?听起来很酷,但你可能已经掉坑里了。很多人都“跑过”回测,结果跑出一个夏普比率4.2的“神策略”,兴奋得想马上实盘——但真相是,这个数字几乎肯定是假的。
这篇教程会带你手写一个最基础的动量策略回测(12个月动量,选标普500成分股),并告诉你为什么那些看起来很美的结果暗藏玄机。学完你就能认出回测里最常见的三种偏差,以后看见高夏普比能先怀疑一下。
这篇教程适合谁?
你会写Python,了解股市基本概念(价格序列、总回报、再平衡)。你可能没做过回测,或者做过但不敢确定结果是不是真的。
目标:一步步搭一个最小可用的正确回测框架,让你看出90%业余回测里暗藏的三大偏差,并且理解为什么“我夏普比4.2”几乎一定不靠谱。
策略背景:教科书级别的动量策略
我们要测的动量策略很经典,学术界有论文支撑(Jegadeesh & Titman 1993):
- 每个月,对标普500所有成分股,按过去12个月的总回报排名(注意,要剔除最近一个月)。
- 持有排名前10%的股票(大约50只),等权重配置。
- 每月再平衡一次。
- 对比买入并持有标普500指数。
为什么要剔除最近一个月?是为了避开短期的反转效应。根据已有研究,这个策略的夏普比率大概比买入持有高0.2~0.4。如果你的回测结果夏普比1.5以上,那肯定哪里出问题了。
第一步:准备数据
import pandas as pd
import numpy as np
import yfinance as yf
# 获取数据:注意这里需要的是历史成分股列表,不是今天标普500的列表!
tickers = pd.read_csv('sp500_historical_constituents.csv') # 文件来源见下面说明
prices = yf.download(tickers['ticker'].unique().tolist(),
start='2010-01-01', end='2025-12-31',
auto_adjust=True)['Close']
prices = prices.dropna(axis=1, how='all')
那个csv文件非常关键——它必须是历史上标普500的真实成分股(某一天指数里有哪些公司),而不是把今天标普500的成分股名单硬套到过去。后者叫幸存者偏差,是业余回测结果虚高的第一大元凶。
如果你用今天的标普500名单去跑历史价格,你测试的只是那些活到现在的公司(比如苹果、微软);那些破产或被收购的(雷曼兄弟、华盛顿互助银行、太阳微系统)就在你的宇宙里消失了。策略“看起来”表现更好,是因为你提前把输家剔除了。
历史成分股数据来源:WRDS(学术用)、CRSP(付费)、Kaggle上有些数据集(数据质量存疑但是免费)。如果你搞不到历史成分股,那你的回测底子就不牢,只能叫“示意性回测”。
第二步:计算动量信号
# 计算过去12个月总回报,并且滞后1个月
returns = prices.pct_change()
# 12个月总回报(不包括最近一个月)
trailing_12m = (1 + returns).rolling(12).apply(np.prod, raw=True) - 1
signal = trailing_12m.shift(1) # 剔除最近一个月:避免用当月信息决定当月买卖
这里的 .shift(1) 是第二个关键细节。如果没有它,你就是在用当月的价格变动来做出月初的买卖决策——这叫前视偏差(look-ahead bias),用未来的数据预测过去,会大大高估夏普比率。
一般原则:在时间点T做任何决策,只能用T时刻(或之前)的信息。拿不准的时候,就把信号滞后一期再跑跑看,如果结果差很多,说明你之前有前视偏差。
第三步:构建投资组合
def get_top_decile(date, signal_df, return_df):
# 选出当天信号值在前10%的股票
universe = signal_df.loc[date].dropna()
threshold = universe.quantile(0.9)
selected = universe[universe >= threshold].index
return selected
# 逐月计算组合收益
portfolio_returns = []
dates = signal.index[12:] # 需要至少12个月历史数据才能开始
for date in dates:
if date not in signal.index:
continue
selected = get_top_decile(date, signal, returns)
if len(selected) == 0:
portfolio_returns.append(0)
continue
# 等权重持有到下一个月
next_date_idx = signal.index.get_loc(date) + 1
if next_date_idx >= len(signal.index):
break
next_date = signal.index[next_date_idx]
next_returns = returns.loc[next_date, selected].dropna()
portfolio_returns.append(next_returns.mean())
portfolio_series = pd.Series(portfolio_returns, index=dates[:len(portfolio_returns)])
注意:这个代码很简单甚至有点幼稚。实际生产环境会用向量化回测库(vectorbt、bt、zipline)。但第一次手工回测,显式循环更容易理解,也不容易偷偷引入bug。
第四步:加入交易成本
这是第三个大坑:忽略交易成本。每月再平衡的动量策略换手率很高,每次再平衡通常有30%~60%的持仓会变化。这么大的换手,交易成本绝对不能省。
一个比较现实的假设:
TRANSACTION_COST_BPS = 10 # 每笔交易10个基点(双边)
def compute_turnover(prev_holdings, current_holdings):
# 计算两期持仓的换手率
if prev_holdings is None:
return 1.0 # 首次建仓换手100%
prev_set = set(prev_holdings)
curr_set = set(current_holdings)
# 粗略算:发生变化的股票比例
return len(prev_set.symmetric_difference(curr_set)) / (2 * len(curr_set))
# 应用交易成本
prev_holdings = None
adjusted_returns = []
for date, selected in zip(dates, holdings_history):
raw_return = portfolio_returns[i]
turnover = compute_turnover(prev_holdings, selected)
cost = turnover * (TRANSACTION_COST_BPS / 10000) * 2 # 买入+卖出各一次
adjusted_returns.append(raw_return - cost)
prev_holdings = selected
每笔10个基点其实已经很乐观了(尤其对散户)。实际交易成本(价差+佣金+滑点)大约15~50个基点,小盘股的买卖价差更大。用真实的成本一算,你的夏普比率瞬间掉0.3~0.5。如果策略在扣除成本后还能有正超额收益,那才有点意思;如果不行,那只是个“没考虑成本的策略”——现实中不存在。
什么样的结果算“正常”?
用上述修正(无幸存者偏差、信号正确滞后、交易成本合理)跑2010~2025年美国大盘动量策略,大概会得到:
- 年均超额收益(相对标普500):1%~3%(不同时间段波动很大)
- 夏普比率:约0.6~0.8(同期标普500约为0.5~0.7)
- 最大回撤:与指数差不多或更差
- 跑输标普500的年份:大约占40%
如果你的回测跑出夏普1.5+或超额收益10%以上,先假设自己代码有bug,然后去找。最可能的原因:你又不小心把幸存者偏差或前视偏差带回来了。
回测最危险的情况是看起来合理的假结果。比如动量策略跑出夏普0.9,看起来“合理”——但学术文献说0.6~0.7,那多出的0.3很可能来自某个隐性偏差。正确的反应是“找bug”,不是“发论文”。
一个最小可用回测框架的清单
第一次做回测时,你真正需要的就这几样:
- 无幸存者偏差的历史成分股数据:这是最难免费搞到的。如果搞不到,所有结果都只能当“示意”。
- 向量化收益率计算:用pandas + numpy就够了,别过早优化。
- 正确的信号滞后:到处显式加上
.shift(1),随机抽若干时间点核对一下:决策是否用了同一时期的数据。 - 交易成本层:可配置的基点/每笔,应用到换手率上。
- 基准对比:所有结果都要和基准(比如同样成分股买入持有)放一起看,才有意义。
就这些。第一次回测不要加什么滑动窗口优化、贝叶斯参数选择、市场状态识别——那些功能会给你更多自由度去拟合历史噪音,把bug藏得更深。
之后用什么?
一旦你的最小回测跑通,可以升级到更专业的工具:
- vectorbt (免费开源):快速做参数扫描
- backtrader (免费):事件驱动回测,模拟真实订单
- QuantConnect (社区版免费,生产付费):云端回测,有付费数据源
- Zipline-Reloaded (免费):机构级基础设施
数据方面:免费数据(Yahoo、Alpha Vantage)有已知质量问题,早晚会坑你。付费数据(Polygon.io、IEX Cloud、EOD Historical Data)等你过了入门阶段值得投资。
总结
一个正确的首次回测,比代码看起来要难。bug不在语法里,而在那些“没说出来的假设”(幸存者偏差、前视偏差、交易成本)——它们悄悄地把夏普比率吹上天。先搭最小框架,和已知学术结果比对,任何看起来太美的数字,先假定它是错的,直到找出原因。
真正的核心技能不是写回测代码,而是培养一种“感觉”——看到太好的结果就本能怀疑:“这里面有什么bug在捣鬼?” 这种直觉能帮你避免发布或交易那些根本不存在的“优势”。
直达网址:https://pickuma.com/for-investor/backtest-first-quant-strategy-python-walkthrough/
