VB.net 2010 视频教程 VB.net 2010 视频教程 python基础视频教程
SQL Server 2008 视频教程 c#入门经典教程 Visual Basic从门到精通视频教程
当前位置:
首页 > 编程开发 > python数据分析 >
  • 避免回测陷阱:未来函数、过度拟合、幸存者偏差

第28章 量化回测系统开发(续)
28.4 避免回测陷阱:未来函数、过度拟合、幸存者偏差
28.4.1 先讲个真实教训:我踩过的三个坑亏了20万
刚做量化那两年,我踩过所有回测的经典坑:
第一次写基本面策略,直接用季度末的财务数据回测,忘了财报是季度结束后1-2个月才披露,回测年化30%,实盘跑半年亏10%,才发现用了未来函数;
第二次优化双均线参数,把参数调成5日和19日,回测过去5年收益翻倍,实盘3个月就亏了8%,典型过度拟合;
第三次做小市值策略,回测只选了现在还上市的股票,没算退市的,回测年化40%,实盘踩了个退市股直接亏12万,踩中幸存者偏差。
三个坑加起来亏了20万才明白:回测收益再高,只要踩中任何一个陷阱,实盘必然亏vb.net教程C#教程python教程SQL教程access 2010教程得很惨。今天我就把每个陷阱的识别、验证、规避方法讲透,帮你避开我踩过的坑。

28.4.2 核心逻辑:三个陷阱的底层成因
三个陷阱本质都是「回测时用了实盘拿不到的信息」或者「回测时的环境和实盘不一致」,导致回测结果严重失真:
1.未来函数:回测时使用了交易发生之后才会产生的数据,相当于开上帝视角作弊,实盘根本拿不到这些信息;
2.过度拟合:把历史数据的偶然规律当成必然规律,参数刚好匹配历史行情,换个时间段就失效;
3.幸存者偏差:回测时只筛选了现在还存在的标的,漏掉了退市、退市整理的标的,高估了策略收益,低估了风险。

28.4.3 实战:逐个识别与规避陷阱
我们结合代码示例,逐个拆解陷阱的表现、检测方法和解决方案,所有方法都可以直接用到你自己的回测系统里。

  1. 实战代码:未来函数的检测与规避
    未来函数是最常见的回测坑,90%的新手都会踩,最典型的就是财务数据对齐错误、指标计算用了未来数据。
    python
	import pandas as pd
	import numpy as np
	from datetime import datetime, timedelta
	from typing import List, Dict
	
	# -------------------------- 错误示例:典型的未来函数 --------------------------
	def wrong_financial_backtest():
	"""错误的基本面回测:直接用季度末日期对齐财务数据"""
	# 财务数据:2023年三季报,实际披露日期是2023-10-28
	fin_data = pd.DataFrame({
	'ts_code': ['000001.SZ'],
	'quarter': ['2023Q3'],
	'end_date': ['2023-09-30'], # 季度结束日期
	'roe': [12.5]
	})
	# 行情数据:2023-09-30的行情
	price_data = pd.DataFrame({
	'trade_date': ['2023-09-30'],
	'close': [15.2]
	})
	# 错误对齐:用季度结束日期匹配行情,相当于9月30日就拿到了10月28日才披露的财报
	merged = price_data.merge(fin_data, left_on='trade_date', right_on='end_date', how='left')
	print("错误对齐结果:9月30日就拿到了三季报数据,属于未来函数!")
	print(merged[['trade_date', 'quarter', 'roe']])
	
	# -------------------------- 正确做法:按财报披露日期对齐 --------------------------
	def correct_financial_backtest():
	"""正确的基本面回测:用财报实际披露日期对齐数据"""
	# 财务数据增加披露日期字段(从Tushare/东方财富可以拿到真实披露日期)
	fin_data = pd.DataFrame({
	'ts_code': ['000001.SZ'],
	'quarter': ['2023Q3'],
	'end_date': ['2023-09-30'],
	'announce_date': ['2023-10-28'], # 真实披露日期
	'roe': [12.5]
	})
	# 行情数据只在披露日期之后才用这个财报数据
	price_data = pd.DataFrame({
	'trade_date': pd.date_range(start='2023-09-30', end='2023-10-30', freq='B')
	})
	# 正确对齐:财报披露之后的交易日才能用这个数据
	merged = []
	for _, price_row in price_data.iterrows():
	trade_date = price_row['trade_date']
	# 找所有披露日期小于等于当前交易日的最新财报
	available_fin = fin_data[fin_data['announce_date'] <= trade_date.strftime('%Y-%m-%d')]
	if not available_fin.empty:
	latest_fin = available_fin.iloc[-1]
	merged.append({
	'trade_date': trade_date,
	'quarter': latest_fin['quarter'],
	'roe': latest_fin['roe'],
	'announce_date': latest_fin['announce_date']
	})
	else:
	merged.append({
	'trade_date': trade_date,
	'quarter': None,
	'roe': None,
	'announce_date': None
	})
	merged_df = pd.DataFrame(merged)
	print("\n正确对齐结果:10月28日之后才能拿到三季报数据,和实盘一致")
	print(merged_df[['trade_date', 'announce_date', 'quarter', 'roe']].tail(5))
	
	# -------------------------- 未来函数检测工具 --------------------------
	def detect_look_ahead_bias(trade_signals: pd.DataFrame, data_date_col: str, signal_date_col: str) -> bool:
	"""
	检测未来函数:检查交易信号的生成日期是否早于数据的可用日期
	:param trade_signals: 交易信号表,包含信号生成日期和数据日期
	:param data_date_col: 数据的产生日期(比如财报披露日期、K线日期)
	:param signal_date_col: 信号生成日期
	:return: True表示存在未来函数
	"""
	# 只要有一个信号的生成日期早于数据可用日期,就存在未来函数
	look_ahead = (trade_signals[signal_date_col] < trade_signals[data_date_col]).any()
	if look_ahead:
	print(f"检测到未来函数:有{sum(trade_signals[signal_date_col] < trade_signals[data_date_col])}个信号使用了未来数据")
	else:
	print("未检测到未来函数")
	return look_ahead
	
	# 运行测试
	if __name__ == '__main__':
	wrong_financial_backtest()
	correct_financial_backtest()
	
	# 测试检测工具
	test_signals = pd.DataFrame({
	'signal_date': pd.to_datetime(['2023-09-30', '2023-10-29']),
	'data_announce_date': pd.to_datetime(['2023-10-28', '2023-10-28'])
	})
	detect_look_ahead_bias(test_signals, 'data_announce_date', 'signal_date')

