第 8 章 · 01 多重检验与 Deflated Sharpe 本节摘要:本节回答一个常被忽视却致命的问题——为什么「我试了 100 个策略,挑出夏普最高的那个」几乎注定是一场统计幻觉。回测越多,「偶然表现出色」的策略越容易出现,这就是多重检验(multiple testing)问题。本节讲清三件事:回测过拟合与数据窥探(data-snooping)如何制造虚假显著性;Bailey 与 López de Prado 提出的 Deflated Sharpe Ratio(简称 DSR)如何在「策略数量、收益非正态、样本较短」三重污染下修正夏普比率的显著性判断;以及如何用一段简洁的 Python 代码估计「期望的最大夏普比率」,把运气从能力中剥离。读完本节,你会对任何「漂亮回测」保持应有的怀疑。
本节摘要:本节回答一个常被忽视却致命的问题——为什么「我试了 100 个策略,挑出夏普最高的那个」几乎注定是一场统计幻觉。回测越多,「偶然表现出色」的策略越容易出现,这就是多重检验(multiple testing)问题。本节讲清三件事:回测过拟合与数据窥探(data-snooping)如何制造虚假显著性;Bailey 与 López de Prado 提出的 Deflated Sharpe Ratio(简称 DSR)如何在「策略数量、收益非正态、样本较短」三重污染下修正夏普比率的显著性判断;以及如何用一段简洁的 Python 代码估计「期望的最大夏普比率」,把运气从能力中剥离。读完本节,你会对任何「漂亮回测」保持应有的怀疑。
内容来源:原项目
08_ml4t_workflow/01_multiple_testing/deflated_sharpe_ratio.py,汉化并套用体系化模板。
⚠️ 学习提示:本节涉及资金风险判断。一个未经多重检验修正的高夏普策略,实盘表现往往大幅衰减——「过拟合的回测」是量化交易亏钱的首要原因之一。
阅读完本节,你应当能够:
deflated_sharpe_ratio.py 的解析解与数值解实现。回测(backtest)是用历史数据模拟一个策略,目标是判断它在未见过的市场中是否还能赚。但历史数据有限、市场不断变化,实现细节中暗藏三类系统性偏差:
| 陷阱类别 | 典型表现 | 后果 |
|---|---|---|
| 数据 | 用了未来才公布的数据;只保留活下来的股票 | 收益虚高 |
| 模拟 | 假设总能以收盘价成交、忽略冲击成本 | 收益虚高 |
| 统计 | 试了 200 个策略,挑出夏普最高的 | 显著性虚高 |
前两类靠工程手段能缓解(用点在时数据、加滑点手续费),第三类最隐蔽——它不是 bug,而是「选择」本身造成的。López de Prado 在这块发表过大量研究,并提供了在线的过拟合模拟器供直观感受。
💡 核心心法:回测不是「证明策略有效」的法庭,而是「收集证据决定是否值得上实盘」的筛子。筛子本身有偏,你必须知道偏在哪里。
扔 1 枚硬币,正面概率 0.5;但同时扔 100 枚,至少有一枚连续 10 次正面的概率并不小。如果你只盯着那枚「连掷 10 次正面」的硬币看,会误以为它有魔法——其实它只是被你「事后」选出来的。
策略回测同理。如果你在同一份历史数据上试了 N 个候选策略,然后挑出夏普最高的,这个「最高」里混进了选择效应:即便所有策略的真实期望夏普都是 0,纯靠运气,N 越大,观察到的最大夏普也越高。
Bailey 与 López de Prado 推导了在「策略相互独立、收益正态」假设下,试 N 次能期望看到的最大夏普。原项目脚本的核心函数如下:
from scipy.stats import ss def get_analytical_max_sr(mu, sigma, num_trials): """计算期望的最大夏普比率(解析解)""" emc = 0.5772156649 # 欧拉-马歇罗尼常数 maxZ = ((1 - emc) * ss.norm.ppf(1 - 1. / num_trials) + emc * ss.norm.ppf(1 - 1 / (num_trials * np.e))) return mu + sigma * maxZ
这里 mu、sigma 是单次试验夏普分布的均值与标准差,num_trials 是试的策略数。关键变量是 maxZ——它是「N 次试验中最大标准正态分位数」的期望,随 N 单调上升。换句话说:试的策略越多,「最佳」的门槛被自然抬高,但这是运气的抬高,不是能力的抬高。
| 试的策略数 N | 含义 |
|---|---|
| 1 | 单个策略,夏普显著性与平时理解一致 |
| 10 | 期望最大夏普已明显高于单次 |
| 100 | 即便所有策略真实期望为 0,也可能看到看似「显著」的高夏普 |
| 1000 | 几乎一定会有若干策略夏普「漂亮」——但全是噪声 |
DSR 的目标是在三个污染源下,计算「这个夏普在统计上仍显著的概率」:
经过修正后,你看到的不再是「裸夏普 2.5 看起来很棒」,而是「在试了 50 个策略、收益明显非正态、只有 3 年样本的条件下,这个夏普真正显著的概率可能只有 0.3」。两个数字天差地别。
⚠️ 常见误用:很多人只在论文/路演里报最优策略的夏普,却绝口不提试了多少个。一个不报 N 的夏普,信息量接近于零。
原项目脚本同时给出解析解和数值解,二者相互校验。数值解的做法是「真的去模拟很多轮,每轮抽 N 个策略,记最大夏普,再取平均」:
def get_numerical_max_sr(mu, sigma, num_trials, n_iter): """数值解:蒙特卡洛估计期望最大夏普""" max_sr, count = [], 0 while count < n_iter: count += 1 series = np.random.normal(mu, sigma, num_trials) max_sr.append(max(series)) return np.mean(max_sr), np.std(max_sr)
主循环遍历不同的 mu 与 num_trials 组合,把两种解与误差写成 DSR.csv,用来验证解析公式的准确性。当解析解与数值解吻合时,我们就可以放心用解析公式快速做修正,而不必每次都跑蒙特卡洛。
💡 动手建议:把脚本里的
num_trials改成 5、50、500 各跑一遍,你会直观地看到「期望最大夏普」随 N 上涨的曲线——这条曲线就是「虚假显著性」的形状。
DSR 框架还派生出一个实用结论:一个真实夏普为 \text{SR}_0 的策略,需要的最小回测年限大致正比于 \text{SR}_0 的平方,且与试的策略数同向增长。也就是说:
这条结论直接击碎了「我用 2 年数据回测出夏普 3.0 的策略」这类说法——在多重检验视角下,2 年样本几乎不足以支撑这么高的夏普显著性。
get_analytical_max_sr 用欧拉常数与正态分位数给出解析解,与蒙特卡洛数值解互相校验。下一节,我们看向量化回测实战——用最简洁的 pandas 把信号、持仓、收益串起来,快速看一个策略雏形的盈亏。