本章导航
- 学习目标
- 故事引入:一个完美的回测,一场真实的噩梦
- 核心知识:回测的六大陷阱
- AI实践:让AI帮你识别回测陷阱
- 动手练习:回测质量检查清单代码
- 本章测验
- 下一章预告
学习目标
回测是量化策略的试衣间——你可以在投入真金白银之前,看看策略在历史数据上的表现。但试衣间的镜子可能变形,衣服可能加了内衬,灯光可能经过特殊设计。回测结果和实盘结果之间的差距,是量化交易中最昂贵的一堂课。
本章结束后,你将能够:
- 识别六大常见回测陷阱:未来函数、幸存者偏差、lookahead bias、过度交易、成本低估、过拟合
- 建立回测质量检查清单:在相信任何回测结果之前,系统性地排查问题
- 理解回测与实盘的核心差异:为什么即使完美的回测也无法保证实盘盈利
- 掌握样本外验证的正确姿势:如何设计真正能检验策略稳健性的验证流程
故事引入:一个完美的回测,一场真实的噩梦
2019年,程序员小张花了三个月开发了一个AI选股策略。回测结果令人窒息地美丽:2015-2018年,年化收益127%,最大回撤仅8%,夏普比率3.5。曲线几乎是一条45度向上的直线。
小张辞掉了工作,凑了50万本金,信心满满地实盘运行。前三个月还算平稳,第四个月开始连续亏损,第六个月净值跌破0.7。他止损出局时,50万只剩31万。
问题出在哪里?事后复盘,小张的策略至少犯了四个致命错误:
- 未来函数:策略使用了当日收盘价作为买入信号,但交易执行也是在当日收盘价——这在现实中不可能,因为你收盘前不知道收盘价是多少。
- 幸存者偏差:回测使用的股票池只包含当前还活着的股票,所有退市、ST、停牌的股票都被悄悄删除了。
- 成本忽略:回测假设零手续费、零滑点。策略每天交易20只股票,实际交易成本eat掉了大部分利润。
- 过拟合:策略有17个参数,通过网格搜索在历史数据上优化到了极致——它完美拟合了历史噪声,但对未来一无所知。
核心知识:回测的六大陷阱
陷阱一:未来函数(Lookahead Bias)
未来函数是最隐蔽、最常见、也最具毁灭性的回测错误。它的本质是:你在做决策时使用了当时还无法获得的信息。
典型的未来函数包括:
- 用当日收盘价计算信号并在当日执行交易。正确的做法是用前一日收盘价或当日开盘价计算信号,次日开盘执行。
- 用季度财报数据在报告发布日之前选股。很多回测框架默认财报日期是季度末,但实际上财报通常在季度结束后30-60天才发布。
- 用全样本均值/标准差做标准化。如果你在2015年计算Z-score时使用了2020年的数据,那就是未来函数。
- 用当前成分股回溯历史。如果回测时你用2024年的沪深300成分股去跑2015年的策略,那些2015年还没被纳入指数的股票会被错误地包含进来。
# ❌ 错误示例:未来函数
signal = df['close'] > df['ma20']
execute_price = df['close']
# 问题:收盘前你不知道收盘价是否高于MA20
# ✅ 正确做法:使用滞后数据
signal = df['close'].shift(1) > df['ma20'].shift(1)
execute_price = df['open']
# ❌ 错误示例:财报日期错误
df['use_earnings'] = df['quarter_end']
# ✅ 正确做法:使用实际公告日期
df['use_earnings'] = df['announcement_date']
陷阱二:幸存者偏差(Survivorship Bias)
假设你在2024年下载了一份A股历史数据,然后开始回测。你有没有想过,这份数据里包含的所有股票,都是到今天还活着的?那些退市、破产、被借壳的公司呢?它们被悄无声息地删除了。
如果你在2008年用某个价值因子选股,你很可能会选中后来ST、退市的股票。但在2024年的数据集中,这些公司不存在了,于是你的回测结果异常美丽。
如何修复:必须使用包含已退市股票的历史数据。对于A股,可以通过专业数据商获取退市股票列表,并在回测时把它们加回来。
陷阱三:成本低估
新手回测最常见的假设是零成本交易。现实中,成本是一座大山:
- 手续费:A股券商佣金约万1-万3,最低5元。对小额交易影响巨大。
- 印花税:A股卖出时征收千分之0.5(2023年减半后)。高频策略尤其受伤。
- 滑点:你的买单可能以比预期更高的价格成交,卖单以更低的价格成交。对中小盘股,滑点可能达到0.1%-0.5%。
- 冲击成本:大单买入会推高价格,大单卖出会压低价格。对流动性差的股票,冲击成本可能远超手续费。
def apply_transaction_costs(returns, turnover, fee_rate=0.00025,
stamp_tax=0.0005, slippage=0.001):
"""
对收益率扣除交易成本
turnover: 日换手率(买卖双边)
fee_rate: 佣金率(默认万2.5)
stamp_tax: 印花税(卖出时,默认万5)
slippage: 滑点(双边,默认千1)
"""
buy_cost = turnover * fee_rate + turnover * slippage
sell_cost = turnover * (fee_rate + stamp_tax + slippage)
total_cost = buy_cost + sell_cost
net_returns = returns - total_cost
return net_returns
# 示例:一个日换手率50%的策略,年化成本有多恐怖?
daily_turnover = 0.5
annual_turnover = daily_turnover * 252
cost_per_year = annual_turnover * (0.00025*2 + 0.0005 + 0.001*2)
print(f"年化交易成本: {cost_per_year:.2%}")
# 结果可能高达30-40%,足以吞噬大部分策略收益
陷阱四:过拟合(Overfitting)
过拟合是量化策略的癌症。它发生在策略参数过多、优化过于精细的情况下——策略完美拟合了历史数据的每一个波动,包括噪声。
识别过拟合的信号:
- 参数过多:策略有10个以上可调参数,而你只回测了5年数据。参数越多,过拟合风险越大。
- 收益曲线过于平滑:真实策略的收益曲线应该是有波动的。如果回测曲线像一条直线,很可能你在某处偷偷作弊了。
- 样本外表现骤降:策略在训练期(2015-2020)表现极好,在测试期(2021-2024)表现极差。
- 参数高原过于狭窄:最优参数点附近稍微变动,收益就大幅下降。这说明策略依赖的是历史巧合,而非稳健规律。
假设你有N个独立交易信号,每个信号有50%的概率在未来有效。如果你测试了100个信号, purely by chance,大约有5个信号会在5%显著性水平下看起来有效(这就是多重比较问题)。
更直观的例子:你扔100次硬币,有人告诉你如果某次连续出现7次正面,就说明这枚硬币有问题。你扔着扔着,某一段确实出现了7次正面——但这完全是随机的。如果你在发现7次正面之后才开始下注赌正面,期望收益仍然是零。
回测中的过拟合,本质上就是在历史数据中寻找碰巧出现的7次正面,并误以为这是规律。
陷阱五:过度交易与容量幻觉
很多策略回测时假设可以无限量地以收盘价成交。现实中:
- 小盘股的日成交额可能只有几千万,你的100万买单可能直接吃掉几个价位。
- 涨停板买不到,跌停板卖不出。回测时的市价单在极端行情下可能完全无法执行。
- 策略容量有限。一个在小资金(10万)下有效的策略,放大到1000万可能就完全失效。
陷阱六:隐含假设与数据质量
数据本身也可能撒谎:
- 前复权陷阱:前复权价格在未来分红送转时会调整历史数据。如果你用前复权价格计算技术指标,然后在未来函数的意义上使用这些指标,就会产生偏差。
- 停牌处理:回测时是否跳过了停牌日?如果策略在停牌前发出卖出信号,实际根本无法卖出。
- 除权除息日:价格跳空是否被正确处理?如果不处理,除权日的巨大跳空会被误判为暴涨或暴跌。
与AI一起练习
练习1:让AI审查你的回测代码
请审查以下回测代码,找出所有潜在的未来函数、幸存者偏差、成本低估问题:
[粘贴你的回测代码]
要求:逐行标注问题,给出修改建议,并估算这些问题对回测收益的高估幅度。
练习2:让AI设计一个回测质量检查清单
请为我设计一个量化策略回测质量检查清单(Checklist),包含:
1. 数据质量检查(10项)
2. 未来函数排查(8项)
3. 成本与流动性检查(6项)
4. 过拟合风险评估(5项)
每项给出检查方法和通过标准。
练习3:让AI对比回测与实盘差异
我有一个策略回测年化收益40%,夏普2.0,最大回撤10%。
请帮我分析:如果这个策略实盘运行,哪些因素会导致收益下降?
请给出各项因素的量化估算(如滑点影响、容量限制、情绪偏差等),
并预测实盘可能的收益区间。
动手练习:回测质量检查清单代码
"""回测质量检查器 - 在相信回测结果之前,先运行这个"""
import pandas as pd
import numpy as np
class BacktestAuditor:
def __init__(self, price_df, signal_df, trades_df):
self.price = price_df
self.signal = signal_df
self.trades = trades_df
def check_lookahead_bias(self):
"""检查未来函数"""
issues = []
# 检查信号是否使用了未来数据
if 'close' in self.signal.columns:
if (self.signal.index == self.price.index).all():
issues.append("警告:信号与价格同日期,可能存在未来函数")
return issues
def check_survivorship_bias(self):
"""检查幸存者偏差"""
total_stocks = self.price['code'].nunique()
# 如果股票池在过去几年一成不变,可能存在幸存者偏差
yearly_counts = self.price.groupby(
self.price.index.year)['code'].nunique()
if yearly_counts.std() < total_stocks * 0.05:
return ["警告:股票池数量年度变化极小,可能存在幸存者偏差"]
return []
def estimate_costs(self, turnover_estimate=0.3):
"""估算交易成本对收益的影响"""
fee = 0.00025
stamp = 0.0005
slip = 0.001
daily_cost = turnover_estimate * (fee*2 + stamp + slip*2)
annual_cost = daily_cost * 252
return annual_cost
def full_audit(self):
print("="*50)
print("回测质量审计报告")
print("="*50)
lb = self.check_lookahead_bias()
print(f"
[未来函数检查] 发现 {len(lb)} 个问题")
for issue in lb:
print(f" - {issue}")
sb = self.check_survivorship_bias()
print(f"
[幸存者偏差检查] 发现 {len(sb)} 个问题")
for issue in sb:
print(f" - {issue}")
cost = self.estimate_costs()
print(f"
[成本估算] 年化交易成本约: {cost:.2%}")
print("="*50)
# 使用示例
# auditor = BacktestAuditor(price_df, signal_df, trades_df)
# auditor.full_audit()
本章测验
下一章预告
我们已经知道了回测的六大陷阱,但有一个陷阱特别重要、特别隐蔽,值得单独用一整章来讨论——那就是过拟合。
第23章将深入探讨过拟合的本质:为什么你的策略在回测中战无不胜,却在实盘中一败涂地?我们将学习如何量化过拟合风险(CSCV方法)、如何用组合对称交叉验证检测过拟合、以及如何设计对过拟合免疫的策略结构。