逐行讲解:
未来函数最常见的场景就是财务数据对齐,A股的年报是每年4月30日之前披露,一季报4月,半年报8月,三季报10月,必须用实际披露日期对齐,不能用季度结束日期。
技术指标也容易出未来函数,比如用整根K线的收盘价计算当天的均线,然后用这个均线信号在当天开盘交易,其实收盘价格是交易结束后才知道的,正确做法是用前一个交易日的均线信号做当天的交易。
检测方法很简单:所有用来生成信号的数据,其可用日期必须早于信号生成的交易日期,只要有一个反例就说明有未来函数。

28.4.4 实战:过度拟合的检测与规避
过度拟合是优化参数的时候最容易踩的坑,看起来回测收益很高,其实是刚好匹配了历史数据的噪声,实盘根本没用。
python

	# -------------------------- 过度拟合的典型表现:参数敏感 --------------------------
	def test_parameter_sensitivity(strategy_class, param_range: List, test_data):
	"""
	参数敏感性测试:看策略收益随参数变化是不是平滑,波动太大就是过度拟合
	:param strategy_class: 策略类
	:param param_range: 要测试的参数范围
	:param test_data: 回测数据
	:return: 参数收益列表
	"""
	results = []
	for param in param_range:
	# 初始化策略,只改一个参数,其他不变
	strategy = strategy_class(short_window=param, long_window=20)
	# 运行回测
	engine = BacktestEngine(start_date='20180101', end_date='20231231')
	engine.load_data(['000001.SZ'])
	engine.run_backtest(strategy.on_bar)
	annual_return = engine.calculate_performance()[0].loc[1, '数值']
	annual_return = float(annual_return.strip('%'))/100
	results.append({'param': param, 'annual_return': annual_return})
	return pd.DataFrame(results)
	
	# -------------------------- 正确做法:样本外测试 + 参数平滑 --------------------------
	def walk_forward_test(strategy_class, param_range, data, train_window=3, test_window=1):
	"""
	滚动样本外测试(Walk Forward Test):避免过度拟合的黄金标准
	:param train_window: 训练窗口年数
	:param test_window: 测试窗口年数
	"""
	years = sorted(data['trade_date'].dt.year.unique())
	total_return = 1.0
	for i in range(train_window, len(years), test_window):
	# 训练集:前train_window年的数据,用来选最优参数
	train_years = years[i-train_window:i]
	train_data = data[data['trade_date'].dt.year.isin(train_years)]
	# 测试集:后test_window年的数据,完全不用来调参数
	test_years = years[i:i+test_window]
	test_data = data[data['trade_date'].dt.year.isin(test_years)]
	# 在训练集选最优参数
	best_param = None
	best_return = -np.inf
	for param in param_range:
	strategy = strategy_class(short_window=param, long_window=20)
	engine = BacktestEngine(start_date=f"{train_years[0]}0101", end_date=f"{train_years[-1]}1231")
	engine.load_data(['000001.SZ'])
	engine.run_backtest(strategy.on_bar)
	ret = engine.calculate_performance()[0].loc[1, '数值']
	ret = float(ret.strip('%'))/100
	if ret > best_return:
	best_return = ret
	best_param = param
	# 用最优参数在测试集回测,测试集完全没参与参数优化
	strategy = strategy_class(short_window=best_param, long_window=20)
	engine = BacktestEngine(start_date=f"{test_years[0]}0101", end_date=f"{test_years[-1]}1231")
	engine.load_data(['000001.SZ'])
	engine.run_backtest(strategy.on_bar)
	test_ret = engine.calculate_performance()[0].loc[0, '数值']
	test_ret = float(test_ret.strip('%'))/100 + 1
	total_return *= test_ret
	print(f"训练期{train_years},最优参数{best_param},测试期{test_years},测试收益{test_ret-1:.2%}")
	print(f"滚动样本外测试总收益:{total_return-1:.2%}")
	return total_return
	
	# 运行测试示例
	if __name__ == '__main__':
	# 双均线参数敏感性测试
	param_range = list(range(3, 15)) # 测试短期均线从3到14
	results = test_parameter_sensitivity(DoubleMAStrategy, param_range, None)
	# 如果参数从5变到6,收益从20%掉到5%,说明参数太敏感,过度拟合了
	print("\n参数敏感性测试结果:")
	print(results)
	# 正常的策略收益应该随参数变化是平滑的,不会跳崖式下跌

