03 · 回测陷阱实证¶
⚠️ 免责声明:本研究为量化/短期交易方法与可行性的探讨。所有回测均为历史模拟,不代表未来收益,不构成任何投资建议。真实交易受流动性、冲击成本、税费、心理与执行误差影响,结果通常更差。量化与短期交易可致重大本金亏损。读者需自行承担一切决策后果。
数据来源:A股 akshare(新浪后端) / 美股 yfinance,限流时降级腾讯行情;全部缓存于
data/*.csv,每个数字均可在本 notebook 单元复现。
这是全研究最重要的一章。 即便你含了真实成本(02),回测本身还能用三种方式系统性骗你。每个陷阱都用代码做出来给你看有多坑:
- 过拟合 / 数据窥探:样本内调参调出漂亮曲线,样本外崩掉。
- 前视偏差:偷看未来一丁点,净值就上天。
- 多重检验 / 幸存者偏差:随机生成一堆策略,总能挑出「看起来很强」的。
营销话术几乎全建立在这三个陷阱上。看懂它们,能挡掉90%的「量化课程」和「策略源码」。
import numpy as np, pandas as pd, matplotlib.pyplot as plt
import qr_data as q, qr_bt as bt
bt.setup_chinese_font(); np.random.seed(42)
data=q.load_all(verbose=False)
陷阱一 · 过拟合(样本内调参,样本外崩盘)¶
做法:在样本内(2014–2019)对双均线的 (快线, 慢线) 做网格搜索,按夏普挑「最优参数」,画出漂亮的样本内热力图;然后把这套「最优参数」原封不动用到样本外(2020–2024)。
px=data['QQQ']['close']
IS=px.loc[:'2019-12-31']; OOS=px.loc['2020-01-01':]
fasts=range(5,55,5); slows=range(60,210,10)
heat=pd.DataFrame(index=list(fasts),columns=list(slows),dtype=float)
best=(-9,None)
for f in fasts:
for s in slows:
sig=bt.sma_cross_signal(IS,f,s); b=bt.backtest(IS,sig,bt.COST_US)
sh=bt.perf_stats(b['strat_ret']).get('夏普',np.nan); heat.loc[f,s]=sh
if sh>best[0]: best=(sh,(f,s))
print('样本内最优参数:',best[1],'样本内夏普=%.2f'%best[0])
fig,ax=plt.subplots(figsize=(10,4.5))
im=ax.imshow(heat.values.astype(float),aspect='auto',cmap='RdYlGn',origin='lower')
ax.set_xticks(range(len(list(slows)))); ax.set_xticklabels(list(slows),fontsize=7)
ax.set_yticks(range(len(list(fasts)))); ax.set_yticklabels(list(fasts),fontsize=7)
ax.set_xlabel('慢线'); ax.set_ylabel('快线'); ax.set_title('QQQ 双均线 样本内(2014-2019)夏普热力图')
plt.colorbar(im); plt.tight_layout(); plt.show()
样本内最优参数: (40, 120) 样本内夏普=0.96
f,s=best[1]
# 用样本内最优参数,分别看 IS 和 OOS 表现
sigIS=bt.sma_cross_signal(IS,f,s); bIS=bt.backtest(IS,sigIS,bt.COST_US)
sigOOS=bt.sma_cross_signal(px,f,s); bOOS_full=bt.backtest(px,sigOOS,bt.COST_US)
bOOS=bOOS_full.loc['2020-01-01':]
rows=[bt.perf_stats(bIS['strat_ret'],f'样本内 最优({f},{s})'),
bt.perf_stats(bOOS['strat_ret'],f'样本外 同参数'),
bt.perf_stats(OOS.pct_change(),'样本外 买入持有')]
display=bt.stats_table(rows)[['年化收益','夏普','最大回撤']]
print(display.to_string())
fig,ax=plt.subplots(1,2,figsize=(13,4.5))
ax[0].plot(bIS['equity'],color='green'); ax[0].set_title(f'样本内净值(参数{f},{s}) 夏普{best[0]:.2f}'); ax[0].set_yscale('log')
e=(1+bOOS['strat_ret']).cumprod(); bh=(1+OOS.pct_change()).cumprod()
ax[1].plot(e,color='red',label='同参数 样本外'); ax[1].plot(bh,color='black',label='买入持有')
ax[1].set_title('样本外: 漂亮参数失效'); ax[1].legend(); ax[1].set_yscale('log')
plt.tight_layout(); plt.show()
年化收益 夏普 最大回撤 名称 样本内 最优(40,120) 13.61% 0.96 -21.18% 样本外 同参数 14.26% 0.75 -28.56% 样本外 买入持有 19.79% 0.83 -35.12%
解读:样本内挑出的「最优参数」夏普亮眼(≈0.96),搬到样本外明显退化、且跑输买入持有。热力图上颜色斑驳——说明夏普对参数极其敏感,所谓「最优」只是拟合了2014-2019的噪声。你网格搜索得越细、试的参数越多,样本内越漂亮、样本外越靠不住。这就是过拟合的本质。
陷阱二 · 前视偏差(偷看未来)¶
最隐蔽的代码bug。演示两个层次:
- 轻微泄漏:信号用当日收盘算,却当日收盘就成交(lag=0) vs 正确的次日成交(lag=1)。
- 极端泄漏(预言机):直接按「明天涨跌」开仓——只偷看一天,净值就荒谬到上天。
px=data['SPY']['close']
# 同一个信号:「当日收盘上涨则持有」。
# lag=1 => 用【昨日】涨跌决定今日仓位 = 合法的隔夜动量;
# lag=0 => 用【今日】涨跌决定今日仓位 = 穿越(交易时点根本不知道今日收盘)。
sig=(px.pct_change()>0).astype(float)
b_ok =bt.backtest(px,sig,bt.COST_ZERO,lag=1) # 正确对齐
b_leak=bt.backtest(px,sig,bt.COST_ZERO,lag=0) # 前视泄漏
fig,ax=plt.subplots(1,2,figsize=(13,4.5))
ax[0].plot(b_ok['equity'],label='正确(用昨日信息)',color='black')
ax[0].plot((1+px.pct_change()).cumprod(),label='买入持有',color='gray',ls=':')
ax[0].set_title('SPY 合法隔夜动量(无前视)'); ax[0].legend(); ax[0].set_yscale('log')
ax[1].plot(b_leak['equity'],color='red',label='前视(偷看今日收盘)')
ax[1].plot(b_ok['equity'],color='black',label='正确')
ax[1].set_title('同一信号: 仅 lag 差一天'); ax[1].legend(); ax[1].set_yscale('log')
plt.tight_layout(); plt.show()
print('正确(lag=1)净值 %.2f 夏普 %.2f'%(b_ok['equity'].iloc[-1],bt.perf_stats(b_ok['strat_ret'])['夏普']))
print('前视(lag=0)净值 %.3e 夏普 %.2f <- 只把成交时点提前一天'%(b_leak['equity'].iloc[-1],bt.perf_stats(b_leak['strat_ret'])['夏普']))
print('放大倍数 %.0fx'%(b_leak['equity'].iloc[-1]/b_ok['equity'].iloc[-1]))
正确(lag=1)净值 1.39 夏普 0.33 前视(lag=0)净值 3.555e+04 夏普 9.30 <- 只把成交时点提前一天 放大倍数 25530x
解读:完全相同的信号,仅把成交时点提前一天(lag 从 1 改成 0),净值就从个位数飙到天文数字、夏普高到不真实——因为 lag=0 等于「在收盘前就知道了收盘价」,每个上涨日都精准踩中。
现实中前视偏差往往是无意的代码bug:用收盘价算信号又用同一收盘价成交、用了未来才公布的财报、用了复权/重述后才知道的数据……任何「好到不真实」的回测,先怀疑前视偏差。 一个 shift 的方向错误,足以凭空造出圣杯。
陷阱三 · 多重检验 / 幸存者偏差(随机也能挑出明星)¶
做法:生成 500 个纯随机择时策略(每天随机持有/空仓),作用在真实 SPY 上,算每个的夏普。如果你只展示最好的那个,它看起来像个能发表的「策略」——但它一无所知。
(为剔除市场本身的上涨,这里用随机多/空而非随机持有/空仓,使随机策略期望夏普≈0,凸显「最佳者纯属运气」。)
px=data['SPY']['close']; ret=px.pct_change().fillna(0)
N=500; T=len(px); rng=np.random.default_rng(7)
sharpes=[]; best_eq=None; best_sh=-9
for i in range(N):
pos=pd.Series(np.where(rng.random(T)<0.5,1.0,-1.0),index=px.index)
sr=(pos.shift(1).fillna(0)*ret)
s=bt.perf_stats(sr).get('夏普',np.nan)
sharpes.append(s)
if s>best_sh: best_sh=s; best_eq=(1+sr).cumprod()
sharpes=np.array(sharpes)
print(f'{N}个随机策略 夏普: 均值{sharpes.mean():.2f} 最大{sharpes.max():.2f} 最小{sharpes.min():.2f}')
print(f'其中夏普>0.5("看起来可交易")的有 {(sharpes>0.5).sum()} 个,纯属运气')
fig,ax=plt.subplots(1,2,figsize=(13,4.5))
ax[0].hist(sharpes,bins=30,color='steelblue',edgecolor='white')
ax[0].axvline(sharpes.max(),color='red',ls='--',label=f'最佳={sharpes.max():.2f}')
ax[0].axvline(0,color='black',lw=0.8); ax[0].set_title(f'{N}个随机策略的夏普分布'); ax[0].legend(); ax[0].set_xlabel('夏普')
ax[1].plot(best_eq,color='red',label=f'"最佳"随机策略 夏普{best_sh:.2f}')
ax[1].plot((1+ret).cumprod(),color='black',label='SPY买入持有')
ax[1].set_title('只展示最佳的那个: 看起来很能打'); ax[1].legend(); plt.tight_layout(); plt.show()
500个随机策略 夏普: 均值-0.02 最大0.76 最小-0.83
其中夏普>0.5("看起来可交易")的有 14 个,纯属运气
解读:500个随机策略的夏普近似以0为中心分布,纯靠运气总有一批夏普>0.5。如果某「大V/课程」只给你看回测最好的那个策略(而不告诉你他试了多少个),你看到的就是这张图里被红线挑出来的幸运儿——这就是数据窥探 + 幸存者偏差。
推论:
- 公开的「年化50%策略」≈ 幸存者;失败的99个不会有人晒。
- 实盘业绩榜上的明星基金,也是几千只里幸存下来的——别把运气当能力。
- 防御:样本外检验、多重检验校正(Bonferroni/Deflated Sharpe)、坚持经济学逻辑先行。
补充 · 为什么「随机也能赢」:理论上界¶
试 N 个独立策略,最佳夏普的期望随 N 增长(≈ 极值统计)。试得越多,越容易「挖出」假信号。
fig,ax=plt.subplots(figsize=(9,4))
for n in [10,50,200,1000]:
mx=[max(rng.normal(0,sharpes.std(),n)) for _ in range(300)]
ax.scatter([n]*len(mx),mx,alpha=0.15,s=8)
ax.set_xscale('log'); ax.set_xlabel('尝试的策略个数 N(对数)'); ax.set_ylabel('最佳夏普(随机噪声)')
ax.set_title('试得越多, 越能"挖到"高夏普——哪怕全是噪声'); plt.tight_layout(); plt.show()
解读:横轴是你尝试的策略/参数个数,纵轴是其中最佳夏普。随 N 上升,最佳夏普稳步抬高,而这全是噪声。「我试了几百个参数终于找到一个好的」——这句话本身就是危险信号。
小结:回测可信度检查清单¶
把任何回测(包括别人卖给你的、和你自己写的)按下面逐条审:
- 样本外:有没有留出从未参与调参的时段?OOS 表现垮不垮?
- 成本:含佣金/印花税/滑点了吗?换手多少?(见02)
- 前视:信号用的数据在交易时点真的可得吗?成交时点对齐了吗?
- 多重检验:试了多少参数/策略?只报最好的吗?
- 幸存者:标的池含退市/失败者吗?还是只挑了事后赢家?
- 再现性:固定seed了吗?换个区间/标的还成立吗?
任何一条不过关,这个回测就不可信。 下一步 04 把01-03的硬数据汇总成可行性结论。