逐行讲解:
过度拟合的典型特征:参数稍微变一点,收益就大幅波动;回测期表现好,换个时间段就大幅跑输;交易次数非常多,夏普比率极高。
滚动样本外测试是规避过度拟合最有效的方法:把数据分成训练集和测试集,训练集用来选参数,测试集完全不参与优化,测试集的收益才是真实的收益。如果滚动测试的收益比全样本回测低30%以上,说明策略过度拟合了。
规避原则:策略的参数尽量不要超过3个,参数越少越不容易拟合;不要为了提高回测收益去调奇怪的参数,比如把均线调成5日和19日,直接用市场公认的5/20、10/30就行。

28.4.5 实战:幸存者偏差的检测与规避
幸存者偏差最容易出现在选股类策略里,尤其是小市值、ST、重组类策略,回测时漏掉退市股会让收益虚高30%以上。
python

	# -------------------------- 错误示例:只包含上市股票的回测 --------------------------
	def wrong_survivorship_backtest():
	"""错误的回测:只选当前还在上市的股票,漏掉退市股"""
	# 错误的股票列表:只有当前上市的股票,没有包含2018-2023年退市的100多只股票
	stock_list = pro.stock_basic(list_status='L', fields='ts_code')['ts_code'].tolist()
	# 回测小市值策略,回测收益会虚高,因为已经退市的垃圾股被排除了
	print(f"错误的股票池:只包含当前上市的{len(stock_list)}只股票,存在幸存者偏差")
	
	# -------------------------- 正确做法:包含所有历史上市标的 --------------------------
	def correct_survivorship_backtest():
	"""正确的回测:包含所有历史上上市过的股票,包括退市的"""
	# 获取所有历史上市的股票,包括退市的(list_status='D'是退市,'P'是暂停上市)
	all_stocks = pro.stock_basic(list_status='L,D,P', fields='ts_code,name,list_date,delist_date')
	print(f"正确的股票池:包含所有历史上市的{len(all_stocks)}只股票,其中退市{len(all_stocks[all_stocks['delist_date'].notna()])}只")
	# 回测的时候,每个交易日只选当时还在上市的股票
	def filter_stocks_by_date(trade_date: datetime):
	"""按交易日期过滤当时可交易的股票"""
	trade_date_str = trade_date.strftime('%Y%m%d')
	# 上市日期 <= 交易日期,且(没有退市日期 或 退市日期 > 交易日期)
	available = all_stocks[
	(all_stocks['list_date'] <= trade_date_str) &
	((all_stocks['delist_date'].isna()) | (all_stocks['delist_date'] > trade_date_str))
	]
	return available['ts_code'].tolist()
	return filter_stocks_by_date
	
	# -------------------------- 幸存者偏差检测 --------------------------
	def detect_survivorship_bias(stock_list: List[str], start_year: int) -> bool:
	"""
	检测幸存者偏差:看股票池里是否包含历史退市股
	"""
	# 获取历史退市股票
	delisted = pro.stock_basic(list_status='D', fields='ts_code,delist_date')
	delisted['delist_year'] = pd.to_datetime(delisted['delist_date']).dt.year
	# 回测区间内退市的股票
	period_delisted = delisted[delisted['delist_year'] >= start_year]
	# 看这些退市股有没有被包含在股票池里
	missing = len([code for code in period_delisted['ts_code'] if code not in stock_list])
	if missing > 0:
	print(f"检测到幸存者偏差:回测区间内有{missing}只退市股未被包含在股票池里,收益会虚高")
	return True
	else:
	print("未检测到幸存者偏差")
	return False
	
	# 运行测试
	if __name__ == '__main__':
	wrong_survivorship_backtest()
	filter_func = correct_survivorship_backtest()
	# 测试2020年1月1日的可交易股票
	available_2020 = filter_func(datetime(2020,1,1))
	print(f"2020年1月1日可交易股票数量:{len(available_2020)}")

逐行讲解:
A股2018年到2023年退市了超过200只股票,这些股票退市前都会大跌,如果你回测小市值策略的时候没把这些股票包含进去,相当于默认你总能避开所有退市股,回测收益肯定虚高。
正确的做法是每次选股的时候,只选当时还在上市的股票,退市的股票在退市前必须纳入选股范围,和实盘一模一样,你实盘也不知道哪只股票会退市。
小市值、ST、亏损股策略对幸存者偏差最敏感,回测这类策略的时候必须检查有没有包含退市股,不然回测收益再高都是假的。

28.4.6 基础知识拓展:三个陷阱的避坑清单

陷阱类型 高发场景 检测方法 规避方案
未来函数 基本面策略、跨周期指标、复权价格计算 检查所有信号生成时用的数据,可用日期是否早于交易日期;把回测结束日期提前半年,看信号逻辑是否成立 财务数据用披露日期对齐;指标计算只用前一个交易日的数据;用未复权价格交易,复权只用来计算收益
过度拟合 参数优化、多因子策略、高频交易 参数敏感性测试,参数变化10%收益波动超过20%就是拟合;滚动样本外测试收益比全样本低30%以上就是拟合 参数不超过3个;用市场公认的常规参数;滚动样本外测试通过再实盘
幸存者偏差 选股策略、小市值策略、ST策略 检查股票池是否包含回测区间内退市的股票;回测时随机去掉10%的股票,看收益是否大幅下降 股票池包含所有历史上市标的;每个交易日动态过滤当时可交易的股票;退市股纳入回测范围

28.4.7 总结:回测的核心原则
1.回测是用来证伪,不是用来证实的:回测收益高的策略不一定能赚钱,但回测有问题的策略一定赚不到钱,回测的核心是帮你排除有坑的策略,不是帮你找一个收益最高的参数。
2.回测越苛刻,实盘越靠谱:把交易成本设高一点,滑点设大一点,参数用常规的,股票池包含退市股,哪怕回测收益低一点,实盘的确定性更高。
3.实盘前必须过三关:未来函数检测通过、滚动样本外测试收益稳定、无幸存者偏差,三个条件都满足再上实盘,不要拿自己的钱去踩坑。
到这里,回测系统的全部核心内容就讲完了,从数据输入、策略开发、指标计算到陷阱规避,你已经具备了开发靠谱回测系统的全部能力。下一章我们讲实盘交易系统的开发,把回测好的策略真正对接实盘,实现自动化交易。
下一章咱们就讲实盘交易接口对接与订单管理系统开发。


转载请注明出处:


相关教程