土法炼钢兴趣小组的算法知识备份

【量化交易】回测陷阱:前视偏差、过拟合、数据窥视

文章导航

分类入口
quant
标签入口
#backtest#lookahead#overfitting#data-snooping#deflated-sharpe

目录

回测引擎跑得通、净值曲线漂亮、Sharpe 看上去 2 以上——这些都不能证明一个策略真的会赚钱。绝大多数被淘汰的策略并不是因为「代码写错了」,而是死在三件事上:第一件是前视偏差(lookahead bias),把未来的信息悄悄用到了当下的决策里;第二件是过拟合(overfitting),在同一段历史里反复调参,把噪声当信号;第三件是数据窥视(data snooping),同一份数据被试了上千个想法,最终被发表的那一个完全无法在新数据上重现。这三件事彼此独立又互相强化,落到回测报告上,最终都会以「漂亮的 Sharpe」呈现,让人难以分辨是真本事还是假阳性。

上一篇《回测引擎》解决的是「回测语法对不对」——撮合规则、滑点模型、保证金、税费、复权、事件流。这一篇要解决的是「回测推断对不对」——同一份数据、同一段净值,能不能支撑「这是一个真实存在的 alpha」这一结论。前者是工具,后者是科学。把工具做对只是入门,把推断做严才是回测真正的难处。

代码示例使用 Python 3.11、numpy 1.26、pandas 2.2、scipy 1.11、statsmodels 0.14。所有数据为本地合成或可复现仿真,不引用任何具体策略或基金的回测结果。

风险提示:本文出现的所有方法、阈值、checklist 仅用于阐释回测推断的方法论本身,不构成任何投资建议,也不能保证任何策略在真实市场中盈利。Deflated Sharpe、PSR、Bonferroni 等修正只是「在统计上更谨慎」,并不能替代独立样本验证、纸面交易(paper trading)和小额实盘的逐级放量。把任何示例代码搬到生产前,请重新核对前视偏差、参数稳定性、样本外表现与多重检验修正。


一、回测可信度的四个层次

把回测结果摆在面前,先问一个问题:这条净值曲线值得相信到什么程度?这个问题不是 yes/no 的,而是分层的。我把回测可信度分成四层:语法对、逻辑对、数据对、推断对。每一层都过关,回测才有资格进入纸面交易,否则就只能停留在「研究草稿」级别。

一点一、第一层:语法对

语法对指的是回测引擎跑得通、不报错、能输出净值与持仓。它要求的事情很底层:撮合时点对齐、订单簿事件按时间戳推进、复权因子应用方向正确、停牌停板能正确处理、手续费与印花税按规则扣除、保证金与杠杆约束生效。这一层的 bug 一般会被单元测试和事件回放抓出来:随机生成一千个事件流,喂进回测引擎和一个独立的 reference 实现,比对两份净值是否一致;不一致就一定有 bug。

很多团队的回测「语法对」其实是不达标的:T+1 算成 T+0、复权方向反了、停牌当天还在交易、保证金按全部市值算而不是按维持保证金算、跨夜手续费忘了扣。这一类 bug 不会让策略「凭空赚钱」,但会让某一类策略——比如频繁吃停牌跳空、大量做空、跨夜套利——的回测严重失真。把语法对做扎实,是回测引擎层的责任,已在上一篇《回测引擎》里详细展开。

一点二、第二层:逻辑对

逻辑对指的是「策略按它声称的规则在交易」。语法对不等于逻辑对:撮合可以完全正确,但策略代码可能在某个分支里用了「下一根 bar 的 close」当作「这一根 bar 的决策依据」;或者 fillna 时把 t+5 的值往前填到了 t;或者标准化(standardize)时用了全样本的均值方差而不是滚动窗口。这些都是逻辑错误,最终的表现就是前视偏差——本文第二节展开。

逻辑对的另一个常见死法是「策略的实现和声明不一致」。研究员写论文里说「用 60 天动量减 1 月反转」,代码里实际写成「60 天动量减 1 周反转」;研究员说「日频再平衡」,代码里其实是「每个 bar 都再平衡」;研究员说「过滤掉成交量后 10% 的小票」,代码里写成「过滤掉成交量前 10% 的大票」。这种 bug 在回测里看不出来,等到上线后表现衰减、复盘时才会发现「原来跑的根本不是同一个策略」。

防止逻辑错的工程方法只有一个:强制 code review + 第三方独立复现。研究员自己写一遍、reviewer 按论文重写一遍,两份净值差距超过设定阈值就要解释。这个流程慢,但能挡住 80% 的逻辑错。

一点二点五、几个不同领域的「语法」差异

不同资产类别的回测语法对要求差别很大,把它们对齐到通用「下单—撮合」抽象上反而会出错。具体几个例子:

股票回测:T+1 结算(A 股)、T+2 结算(多数海外市场)、停牌停板、盘后大宗交易、ETF 申赎、转融通借券、回购冻结资金。把这些规则用一套通用 fill 模型打不下,必须为 A 股、港股、美股各写独立的撮合模块。

期货回测:保证金规则(SPAN、CME SPAN 2、上期所 / 中金所原生规则各不同)、当日无负债结算、强平阈值、合约换月、最后交易日规则、限仓制度。期货撮合的关键是当日无负债结算——回测里如果不每日结算盈亏并扣减保证金,杠杆和强平触发都会失真。

期权回测:到期行权、自动 assignment、隐含波动率曲面的拟合、Greeks 计算、合约稀疏度。期权回测的语法对极其难做,许多团队的期权回测系统其实只是「把期权当成 delta-1 资产乘以 delta」的近似——这种近似在尾部事件下完全失真。

加密资产回测:永续合约的 funding rate 结算、L2 订单簿事件、跨交易所价差、提现冻结时间、链上拥堵导致的 fill 延迟。加密回测的特殊难点是没有官方 close 价——每家交易所都有自己的 close 时点,跨交易所策略的回测必须明确选择一个 anchor 交易所。

把每个资产类别的「语法」单独 enumerate、单独测试,比写一个万能撮合引擎更可靠。这一点在 19 章的回测引擎里有详细展开。

一点三、第三层:数据对

数据对指的是回测使用的数据集本身没有偏差。这一层的陷阱比上一层更隐蔽:数据来源可能合法、字段也没错,但样本本身不代表当年真实可见的市场。最经典的例子是幸存者偏差(survivorship bias):今天能从数据库里拉出来的「中证 500 所有成分股 2010 年的数据」,并不是 2010 年真实的中证 500 成分;那些后来退市、合并、分拆、被剔除的股票已经悄悄消失。在这份「干净」的数据上跑出来的策略,回测漂亮、上线哑火,几乎是必然。

数据对还包括以下几件事:财报使用 PIT(point-in-time)数据而非最新修订数据——见第二节;指数成分按当时的 membership 而非今天的 membership复权因子按当时已发生的拆分/分红应用而非今天回看;行业分类按当时的标准而非今天的 GICS 11;期货合约按当时的主力规则切换而非按今天定义的滚动规则。每一项都是一个可独立 enumerate 的偏差源。

幸存者偏差和 PIT 数据的具体实现,见本系列《幸存者偏差》《特征仓库》两篇。

一点三点五、四个层次的相互关系

这四层不是「越往后越重要」的线性结构,而是「每一层都必须独立通过、缺一不可」的并联结构。一个团队常见的失败模式是「把所有精力放在第三层」——花几个月清洗 PIT 数据、构建幸存者偏差自由的数据集,但完全忽略第四层的多重检验修正,于是回测结果在干净数据上跑出 IS Sharpe 4,团队开心、上线、亏钱。另一种失败模式是「只关心第四层」——研究员熟读 DSR 论文,对 N 和 σ_SR 都精确计量,但代码里仍然有 fillna(method=‘bfill’) 这种第二层的逻辑错误,多重检验修正再严也修不掉前视偏差污染过的 Sharpe。

把这四层并联,意味着每一层都要有独立的负责人、独立的自动化检查、独立的 review 流程。研究员负责第一层和第二层,数据团队负责第三层,量化研究主管或独立的 quant risk 团队负责第四层。任何一层的检查由同一个人既写代码又自己审,几乎一定漏掉问题。

一点四、第四层:推断对

推断对是这篇文章的核心。即便前三层全部过关——回测引擎没 bug、策略代码忠实于论文、数据干净并 PIT——回测里跑出来的「Sharpe = 2.3、最大回撤 8%」依然不一定能在样本外重现。原因是这条净值是在巨大假设空间里被挑出来的:研究员可能跑了 500 组参数、20 个不同的特征、5 种再平衡频率,最终留下的是看起来最好的那一个。把它当成对真实世界的统计推断,相当于「先射箭再画靶」。

推断对要求做到三件事:显式记录所有试过的假设(试验数 N 是多少)、按 N 调整显著性阈值(多重检验修正)、用样本外或新数据验证(walk-forward、留出集、纸面交易)。这一层的工具是统计学:Family-Wise Error Rate(FWER)、False Discovery Rate(FDR)、Bonferroni、Benjamini-Hochberg、Probabilistic Sharpe Ratio(PSR)、Deflated Sharpe Ratio(DSR)。本文的第四节、第六节集中展开。

把这四层串起来:语法对是工具、逻辑对是工程、数据对是基础设施、推断对是科学。每一层都需要不同的人、不同的工具、不同的验证方式。一个真正可上线的回测,要在四层上都留出明确的证据链,不能跳过任何一层。


二、前视偏差

前视偏差是回测里最常见、最致命、也最难自查的一类 bug。它的定义很简单:在 t 时刻的决策里使用了 t 之后才能合法获得的信息。一旦发生,回测里的 Sharpe 会被人为推高几倍,看起来像是发现了圣杯。前视偏差的种类多到可以单写一本书,本节按出现频率从高到低,挑五个工程上最常踩的方向展开。

二点零、为什么前视偏差特别隐蔽

前视偏差的特殊之处在于:它产生的回测结果几乎一定是「漂亮的」。和数据 bug、撮合 bug 不同——后者既可能让 Sharpe 变高也可能让 Sharpe 变低,研究员遇到 Sharpe 突然变高时一般会本能地排查,遇到 Sharpe 突然变低时也会本能地排查;前视偏差几乎总是把 Sharpe 推高,研究员的本能反应是接受而不是怀疑。这种不对称使得前视偏差在团队里反复出现、反复出现、反复出现。

把这件事写成一条经验法则:任何让 Sharpe 突然提高 30% 以上的「优化」,都先假设它是前视偏差,然后再尝试证伪。这条法则简单粗暴,但比任何精巧的 lint 工具都管用。

二点一、财报数据:发布日 vs 报告期

财报数据是前视偏差的高发区,因为字段时间戳容易被混淆。一份财报有至少四个不同的「时间」:报告期末(report period end,比如 2025-03-31,对应 2025 年 Q1)、披露日(announcement date,比如 2025-04-30)、修订日(restatement date,可能在数月后)、入库日(database insertion date,数据厂商把这条记录放进数据库的那个时刻)。

回测里要用的是披露日 + 一个延迟,而不是报告期末。一个 2025-Q1 的 EPS,最早能在 2025-04-30 才作为公开信息进入决策。把它按 2025-03-31 的时间戳合并到日频价格序列上,然后在 2025-04-01 就拿来选股,相当于提前一个月知道了未来一个月的财务数据。这种偏差在中国 A 股上尤其严重,因为披露窗口集中在 4 月、8 月、10 月,披露日和报告期末之间常常隔一个月以上。

修订日是更深的坑。许多上市公司会在初次披露之后修订财报,修订后的数字才是数据库里的最终值。今天我们拉到的「2020 年 Q1 EPS」可能是 2020 年发布的原始值,也可能是 2021 年修订后的值。回测时用了修订后的值——这就是用了未来才会被知道的更准确数字——相当于偷看了几个月后的修订结果。点位时态(point-in-time,PIT)数据库的存在就是为了防止这件事:它对每个字段保留 (as_of, knowledge_time) 二元组,要求查询时显式指定「截至什么时间能看到的版本」。

工程上写一个 pit_join 函数,强制按 knowledge_time <= t 过滤后再拿最新版本:

import pandas as pd

def pit_join(price_df: pd.DataFrame,
             fundamental_df: pd.DataFrame,
             on: str = 'ticker',
             time_col: str = 'date',
             knowledge_col: str = 'knowledge_time',
             value_cols: list[str] | None = None) -> pd.DataFrame:
    """以 price_df 的 date 作为时间锚,把 fundamental_df 中
    所有 knowledge_time <= date 的最新一行 join 进去。

    fundamental_df 必须包含 [ticker, knowledge_time, *value_cols]。
    缺失列以 NaN 填充。函数本身不做任何 fillna 前向传播——
    那是上层策略的责任,写在这里很容易掩盖前视偏差。
    """
    if value_cols is None:
        value_cols = [c for c in fundamental_df.columns
                      if c not in {on, knowledge_col}]
    f = fundamental_df.sort_values([on, knowledge_col]).copy()
    p = price_df.sort_values([on, time_col]).copy()
    out = pd.merge_asof(p, f,
                        left_on=time_col, right_on=knowledge_col,
                        by=on, direction='backward', allow_exact_matches=True)
    return out

这个函数的关键是 direction='backward'allow_exact_matches=True:永远只取已经发布的最新一版。任何允许 forward join 的 fundamental 数据接入都是潜在的前视偏差源,必须在 review 阶段被拦下。

二点二、行业、指数成分与分类的「今天回看」

第二个常见前视偏差与「分类」有关。今天看到的「沪深 300 成分股」「申万一级行业」「GICS sector」都是今天版本的分类。把它当作 2018 年的分类用到 2018 年的回测里,会带来微妙但不可忽视的偏差。

举个具体例子:某只股票在 2018 年还在传统行业里,2022 年被重新归类为新能源。回测时如果按「今天的新能源行业」去做行业中性化(industry neutralization),意味着把 2018 年还属于传统行业的它放到了新能源篮子里。这只股票 2018 到 2022 期间的超额收益,会被错误地归因到「新能源行业」上,行业中性化的残差会偏小。

更糟的是指数成分。「2010 年的沪深 300」如果按今天看到的成分名单回测,会把那些 2010 之后才被纳入的股票悄悄塞进去——它们之所以被纳入,本身就因为后来涨得好。这种偏差和幸存者偏差是一对孪生兄弟:一个剔除了「死掉的」,一个加进了「后来好的」。

工程上的解决方法是给所有 categorical 字段建立 PIT 历史:成分股的 add/remove 日志、行业分类的 reclassification 日志、指数 rebalancing 的 announce/effective 日志。所有 join 必须按当时有效的版本,不能用当前快照。

二点三、复权方向与拆分调整

复权(adjustment)是前视偏差的另一个高发点。一只股票在 t0 拆股 1:2,今天看到的「过去价格」是后复权(backward-adjusted)的:t0 之前的价格被除以 2,让整条曲线连续。这本身没有问题——只要回测时用的是当时的真实价格,而不是这条已经被未来事件调整过的曲线。

很多人的回测代码里直接拉后复权收盘价,按它计算 t-1 时刻的「价格」,然后乘以「t-1 的真实可交易股数」算出市值。这就错了:t-1 时刻的人不可能知道 t0 会拆股,也不可能用「拆股调整后的价格」去做决策。后复权价格在 t0 之前的部分都被悄悄注入了「未来要拆股」这个信息。

正确的做法是用前复权(forward-adjusted,又叫 unadjusted with as-of date)或者直接用未复权价格 + 拆股事件流。前复权对历史价格不做修改,只在 t0 当天根据已发生的拆股调整 t0 之后的价格(实际是把 t0 之后的所有价格按 1/2 缩放后再展示);对回测决策没有干扰。

def to_forward_adjusted(prices: pd.DataFrame,
                        splits: pd.DataFrame,
                        ticker_col: str = 'ticker',
                        date_col: str = 'date',
                        price_col: str = 'close') -> pd.DataFrame:
    """把后复权或未复权价格转成前复权。splits 字段为 (ticker, ex_date, ratio)。
    ratio = 拆股比,比如 1:2 拆股 ratio = 2。
    """
    out = prices.sort_values([ticker_col, date_col]).copy()
    out['adj'] = 1.0
    for tk, ex, r in splits[['ticker','ex_date','ratio']].itertuples(index=False):
        m = (out[ticker_col] == tk) & (out[date_col] >= ex)
        out.loc[m, 'adj'] *= (1.0 / r)
    out[price_col + '_fwd'] = out[price_col] * out['adj']
    return out.drop(columns=['adj'])

注意函数里 >= ex 而不是 > ex:除权日当天就以新价格交易,所以从除权日开始 adj 就要乘上去。把这个边界搞错也会引入一格的 lookahead。

二点四、特征工程与归一化的「全样本平均」

第四个隐蔽的前视偏差源出现在特征工程里。研究员把一组日频特征做 z-score:

df['z_mom'] = (df['mom'] - df['mom'].mean()) / df['mom'].std()

这一行代码在样本内研究中无伤大雅,但放进回测就是灾难。df['mom'].mean()全样本的均值,包含了未来。t 时刻的 z 分数被注入了 t+1, t+2, …, T 的均值信息。一个动量因子原本 IC 只有 0.02,被这样归一化之后回测 IC 可能跳到 0.05,看起来非常可信。

同类问题还有 winsorize(按全样本分位数截尾)、StandardScaler.fit(全数据)、PCA.fit(全数据)、lookback 窗口里包含 t+1 的值(off-by-one)、滑动窗口标准差用 min_periods=1 在数据稀薄时引入偏差。所有 stateful 变换都必须用滚动窗口或扩展窗口的「截至 t」版本,绝不允许 df.x.mean() 这种一刀切写法。

正确的写法:

df['z_mom'] = (df['mom'] - df['mom'].expanding(min_periods=252).mean()) \
              / df['mom'].expanding(min_periods=252).std()

或者更稳健地,按横截面标准化(cross-sectional z-score)——每天单独计算,天然避开纵向前视。

二点五、撮合时点与「这一根 bar 的 close」

最后一类前视偏差出在交易时点。日频策略最容易写成:「t 日的 close 决策、t 日的 close 成交」。这是一个明显的 bug——这根 bar 的 close 出来时,市场已经收盘,根本没法再用 close 价格成交。但这种 bug 写法在 Jupyter notebook 里非常常见,因为信号生成和回测被写在同一个 cell 里,研究员不会自然地区分「决策时点」和「执行时点」。

正确的写法应该明确两个时点:

或者更保守地:

把这两个时点拆成两个独立的字段(decision_time, execution_time),并要求回测引擎只接受这种 schema 的信号——不接受裸的 signal[t]——是从工程上消除这类前视的最简方法。

二点五点五、撮合与 close-to-close 的细节差

更细一点,「撮合时点」还有几个常被忽略的细节:

第一,集合竞价 vs 连续竞价。A 股开盘 9:25 的集合竞价价格通常和 9:30 连续竞价开盘第一笔不同。回测里如果用 9:30 的「open」做撮合,但研究里其实信号是基于 9:25 集合竞价价格生成的,二者价格差几个 bps 累积下来会显著改变 Sharpe。最稳妥的做法是把这两个价格都纳入数据,明确标注每个 fill 用的是哪一个。

第二,收盘集合竞价。A 股收盘 14:57–15:00 是集合竞价,最后一笔成交是 15:00 的撮合价。日 K 线的「close」通常用的就是 15:00 集合竞价。但如果策略要在 close 时点下市价单,实际能撮合到的是 14:55 之前的连续竞价价格——研究和实盘的「close」不是同一个数。

第三,日内分钟级数据的对齐。1 分钟 K 线在不同数据厂商有 [t, t+1) 与 (t-1, t] 两种时间戳约定。把厂商 A 的分钟数据和厂商 B 的拼接起来,会无意中引入一分钟的 lookahead。所有跨厂商数据必须统一时间戳约定后再使用。

这三个细节单独看都很小,但都是回测里 Sharpe 被推高几十个 bps 的常见来源。

二点六、前视偏差自查的三条机械规则

把上面五类合并起来,我用三条机械规则做工程层自查:

第一,所有时间敏感字段必须有 knowledge_time。包括财报、行业分类、指数成分、评级、新闻、事件。Join 时只允许 knowledge_time <= t 的版本。

第二,所有滚动统计必须显式声明窗口与 min_periods。禁止 mean()std()quantile() 不带窗口;禁止 fit(全数据).transform(全数据);禁止把测试集的统计量回灌训练集。

第三,信号与执行必须双时间戳。信号有 decision_time,执行有 execution_time,回测引擎在每个 bar 校验 execution_time > decision_time

这三条机械规则本身写不出聪明的策略,但能挡住 90% 的前视偏差。

二点七、几个工程上特别狡猾的子模式

把上面三条规则补充几个特别容易被忽略的子模式,每一个都可以在工程评审里独立 enumerate:

子模式一:fillna 跨时间方向df.fillna(method='bfill')(向后填充)天然是前视偏差——它用未来的非缺失值填回过去。任何使用 bfill 的代码都必须给出明确的解释,否则禁止入仓。ffill(前向填充)一般安全,但要小心「停牌期间的 last close」这种用法:停牌前一天的 close 不能成为停牌期间「可以成交的价格」。

子模式二:交易日历对齐。境内 A 股、港股、美股、欧洲、日韩的交易日历不同。把跨市场策略的因子计算在「联合交易日」上做,然后再用 ffill 对齐到目标市场——这一步特别容易出错。最常见的错误是把美股 close(北京时间次日凌晨 5 点)当作 A 股开盘前可见的信息——实际中 A 股 9:30 开盘前美股已经收盘很久;但美股盘后的事件(earnings call、guidance)需要单独时间戳。

子模式三:数据厂商修订。Bloomberg、Wind、CRSP 等数据厂商都会追溯修改历史数据。今天拉到的「2010 年 3 月 15 日 IBM 收盘价」和半年前拉到的可能不一样(错误修正、口径调整、再分红)。生产环境必须冻结快照——把每天的数据快照单独保存,用「当时拉到的版本」做回测,而不是「今天能拉到的版本」。

子模式四:第三方因子库。许多团队直接订阅 Axioma、Barra、MSCI、Wolfe 的因子库做特征。这些因子库本身已经是 PIT 修订过的:今天拉到的「2018-03-31 的 Barra 因子值」和 2018-04-01 当时能看到的可能不同。订阅前要明确数据厂商提供的是 as-of-now 还是 as-of-then,缺哪一种就要做映射。

子模式五:研究环境与生产环境的特征不一致。研究员在 notebook 里用 pandas + 全样本 fit 算特征,写出来一个版本;上线时工程团队用流式计算(Flink、KStreams)算实时特征,又写一个版本。两个版本几乎一定有 numerical 差异。回测里跑的是 notebook 版,上线跑的是流式版——这是另一种「实现不一致」的前视偏差。解决方法是单一特征实现:研究和生产共用同一份代码、同一份特征仓库(详见《特征仓库》)。

把这五个子模式加进自查 checklist,是已经踩过坑的团队的共识。

二点八、延展阅读:和数据团队的协作

前视偏差的根源往往不在策略代码,而在数据基础设施。研究员能写多少 lint 规则都挡不住数据库本身没存 knowledge_time 这种结构性问题。所以前视偏差自查到一定阶段,必然要从「研究员自查」升级到「研究员 + 数据团队联合自查」。

具体协作模式有几种:

模式一:数据团队提供 PIT API,研究员只能通过 API 拿数据。任何绕过 API 的直接 SQL 查询被 lint 工具拦下。这种模式下数据团队对 PIT 正确性负责,研究员对策略逻辑负责,分工明确。

模式二:数据团队提供「冻结快照」服务。每天 EOD 把所有数据 snapshot 一份到 S3 / OSS / HDFS,研究员的所有回测都强制指定一个 snapshot 日期,不允许使用「latest」。这种模式特别适合应对数据厂商追溯修订的问题。

模式三:数据团队提供「lookahead detector」工具。把研究员的代码当输入,扫描所有 join、所有 fillna、所有 rolling 操作,自动检测潜在前视偏差。这种工具在 ClearStream、qcom-pit、deequ 等开源项目里都有雏形,每个团队可以基于自己的 schema 二次开发。

把这三种模式叠加,前视偏差的工程负担可以从研究员转移到基础设施,研究员能把更多精力放在策略思想本身。这是大型量化基金的标配,小团队也可以渐进式构建。


三、过拟合的来源

过拟合是回测的「物理学第二定律」:只要参数足够多、搜索次数足够多、特征足够灵活,任何一段历史净值都能被一组参数完美拟合,但这组参数在样本外几乎一定失效。过拟合不是「研究员手懒」,而是统计学的必然结果——在有限样本里,最大化 IS 表现等价于最大化噪声拟合度,IS 越好,OOS 越差。下面拆解过拟合的三个主要来源。

三点零、过拟合的物理学比喻

我喜欢用「自由度」的比喻理解过拟合。一段历史净值有 T = 1250 个观测点,可以认为它的「自由度」是 1250。一个有 K 个可调参数的策略,每多调一个参数就消耗 1 个自由度的拟合能力。当 K 接近甚至超过 T 时,策略可以完美拟合历史,但留给「真正解释规律」的自由度为零。

更精确一点:研究员实际在调的参数远不止「策略参数」本身。每个特征的标准化方式、每个过滤器的阈值、每个 join 的方向、每个填充的策略——都是一个隐性的可调参数。一段「看起来只有 5 个参数」的策略,实际可调自由度可能有几十个。当这几十个自由度都被「样本内表现」反复调过之后,策略在 IS 上漂亮是必然的,在 OOS 上失败也是必然的。

这个比喻不能被精确成数学定理,但它提醒研究员:不要数策略明面上的参数,要数所有曾经被调过的决策。每一次「我们试过 A 和 B,最后选 A」都是在消耗自由度。

三点一、参数搜索

最直接的过拟合来源是参数搜索。给一个动量策略加上窗口长度(5, 10, 20, 60, 120, 250),止损比例(1%, 2%, 5%, 10%),止盈比例(5%, 10%, 20%),再平衡频率(日, 周, 月),再加上多空阈值(0.3σ, 0.5σ, 1σ, 2σ)。这一组参数空间已经有 6 × 4 × 3 × 3 × 4 = 864 个组合。在 5 年的日频数据(约 1250 个交易日)上跑 864 个组合,挑出 IS Sharpe 最高的那个,几乎一定会得到一个 Sharpe ≥ 2 的「策略」——哪怕原始信号根本就是随机数。

数学上可以估算「最大 Sharpe」的期望值。设单次实验的 Sharpe 服从均值 μ、标准差 σ_SR 的近似正态分布;N 次独立实验取最大值的期望近似为:

\[E[\max SR] \approx \mu + \sigma_{SR} \cdot \Phi^{-1}\left(1 - \frac{1}{N+1}\right) \approx \mu + \sigma_{SR}\sqrt{2\ln N}\]

T = 5 年(约 1250 日频点)下,单次实验的 σ_SR ≈ √(1/T) ≈ 0.028 日频,年化 ≈ 0.028 × √252 ≈ 0.45。即使 μ = 0(无信号),跑 100 次实验下 max Sharpe 期望就是 0.45 × √(2 ln 100) ≈ 1.36;跑 1000 次是 1.66;跑 10000 次是 1.92。这就是所谓「Selection Bias 放大效应」:试得越多,看起来越好,但全是噪声。

参数搜索的过拟合不能被「严格交叉验证」完全消除,只能被减小:要么减少搜索空间(先验剪枝)、要么用更长的样本(让 σ_SR 缩小)、要么用 walk-forward / nested CV 让每次搜索都在更小的子样本上完成(详见下一篇 Walk-forward 与时间序列 CV)。

参数搜索的过拟合还有一个二阶效应:搜索过程本身让研究员的「先验信念」也被污染。研究员第一轮跑了 100 组参数,发现窗口在 60–90 之间表现最好,于是第二轮把搜索空间限制到 50–100,再跑 50 组。表面上看第二轮 N=50 很合理,实际上「把窗口限制到 50–100」这个决策本身已经吸收了第一轮 100 次的信息——真实 N 至少是 100+50=150,而且信息利用更充分(先验已经偏向了好的区间)。这种「分阶段搜索」的累积 N 几乎都会被低估。

三点二、特征工程

过拟合的第二个来源是特征工程,比参数搜索更隐蔽。研究员不动「策略结构」,只是「加了一个新特征」「换了一种归一化」「加了一个对数变换」「换了一种缺失值填充方式」——每一次都是一次试验。但这些试验大多数不会被记录下来。研究员只会在最终论文或报告里写「我们使用了 X 特征」,不会写「我们试过了 X1, X2, …, X37 才选定 X」。

特征工程的过拟合特别容易在以下场景里发生:因子合成(把 5 个候选因子用 IR 加权合并,但 IR 是在同一段历史上算的);特征筛选(用 IC 排序选 top-k 特征,但 IC 是在同一段历史上算的);非线性变换(试了 log、sqrt、rank、winsorize,挑残差最干净的那一个);滞后调整(试了 lag 1, 2, 3, 5,挑 IC 最高的)。每一类操作单独看都「合理」,叠在一起就把 IS 表现推到了天上去。

特征工程的过拟合自查只有一条:把每一次试验都计入「试验次数 N」。不管研究员意识到没有,每次「换一个变换」都是一次试验,N 都要加 1。N 进入第四节的多重检验修正,自然把 IS 漂亮的特征削回到合理水位。

三点三、模型选择

第三个过拟合来源是模型选择。同一份特征,跑 OLS、Ridge、Lasso、ElasticNet、Random Forest、GBM、XGBoost、LightGBM、CatBoost、MLP、LSTM、Transformer——每一个都可能给出不一样的 IS 表现,挑最好的那一个。这种过拟合不仅作用于「模型类型」,还作用于每个模型的超参数(学习率、深度、正则化系数、early stopping 阈值)。

模型选择的过拟合在机器学习类策略里特别严重,因为模型本身的容量很大、参数很多、超参数空间很大,单次拟合就已经有 IS 极优、OOS 平淡的倾向;再叠加一层模型/超参数搜索,N 可以轻易达到 10000 量级。Sharpe 看起来很高、Train R² 看起来很大、但 OOS 几乎不可能 reproduce。

防止模型选择过拟合,工程上的标准做法是:先固定模型族再搜参数——比如先决定「用 GBM」,不再考虑换模型;用 nested CV 隔离超参数搜索与最终评估记录所有跑过的模型——不仅是最终发布的那个;把模型选择过程当作一个「外层 N」乘进 Bonferroni 修正里

三点四、过拟合的三个识别信号

把上面三类合并,过拟合在回测报告里通常呈现为下面三个信号。任何一个出现,都要触发深入审计:

第一,IS 与 OOS Sharpe 差距大于 1。一个稳健的策略,IS Sharpe 2.0、OOS Sharpe 1.5 是合理范围;IS 2.0、OOS 0.3 几乎肯定过拟合。

第二,参数曲面陡峭。把 Sharpe 画成参数(窗口、阈值)的等高线图。如果最优参数附近梯度很陡——稍微挪一点 Sharpe 就掉一半——说明这个最优是噪声里的尖峰,不是结构性的山脊。稳健参数应该形成一片「平台」(plateau)。

第三,净值曲线靠少数几次大涨支撑。把每月收益排序,看 top-5 月份对总收益的贡献。如果前 5 个月份贡献了 80% 的总收益,剩下的月份基本平的甚至略亏,说明这条净值是被极少数事件拉起来的——可能是过拟合在历史里偶然抓到了几个极端事件。

三点五、过拟合的可视化诊断

除了上面三个静态信号,还有三个动态诊断图,强烈推荐每个策略上线前都画一遍。

第一图:参数曲面热力图。把策略的两个最敏感参数作为坐标轴,Sharpe 作为颜色,画一张 heatmap。健康的策略呈现「一片高地」——一大片连续的高 Sharpe 区域;过拟合的策略呈现「孤立尖峰」——只有一个点 Sharpe 高,相邻点全是平地或低谷。前者说明 Sharpe 是结构性的,后者说明 Sharpe 是噪声里的偶然。

第二图:滚动 Sharpe。把策略按 252 日滚动窗口计算年化 Sharpe,画时间序列。健康策略的滚动 Sharpe 围绕真实值上下浮动,浮动幅度与 1/√T 一致;过拟合策略的滚动 Sharpe 在一段时间里特别高、其他时间接近 0 甚至负数——说明全样本 Sharpe 被某个时段单独拉起。

第三图:参数稳定性 vs 时间。把每年单独做一次参数搜索,看每年的「最优参数」是否稳定。健康策略每年的最优参数差异在合理范围内(比如动量窗口在 60–90 天之间游走);过拟合策略每年最优参数跳来跳去(去年最优窗口 5 天,今年最优 250 天),说明根本没有稳定的 alpha 结构,只是每年抓一段噪声。

这三张图加起来,能在 5 分钟内识别 80% 的过拟合策略,比仅看终值 Sharpe 远更可信。

下面这张图(multiple-testing-threshold)展示了多重检验下 Sharpe 阈值如何随试验次数 N 抬升;下下张图(overfitting-curve)展示了参数搜索下 IS 与 OOS Sharpe 的典型走势。

多重检验下的 Sharpe 阈值
参数搜索过拟合曲线

三点六、过拟合与「研究自由」的张力

最后一句关于过拟合的话,给研究员留:严控过拟合不等于禁止研究。研究员需要自由探索新想法、试错、调参——这是创造力的来源,不能用 checklist 完全锁死。本文倡导的不是「不允许调参」,而是「调参的成本要被显式计量」。每次调参都计 N、每次决策都进日志、每次发布都过 DSR——研究自由仍然在,但「免费午餐」没了。

把这件事写成一句话:过拟合的解药不是减少试验次数,而是诚实地报告试验次数。诚实报告之后再决定是否上线,是科学的态度;隐瞒试验次数然后只报最好那一个,是营销的态度。两者的区别就是研究员和销售员的区别。


四、数据窥视与多重检验

数据窥视(data snooping)是过拟合在「研究流程」层面的对应物。过拟合关注的是「同一个模型在同一份数据上反复调参」,数据窥视关注的是「同一份数据被同一个研究员、同一个研究团队、整个学术界反复试」。这两件事在统计上的后果是一样的:试得越多,假阳性(false positive)越多。

数据窥视的危害已经被金融经济学界用大样本研究反复验证。Harvey、Liu、Zhu(2016)整理了过去 50 年间 Journal of FinanceJournal of Financial EconomicsReview of Financial Studies 上发表的 313 个「显著的」截面定价因子,按 Bonferroni、BH-FDR、Holm 等方法重新检验,发现 超过一半的「显著因子」按多重检验修正后不再显著。这个数字不是耸人听闻——它是把 50 年文献当作一个统一的多重检验家族重算的结果。McLean & Pontiff(2016)把已发表因子在发表后的 OOS 表现做对比,发现因子的 OOS 收益平均比 IS 衰减约 26%,其中显著来自数据窥视的部分占衰减的 50% 以上。这两篇都是过去十年金融经济学界的重要 wakeup call。

四点零、什么叫「数据窥视」

数据窥视有两层含义。狭义的数据窥视是研究员自己反复在同一份数据上试不同想法、最终发表表现最好的那一个。广义的数据窥视是整个领域、整个学术界、整个市场的所有研究者,在同一份历史数据上反复试,最终被发表/被实施的策略只是这巨大试验池里的尾部。前者属于「单个团队的研究流程问题」,后者属于「领域级 selection bias」,两者都让最终发表的 Sharpe 数字严重 inflate。

数据窥视的危险在于它不是 bug——研究员没有写错任何代码、没有违反任何规则、没有作弊。每一次试一个新想法都是合理的研究行为。问题出在:单次行为合理 + 累积行为有偏。这是统计学层面的现象,不是道德层面的问题,所以也不能靠「研究员更诚实」解决,只能靠工程化的实验日志 + 多重检验修正来对抗。

四点一、Family-Wise Error Rate(FWER)与 Bonferroni

经典的多重检验框架叫 FWER:在 N 个独立检验里,至少出现一个假阳性的概率。如果每个检验单独的显著性水平是 α(典型 0.05),那么 FWER ≈ 1 − (1 − α)^N ≈ Nα(小 α、小 N 时近似)。N = 100 时 FWER ≈ 1,意思是几乎肯定能找到至少一个「显著」结果——即使所有原假设都是真的。

Bonferroni 修正把每次检验的 α 缩小到 α/N,让 FWER 维持在 α 水平。对应到 Sharpe 的检验阈值上:

T = 5 年时,单次阈值 ≈ 0.88;N = 100 时阈值 ≈ 1.55;N = 1000 时阈值 ≈ 1.83;N = 10000 时阈值 ≈ 2.08。这是对应图(multiple-testing-threshold)里的蓝线。

Bonferroni 的优点是简单、保守、永远是上界;缺点是过于保守——当试验间高度相关时(比如 1000 个动量策略只是窗口不同,本质上是同一个),Bonferroni 把阈值推得过高,会损失大量真阳性。

import numpy as np
from scipy import stats

def bonferroni_sharpe_threshold(n_trials: int, T: int, alpha: float = 0.05) -> float:
    """返回年化 Sharpe 阈值。T 单位为「年」(不是观测点数)。
    假设单次检验 SR 的标准差 = 1/√T(年化)。
    """
    z = stats.norm.ppf(1 - alpha / (2 * n_trials))
    return z / np.sqrt(T)

# 例:5 年、1000 次试验 → 阈值约 1.83
print(bonferroni_sharpe_threshold(1000, 5))

四点二、False Discovery Rate(FDR)与 Benjamini-Hochberg

FDR 控制的不是「至少一个假阳性的概率」,而是「被声称为发现的项里假阳性的占比」。在量化研究里 FDR 比 FWER 更现实:研究员不会因为「跑了 1000 个策略中有 1 个假阳性」就停止研究,但会希望「最终上线的 50 个策略里假阳性占比不超过 5%」。

Benjamini-Hochberg(BH)程序按 p 值升序排列,找最大的 k 使得 p_(k) ≤ k·q/N(q 是目标 FDR),然后把前 k 个声称为发现。BH 的阈值比 Bonferroni 宽松——对应图里的绿线低于蓝线。

def bh_threshold(p_values: np.ndarray, q: float = 0.05) -> float:
    """返回 BH 程序下被声称显著的 p 值阈值,达不到则返回 0。"""
    p = np.sort(p_values)
    n = len(p)
    bh = q * np.arange(1, n + 1) / n
    below = np.where(p <= bh)[0]
    return p[below.max()] if len(below) else 0.0

BH 的合理使用前提是检验之间近似独立或正相关;负相关情况下要用 BY(Benjamini-Yekutieli)。在策略研究里大多数策略的因子暴露相似,正相关居多,BH 在实践中通常够用。

四点二点五、Holm-Bonferroni 与 step-down 程序

Bonferroni 是一种 single-step 程序——所有假设用同一个阈值 α/N。Holm-Bonferroni 是它的 step-down 改进:把 N 个 p 值升序排列,依次以 α/N、α/(N−1)、α/(N−2)、… 检验,第一次失败就停。Holm 在控制 FWER 上和 Bonferroni 等价(两者都把 FWER 控制在 α),但统计功效更高——会拒绝更多真实的非零假设。

在量化研究里 Holm 比 Bonferroni 更实用,因为它能识别「最显著的几个策略」而不只是「最显著的那一个」。

def holm_threshold(p_values: np.ndarray, alpha: float = 0.05) -> int:
    """返回 Holm-Bonferroni 程序下被拒绝的假设数。
    p_values 是 N 个原假设的 p 值。
    """
    p = np.sort(np.asarray(p_values))
    N = len(p)
    for k in range(N):
        if p[k] > alpha / (N - k):
            return k
    return N

Holm 程序与 BH-FDR 的差别:Holm 控制的是 FWER(至少一个假阳性的概率),BH 控制的是 FDR(被声称为发现里假阳性的占比)。前者更保守、适合「上线门槛」决策;后者更宽松、适合「研究优先级排序」决策。两者在工程上各有用武之地,不互相替代。

四点三、Family-Wise vs Family-Specific:要不要把别人跑过的也算进去

一个无法回避的问题是:N 到底应该怎么计?只算我自己跑过的,还是算我们组跑过的,还是算「整个学术界」过去 50 年跑过的所有策略?

经验上有两套答案:

第一种是狭义 N——只算这次研究里我自己尝试的次数。这种 N 容易被记录、容易和审计对齐,但对「数据窥视的累积效应」无能为力。学术界已经在 SP500 历史上跑了上万次回测,狭义 N 永远低估了真实的 Selection Bias。

第二种是广义 N——把整个文献中已发表的同类策略数加进去。Bailey、López de Prado 等人在 The Probability of Backtest Overfitting 系列论文里建议把广义 N 当作研究 anchoring 的下限。比如「美股动量类策略」的广义 N 至少是数百,很可能上千。即使你这次只跑了 10 个变体,仍然要按广义 N ≥ 数百 来算 Bonferroni。

实操上我建议折中:狭义 N 用于 BH-FDR(控制本次研究的假阳性);广义 N 用于 Deflated Sharpe(让最终阈值反映「整个领域已经被挖过多少」)。两层一起用,能让最终上线的策略对得起「这是真发现」的声明。

四点四、N 的实际计数:研究员日志的工程要求

要做多重检验修正,必须先精确计 N。很多团队的「N」根本没记录,研究员凭印象说「我大概跑了 50 个变体」,实际可能 500。我推荐的工程要求:

这一套日志做下来,每个发布的策略才有「N=多少」可填。少了这一项,Bonferroni、BH、Deflated Sharpe 都变成了拍脑袋。


五、Selection Bias 与回测衡量

Selection Bias 是数据窥视的统计后果。它的核心数学是:N 个噪声 Sharpe 取最大,期望大于零、方差也变小。这意味着「最好的那个策略」的 IS Sharpe 几乎一定超过零,但它在 OOS 上的期望就是真值(通常接近零)。

五点一、Selection Bias 的解析估计

设 N 次独立实验的 SR 服从 Normal(μ, σ_SR²),最大值的期望近似:

\[E[\max_{i \le N} SR_i] \approx \mu + \sigma_{SR}\left[(1-\gamma)\Phi^{-1}\left(1-\frac{1}{N}\right) + \gamma \Phi^{-1}\left(1-\frac{1}{N e}\right)\right]\]

γ ≈ 0.5772 是 Euler-Mascheroni 常数。这个公式是 Bailey & López de Prado 在 DSR 论文里给出的,比简单的 √(2 ln N) 更精确。

def expected_max_sharpe(n_trials: int, mu: float = 0.0, sigma_sr: float = 1.0) -> float:
    gamma = 0.5772156649
    a = stats.norm.ppf(1 - 1.0 / n_trials)
    b = stats.norm.ppf(1 - 1.0 / (n_trials * np.e))
    return mu + sigma_sr * ((1 - gamma) * a + gamma * b)

把 σ_SR 取为单次实验下 SR 的标准差(年化),这个函数告诉你「在零信号假设下,跑 N 次能看到的最高 Sharpe 大概是多少」。它是 DSR 修正的核心。

这个公式给出的「上界」可以被用作一个非常实用的内部红线:任何回测的 IS Sharpe 必须显著高于 zero-skill 上界。比如团队过去 12 个月在某个策略族里跑了 N=500 次实验,单次 σ_SR ≈ 0.45,那么 zero-skill 上界 ≈ 0.45 × 3.06 ≈ 1.4。新提交的策略如果 IS Sharpe 是 1.5,这个 1.5 完全可能就是 selection 出来的,不值得进入下一轮 review。如果 IS Sharpe 是 3.5,超过上界 2 倍以上,才有资格往下走。把这个红线刻在团队共识里,比任何 checklist 都管用。

五点二、衡量回测结果的两个习惯

我在每次拿到回测结果时,强制做两件事,养成习惯:

第一件是对比 zero-skill 上界:用 expected_max_sharpe(N) 计算一下「无信号下跑 N 次能跑出多少 Sharpe」。如果回测的 Sharpe 没显著超过这个上界,结果不可信。

第二件是估计 SR 的方差。SR 的样本方差(年化)近似为:

\[\hat{\sigma}^2_{SR} = \frac{1}{T}\left(1 + \frac{1}{2}\hat{SR}^2 - \hat{\gamma}_3 \hat{SR} + \frac{\hat{\gamma}_4 - 3}{4}\hat{SR}^2\right)\]

其中 γ₃ 是收益序列的偏度、γ₄ 是峰度。这个方差比简单的 1/T 大,特别是当收益分布有重尾或负偏时。后面 PSR 与 DSR 都建立在这个公式上。

def sharpe_variance(returns: np.ndarray, sr_hat: float | None = None) -> float:
    """返回年化 Sharpe 估计量的方差近似。返回单位与 sr_hat 一致。
    returns 应为日频或更细频率的超额收益序列;按年化 252 日处理。
    """
    r = np.asarray(returns, dtype=float)
    if sr_hat is None:
        sr_hat = r.mean() / r.std(ddof=1) * np.sqrt(252)
    skew = stats.skew(r, bias=False)
    kurt = stats.kurtosis(r, fisher=False, bias=False)  # 非超额峰度
    T = len(r) / 252.0  # 年数
    var = (1 - skew * sr_hat + (kurt - 1) / 4 * sr_hat**2) / T
    return max(var, 1e-9)

注意公式里 (kurt − 1)/4 是因为我们用的是「非超额峰度」γ₄ 本身,不是 γ₄−3 的 excess kurtosis。Lo(2002)原文用的是 γ₄ 的形式;Bailey & López de Prado(2014)改写成了 (γ₄−3)/4 配合 excess kurtosis。两种写法等价。

五点三、bootstrap 与 SPA / 真实性检验

除了解析公式,bootstrap 是另一条衡量回测结果稳健性的路径。最简单的 stationary bootstrap(Politis & Romano 1994)按几何分布采样块大小,对原始收益序列做有放回抽样,得到 B 份合成 PnL;在每份上重新计算 SR、回撤、胜率等指标,得到这些指标的经验分布。

def stationary_bootstrap(returns: np.ndarray, n_boot: int = 1000,
                         expected_block: float = 50.0,
                         rng: np.random.Generator | None = None) -> np.ndarray:
    """返回 shape=(n_boot, T) 的 bootstrap 矩阵。"""
    if rng is None:
        rng = np.random.default_rng()
    T = len(returns)
    p = 1.0 / expected_block
    out = np.empty((n_boot, T), dtype=returns.dtype)
    for b in range(n_boot):
        idx = np.empty(T, dtype=np.int64)
        idx[0] = rng.integers(0, T)
        for t in range(1, T):
            if rng.random() < p:
                idx[t] = rng.integers(0, T)
            else:
                idx[t] = (idx[t-1] + 1) % T
        out[b] = returns[idx]
    return out

把 bootstrap 矩阵每行都跑一遍策略指标,就能看到「同样的数据生成过程下,Sharpe 的分布有多宽」。如果 IS Sharpe = 2.3、bootstrap 分布的 5% 分位数是 0.4,说明这个策略对采样路径很敏感,结果不稳定。

更进一步的工具是 White(2000)的 Reality Check 与 Hansen(2005)的 SPA(Superior Predictive Ability)检验。两者都把「在 N 个候选策略里挑最好」这件事的零分布通过 bootstrap 模拟出来,给出「最佳策略 SR 是否真的超过 benchmark」的 p 值。SPA 比 Reality Check 更强,因为它用了 studentization 把噪声策略剔除。在量化研究的最终评估阶段,跑一次 SPA 是一个值得养成的习惯。

五点四、为什么 OOS 验证仍然不够

很多团队的回测流程已经做到「IS 训练 + OOS 验证」,认为这就够了。事实上单次 OOS 验证仍然会被数据窥视污染,原因有三:

第一,OOS 集本身可能被反复看过。研究员在 IS 上调参数,发现某组参数 IS 表现好,跑一遍 OOS,OOS 不行,于是回去重调参数。再跑 OOS,再不行,再回去调。这种 iterative 的过程相当于把 OOS 集也变成了 IS 的一部分——OOS 失去了「独立验证」的属性。这个问题被 López de Prado 称为「backtesting on a single hold-out set is fool’s gold」。

第二,OOS 和 IS 来自同一个时段或同一个市场 regime。把 2018–2022 切成 IS(2018–2020)和 OOS(2021–2022),两段都是低利率、低通胀、科技股牛市。在这种相关性极强的「OOS」上验证通过,不能保证策略在 2023+ 的高利率、高通胀环境下还有效。这是为什么真正稳健的 OOS 验证应该跨越多个 regime——至少包含一次熊市、一次牛市、一次震荡市。

第三,OOS 长度不够支撑统计推断。2 年 OOS 大约 500 个观测点,单次 SR 估计的标准差近似 0.45,95% 置信区间宽度近似 ±0.9。一个 OOS Sharpe = 1.0 的策略,95% CI 是 [0.1, 1.9]——根本不能拒绝「真实 Sharpe = 0」。换句话说,「OOS 表现还行」在统计上很可能就等价于「OOS 是噪声」。

第四,OOS 上无法做参数稳定性检验。单次 OOS 只能告诉你「这组参数在这一段时间里能不能跑」,不能告诉你「最优参数会不会在这一段时间里漂移」。参数漂移本身就是过拟合的证据,但单次 OOS 看不见。

要解决这三件事,必须升级到 walk-forward 或 nested CV:把多段独立 OOS 串起来,每段都按「先固定参数、再验证」的顺序进行,且每段 OOS 都至少 1 年长度。下一篇 Walk-forward 与时间序列 CV 专门展开。


六、Deflated Sharpe Ratio 与 Probabilistic Sharpe Ratio

DSR 与 PSR 是把上面所有概念整合在一起的两个评估指标,由 Bailey 与 López de Prado 在 The Sharpe Ratio Efficient Frontier(2012)和 The Deflated Sharpe Ratio(2014)中提出。本节给出可以直接拷贝运行的实现。

六点零、为什么 Sharpe 不够,需要 PSR / DSR

传统意义上的 Sharpe Ratio 是一个点估计——给一个数字(年化 Sharpe = 1.5),不告诉你这个数字的不确定性有多大。两个团队都报「Sharpe = 1.5」,可能:A 团队跑了 1 次,T = 10 年;B 团队跑了 1000 次,T = 1 年——同样的 1.5,可信度天差地别。Sharpe 这个指标无法区分。

PSR 和 DSR 把这个不确定性显式装进了报告:PSR 装进了「样本量 + 偏度 + 峰度」、DSR 进一步装进了「试验次数 N + 试验间 σ_SR」。两个指标都返回一个概率——「在你这个研究流程下,真实 Sharpe 大于阈值的概率」——而不是一个 inflated 的点估计。

把 Sharpe 升级到 PSR/DSR 是研究流程的一次范式转变:从「我跑出了好看的数字」到「我有多大把握这个数字反映真实信号」。这一步走完,团队对回测结果的态度会从「兴奋」转向「审慎」——这个转变本身比任何具体公式都重要。

六点一、Probabilistic Sharpe Ratio

PSR 的问题陈述:给定观察到的 SR̂,在多大概率下真实 SR 大于某个 benchmark SR*

\[PSR(SR^*) = \Phi\left(\frac{(\hat{SR} - SR^*)\sqrt{T-1}}{\sqrt{1 - \hat{\gamma}_3 \hat{SR} + \frac{\hat{\gamma}_4 - 1}{4}\hat{SR}^2}}\right)\]

T 为观测点数(比如 1250 日);γ₃、γ₄ 为偏度与峰度(非超额)。PSR 把样本量、SR、偏度、峰度都纳入考量。常用的 SR* 是 0(检验「真实 Sharpe 是否大于零」)或 1(检验「真实 Sharpe 是否大于 1」)。

def probabilistic_sharpe(returns: np.ndarray, sr_benchmark: float = 0.0,
                         freq: int = 252) -> tuple[float, float]:
    """返回 (annualized_sharpe, PSR)。"""
    r = np.asarray(returns, dtype=float)
    T = len(r)
    if T < 30:
        return float('nan'), float('nan')
    sr = r.mean() / r.std(ddof=1)         # per-period
    skew = stats.skew(r, bias=False)
    kurt = stats.kurtosis(r, fisher=False, bias=False)
    sr_b = sr_benchmark / np.sqrt(freq)   # benchmark 也转 per-period
    denom = np.sqrt(1 - skew * sr + (kurt - 1) / 4 * sr * sr)
    z = (sr - sr_b) * np.sqrt(T - 1) / denom
    psr = stats.norm.cdf(z)
    return sr * np.sqrt(freq), psr

PSR 的解读:PSR ≥ 0.95 意味着「在 95% 的置信度下相信真实 Sharpe 大于 benchmark」。

六点二、Deflated Sharpe Ratio

DSR 在 PSR 基础上对多重检验做修正——把 benchmark SR* 替换为「N 次试验下零信号假设的最大 Sharpe 期望」。具体公式:

\[SR^*_0 = \sigma_{SR}\left[(1-\gamma)\Phi^{-1}\left(1-\frac{1}{N}\right) + \gamma\Phi^{-1}\left(1-\frac{1}{Ne}\right)\right]\]

\[DSR = \Phi\left(\frac{(\hat{SR}-SR^*_0)\sqrt{T-1}}{\sqrt{1-\hat{\gamma}_3\hat{SR}+\frac{\hat{\gamma}_4-1}{4}\hat{SR}^2}}\right)\]

σ_SR 是 N 次试验里 SR 估计量的横截面标准差(不是单次估计的标准差),通常用试验日志里的样本标准差代入。

def deflated_sharpe(returns: np.ndarray,
                    n_trials: int,
                    sigma_sr_across_trials: float,
                    freq: int = 252) -> tuple[float, float, float]:
    """返回 (annualized_sharpe, sr_threshold, DSR)。

    sigma_sr_across_trials: 在 N 次试验里 Sharpe 估计的横截面标准差(年化)。
    没有日志可用时,可以用 1/sqrt(T_year) 作为粗估上界。
    """
    r = np.asarray(returns, dtype=float)
    T = len(r)
    sr = r.mean() / r.std(ddof=1)            # per-period
    skew = stats.skew(r, bias=False)
    kurt = stats.kurtosis(r, fisher=False, bias=False)

    gamma = 0.5772156649
    a = stats.norm.ppf(1 - 1.0 / n_trials)
    b = stats.norm.ppf(1 - 1.0 / (n_trials * np.e))
    sr_star_ann = sigma_sr_across_trials * ((1 - gamma) * a + gamma * b)
    sr_star = sr_star_ann / np.sqrt(freq)

    denom = np.sqrt(1 - skew * sr + (kurt - 1) / 4 * sr * sr)
    z = (sr - sr_star) * np.sqrt(T - 1) / denom
    return sr * np.sqrt(freq), sr_star_ann, stats.norm.cdf(z)

DSR 的语义:「在试了 N 次、每次 SR 估计量横截面标准差为 σ_SR 的研究里,真实 Sharpe 大于零的概率」。DSR ≥ 0.95 才算通过显著性检验——这个标准比单次 PSR ≥ 0.95 严苛得多。

六点三、用 DSR 模拟数据窥视的虚假发现率

下面用一段可复现的仿真,验证 DSR 能否压制虚假发现率。仿真设定:N = 1000 个策略,每个策略对应一段长度 T = 1250 的随机收益序列,真实 Sharpe = 0(零信号)。每段序列计算 SR̂、PSR、DSR;统计「PSR ≥ 0.95」与「DSR ≥ 0.95」分别有多少假阳性。

import numpy as np
from scipy import stats

rng = np.random.default_rng(20260501)
N_TRIALS = 1000
T = 1250
sigma = 0.01     # 日波动 1%
sr_true = 0.0

returns_matrix = rng.normal(0, sigma, size=(N_TRIALS, T))
sharpe_estimates_ann = returns_matrix.mean(axis=1) / returns_matrix.std(axis=1, ddof=1) * np.sqrt(252)

# PSR 假阳性
psr_pos = 0
for i in range(N_TRIALS):
    _, psr = probabilistic_sharpe(returns_matrix[i], sr_benchmark=0.0)
    if psr >= 0.95:
        psr_pos += 1

# DSR 假阳性
sigma_sr = sharpe_estimates_ann.std(ddof=1)
dsr_pos = 0
for i in range(N_TRIALS):
    _, _, dsr = deflated_sharpe(returns_matrix[i], n_trials=N_TRIALS,
                                sigma_sr_across_trials=sigma_sr)
    if dsr >= 0.95:
        dsr_pos += 1

print(f'PSR 假阳性 = {psr_pos}/{N_TRIALS}{psr_pos/N_TRIALS:.1%}')
print(f'DSR 假阳性 = {dsr_pos}/{N_TRIALS}{dsr_pos/N_TRIALS:.1%}')

期望结果:PSR 假阳性接近 5%(因为 PSR 是单次检验,没修正多重性);DSR 假阳性远低于 5%,通常在 0–1% 量级。把这段代码扔进任何一台机器都能复现这个对比,说明DSR 不是花架子,是真能挡掉数据窥视

六点四、DSR 的边界与局限

DSR 不是万灵药,它有几个局限值得在使用前清楚:

第一,对 N 的估计依赖。N 估错一个数量级,阈值会显著改变。前面提到的「狭义 N vs 广义 N」必须先和团队对齐。

第二,对 σ_SR 的估计依赖。试验日志要够丰富才能拿到真实的横截面 SR 标准差。日志稀疏时,σ_SR 容易低估,DSR 偏向乐观。

第三,对 i.i.d. 假设依赖。DSR 推导假设收益日序列近似 i.i.d.;自相关、波动聚集都会让方差被低估。实践中可以用 HAC(Newey-West)调整。

第四,对偏度/峰度估计的稳定性。短样本里偏度、峰度估计不稳定,DSR 数值会上下跳。建议样本至少 5 年以上。

把这些局限写在策略上线材料的「假设与限制」一节,是研究员的责任,不是 DSR 公式自身的责任。

六点五、HAC 修正下的 PSR

如果收益序列存在显著自相关(比如月频策略残留的截尾自相关、HFT 策略的微结构自相关),SR 估计量的方差被简单 i.i.d. 假设低估。一个粗略修正是把分母里的 (T−1) 替换为 Newey-West 有效样本量:

def hac_effective_T(returns: np.ndarray, lags: int = 5) -> float:
    """Newey-West 有效样本量近似。lags 取经验值 floor(4*(T/100)^(2/9))。"""
    r = np.asarray(returns) - returns.mean()
    T = len(r)
    s2 = (r * r).mean()
    s_hac = s2
    for k in range(1, lags + 1):
        w = 1 - k / (lags + 1)
        cov = (r[:-k] * r[k:]).mean()
        s_hac += 2 * w * cov
    return T * s2 / max(s_hac, 1e-12)

def probabilistic_sharpe_hac(returns: np.ndarray, sr_benchmark: float = 0.0,
                             freq: int = 252, lags: int = 5) -> tuple[float, float]:
    r = np.asarray(returns, dtype=float)
    T_eff = hac_effective_T(r, lags=lags)
    sr = r.mean() / r.std(ddof=1)
    skew = stats.skew(r, bias=False)
    kurt = stats.kurtosis(r, fisher=False, bias=False)
    sr_b = sr_benchmark / np.sqrt(freq)
    denom = np.sqrt(1 - skew * sr + (kurt - 1) / 4 * sr * sr)
    z = (sr - sr_b) * np.sqrt(max(T_eff - 1, 1)) / denom
    return sr * np.sqrt(freq), stats.norm.cdf(z)

T_eff 几乎一定小于 T,意味着自相关下「有效观察」更少、PSR 数值更低。这一步在月频或更低频策略里非常关键,能直接把不少看起来 PSR=0.97 的策略压回 0.85 以下。


七、典型反例:虚假 Sharpe 5 的策略

为了把上面所有概念落到一个具体的反例上,我构造一个在零信号数据上轻松跑出 Sharpe = 5 的「策略」,并演示它被 DSR 一击穿透的过程。

七点零、构造一个虚假 Sharpe 的策略

下面这个反例有两个目的:第一,让读者亲手跑一次「在零信号数据上 IS Sharpe 显著大于零」,建立对 selection bias 量级的直觉;第二,演示 DSR 怎么穿透这个伪发现。理解了这个反例,前面所有公式才会从抽象记号变成可触摸的东西。

我把这个反例做得有意构造——明显的零信号数据、显式的参数搜索、能在普通笔记本上几十秒跑完的代码——这样读者能复现,能改参数 N 看阈值变化,能换数据生成方式看是否还能假性显著。这种「亲手试一遍」对建立直觉远比读公式重要。

七点一、构造

数据:随机正态收益,N_assets = 100 只「股票」,T = 1250 日,每只股票日收益 ~ N(0, 0.01²)。真实 Sharpe = 0

策略:每天选昨日收益最高的 5 只做多、最低的 5 只做空,等权重,第二天 close 平仓再开。这是一个「短期反转 vs 短期动量」二元开关——研究员可以从两个方向各跑一遍,挑表现好的方向作为「策略」。

参数搜索空间:

总搜索空间 N = 6 × 4 × 2 × 3 × 3 = 432。

import numpy as np

rng = np.random.default_rng(20260502)
T, M = 1250, 100
R = rng.normal(0, 0.01, size=(T, M))   # 零信号

def strategy_sharpe(R, lookback, top_k, direction, rebalance, winsor):
    T, M = R.shape
    pnl = np.zeros(T)
    pos = np.zeros(M)
    for t in range(lookback, T - 1):
        if t % rebalance != 0:
            pnl[t+1] = pos @ R[t+1]
            continue
        sig = R[t-lookback+1:t+1].sum(axis=0)
        lo, hi = np.quantile(sig, [winsor, 1-winsor])
        sig = np.clip(sig, lo, hi)
        idx_long = np.argsort(sig)[-top_k:]
        idx_short = np.argsort(sig)[:top_k]
        pos = np.zeros(M)
        if direction == 'momentum':
            pos[idx_long] = 1.0 / top_k
            pos[idx_short] = -1.0 / top_k
        else:
            pos[idx_long] = -1.0 / top_k
            pos[idx_short] = 1.0 / top_k
        pnl[t+1] = pos @ R[t+1]
    return pnl

best_sr = -np.inf
best_pnl = None
n_trials = 0
for lookback in [1, 2, 3, 5, 10, 20]:
    for top_k in [3, 5, 10, 20]:
        for direction in ['momentum', 'reversal']:
            for rebalance in [1, 2, 5]:
                for winsor in [0.0, 0.01, 0.05]:
                    n_trials += 1
                    pnl = strategy_sharpe(R, lookback, top_k, direction, rebalance, winsor)
                    sr = pnl.mean() / pnl.std(ddof=1) * np.sqrt(252)
                    if sr > best_sr:
                        best_sr = sr
                        best_pnl = pnl
                        best_cfg = (lookback, top_k, direction, rebalance, winsor)

print(f'N_trials = {n_trials}')
print(f'best Sharpe (annualized) = {best_sr:.2f}')
print(f'best config = {best_cfg}')

实际跑一遍——你会看到一个 IS Sharpe 在 2–3 之间的「最佳策略」,再加上一些非线性变换、特征筛选、组合优化(每次 N 加倍),轻松能把 IS Sharpe 推到 4–5。

七点二、PSR、DSR 的判决

把 best_pnl 喂进 PSR 和 DSR:

sr_ann, psr = probabilistic_sharpe(best_pnl, sr_benchmark=0.0)
sigma_sr_grid = np.array(...).std(ddof=1)   # 用上面 grid 的所有 sr 算横截面 std
sr_ann2, sr_star, dsr = deflated_sharpe(best_pnl, n_trials=n_trials,
                                        sigma_sr_across_trials=sigma_sr_grid)

print(f'IS Sharpe (annualized) = {sr_ann:.2f}')
print(f'PSR(0)  = {psr:.3f}')
print(f'DSR(N)  = {dsr:.3f}, threshold = {sr_star:.2f}')

预期结果:PSR 给出 0.97+(似乎显著);DSR 在 0.3–0.6 区间(不显著)。原因正是 DSR 把 N=432、σ_SR、偏度、峰度全部装进了去——它能区分「真实 Sharpe 2」和「N 次试验里挑出来最好的 IS Sharpe 2」。

七点三、样本外的归零

把同一组最优配置 best_cfg 拿到一段新数据(rng 用不同的 seed 重新生成 R’)跑:

R_oos = rng.normal(0, 0.01, size=(T, M))
oos_pnl = strategy_sharpe(R_oos, *best_cfg)
oos_sr = oos_pnl.mean() / oos_pnl.std(ddof=1) * np.sqrt(252)
print(f'OOS Sharpe = {oos_sr:.2f}')

OOS Sharpe 的期望是 0(真实信号为 0)。多次仿真平均下来确实接近 0,方差为 1/√T_year ≈ 0.45。这就完整闭环了「IS 漂亮 + DSR 拒掉 + OOS 归零」三件事。

这个反例不是技术 demo,而是研究流程的镜子。每次研究员拿来一份 IS Sharpe 高于 3 的回测,第一反应不应该是兴奋,而是按上面这个流程先证伪。证伪不掉,再考虑上线。

七点四、几种「看起来不一样但本质相同」的反例

上面零信号 + 反转选股的反例可能让人觉得「我又不会真这么写」。事实上同一类陷阱以非常多伪装出现,下面列几种实战里见过的:

反例 A:因子合成里的 IR 加权。研究员有 50 个候选因子,每个 IC 都不显著(≈0.01),但把所有因子按 IS 上的 IR 倒数加权合成成一个「meta-factor」,meta-factor 在 IS 里 IC 突然跳到 0.06。原因是 IR 加权本身就是一次「样本内挑选 + 加权」,等价于 50 维参数搜索。OOS 上 meta-factor IC 通常掉到 0.005–0.015。

反例 B:日内择时的 hourly bin 优化。把一天分成 24 个小时区间,对每个区间单独优化一组持仓权重——这是 24 个独立检验。挑出 IS 表现最好的 8 个小时作为「最佳交易时段」。OOS 上「最佳时段」会 reshuffle 到完全不同的小时。

反例 C:复杂 ML 模型 + 提前停止。XGBoost 训练时用 IS 验证集做 early stopping,迭代次数 n_estimators 自动选 IS 表现最好的那一轮。这一步本身就是参数搜索(n_estimators ∈ {1, 2, …, 10000})。如果验证集泄漏到测试集,OOS 表现一定灾难。

反例 D:信号过滤器的层层组合。一个动量信号 + 一个波动率过滤器 + 一个流动性过滤器 + 一个事件过滤器,每个过滤器单独看都「合理」,组合后 IS Sharpe 很高。但每个过滤器都是一个二元决策(开/关),4 个过滤器就是 2⁴ = 16 个组合的搜索。如果每个过滤器又有阈值(3 档),就是 4³ × 16 = 1024 个组合。研究员通常根本没意识到自己跑了 1000 次试验。

反例 E:板块轮动的「事后解释」。回测发现 2018 年某段时间动量信号在新能源板块特别有效,于是策略加上「在新能源板块里加倍仓位」的规则。这是典型的 in-sample tuning:把已经发生的 outperformance 写进了策略本身。OOS 上「板块加倍」规则的预期收益是 0。

把这五个反例和反例的反例加起来,会发现过拟合的形态变化无穷,但 DSR + 严格 walk-forward + 独立样本验证三件武器组合起来,几乎都能识别。把这三件事做扎实,比纠结「我的 Sharpe 是 4.8 还是 5.0」重要得多。

七点五、和「真有效策略」的对比:怎么区分

仅仅证伪是不够的,还要能识别真信号。把上面这套「IS Sharpe + DSR + walk-forward + 独立样本」流程套在一段真有信号的合成数据上,结果应该是:

把这五条都对上的策略,才有资格说「这是一个真信号」。其中任何一条不对,要回到流程上找原因——是数据问题、还是参数搜索过头、还是某个时段 regime 改变。把每一次「不对」当成线索而不是结论,是回测推断阶段最重要的姿势。

七点六、为什么不要相信「Sharpe 5」的故事

一个反复出现的现象:研究员/同行/会议讲者会展示 IS Sharpe 5、回撤 3%、年化 50% 的策略。每一次出现这种数字时,都应该默认这是 selection bias 的结果,除非给出三件事的同时证据:(1)N 的精确计数与 DSR 计算;(2)至少 3 段独立 OOS 的同方向表现;(3)真金白银运行 6 个月以上的 track record。这三件证据缺一件,「Sharpe 5」就只是一段被挑出来的随机数。这不是悲观,而是统计学:在容量足够大的策略空间里,Sharpe 5 完全可以来自纯噪声——前面的反例代码就是证明。

更深的一层是:真正能稳定跑出 Sharpe 5 的策略,几乎都不会被公开展示——容量极小、生命周期短、暴露之后立刻死掉。你能在公开材料里看到的「Sharpe 5」,绝大多数是 selection 后留下的样本。这个先验本身就比任何统计计算更强。


八、回测自检清单

把前面七节合并成一份上线前回测自检清单。我把它分四组、每组若干条,共 30 条。所有条目要求「逐条勾选 yes/no/理由」,no 必须有书面理由,否则不允许进入纸面交易。

八点零、checklist 的设计哲学

下面这份 checklist 不是「越长越好」——长 checklist 没人会逐条勾选。我把条目限制在 30 条,分四组,每组 5–10 条,确保 30 分钟内能过完一遍。条目的设计原则有四条:第一,每条必须可验证。不允许「策略稳健」「数据干净」这种主观描述,所有条目都要能用代码或数据证伪。

第二,每条必须独立。条目之间不应有「A 通过则 B 自动通过」的依赖,否则 checklist 实质上变短。

第三,no 必须有理由。每个 no 都要写出书面理由,要么是「不适用」(带场景),要么是「已知风险」(带补救措施)。

第四,条目随领域演化。每隔 6 个月 review 一次 checklist,把过去 6 个月暴雷的新模式加进来、把已经被自动化检查覆盖的条目移出去。

第五,checklist 与流水线绑定。每个条目都对应一个 CI 任务或人工签字流程,不允许「研究员口头确认」。

八点一、第一组:语法对(5 条)

  1. 回测引擎跑过随机事件流的 reference 实现对比,PnL 误差小于 0.01% NAV,无 panic?
  2. T+0/T+1 规则、停牌停板、ST 标记、新股次新股规则在事件流里被正确触发?
  3. 复权方向使用前复权或未复权 + 拆股事件流,未在历史价格里注入「未来才发生」的拆股调整?
  4. 手续费、印花税、过户费、买卖价差、市场冲击、跨夜利率全部按现实规则扣除,且各自独立可关闭以做敏感性分析?
  5. 保证金、维持保证金、强平规则按券商真实条款实现,回撤期能正确触发追保 / 强平流程?

八点二、第二组:逻辑对与数据对(10 条)

  1. 所有 fundamental 数据按 PIT 接入,knowledge_time 字段存在并参与 join 过滤?
  2. 财报披露日 vs 报告期末做了显式区分,最早可用时间为 announcement_date + 1 个交易日?
  3. 行业分类、指数成分、信用评级、ESG 标签按当时有效的版本 join,不使用今天的快照?
  4. 所有特征计算的统计量使用滚动或扩展窗口,禁止 df.x.mean()StandardScaler.fit(全数据) 这种全样本写法?
  5. 横截面标准化按每天单独计算,纵向标准化使用 expanding window 且 min_periods 显式设置?
  6. 信号与执行使用双时间戳 schema(decision_time、execution_time),引擎校验 decision < execution?
  7. 标签、特征、信号在时间上对齐,无 off-by-one 错误(昨日特征预测今日收益,而非今日特征预测今日)?
  8. 缺失值填充策略说明清楚(前向填充、零填充、删除),并核对填充范围未覆盖未来观测?
  9. 数据集包含已退市已合并已停牌摘牌的股票历史,覆盖率与当年上市数对得上?
  10. 数据集中已重命名 / 换股代码的股票按当时 CUSIP / ISIN / wind code 跟踪,不被错误合并?

八点三、第三组:推断对(10 条)

  1. 所有跑过的策略变体写入中央实验日志,本次报告的 N 可被独立审计员复现?
  2. 报告里显式给出狭义 N 与广义 N 的估计,并在 DSR 中使用其中较保守的一个?
  3. PSR(0) ≥ 0.95、DSR(N) ≥ 0.95 同时满足?任一不达标必须写出原因?
  4. 单次 IS Sharpe 与 zero-skill 上界 E[max SR | N, T] 的对比写入报告?
  5. IS 与 OOS Sharpe 差距小于 1.0,且 OOS 的 hit rate、avg win/loss、最大回撤与 IS 量级一致?
  6. 参数曲面在最优点附近梯度温和,不存在「梯度陡峭的尖峰」(用每参数±10% 的扰动测试)?
  7. 月度 PnL 的前 5 大正贡献月份合计占总收益不超过 50%,避免净值由极少数事件支撑?
  8. 净值的偏度、峰度、最大回撤、回撤恢复天数同时披露,不单独看 Sharpe?
  9. 用 walk-forward CV(详见下一篇 Walk-forward 与时间序列 CV)独立验证,每段 OOS 的 Sharpe 与全样本 OOS 的方向一致?
  10. 模型选择、超参数搜索的 N 已计入 DSR;nested CV 隔离参数搜索与最终评估?

八点四、第四组:上线现实(5 条)

  1. 策略的容量(capacity)估计已做:单日成交额上限、单笔最大冲击成本、参与率上限、容量衰减曲线?
  2. 执行成本(详见 执行成本)按真实 TCA 模型扣除,不使用统一固定 bps?
  3. 策略的退出机制有明确触发条件:连续亏损天数、累积回撤、Sharpe 衰减阈值、市场结构变化?
  4. 纸面交易(paper trading)至少 1 个月、小额实盘至少 1 个月,OOS 表现与回测在置信区间内一致?
  5. 策略说明书写明所有假设与限制,特别是 DSR 局限(N、σ_SR、i.i.d.、偏度峰度估计稳定性)的具体取值?

把这 30 条逐一勾选过的策略,未必是真 alpha,但至少不是「IS Sharpe 5、DSR 拒掉、OOS 归零」那一类一眼假的策略。回测的工程目的从来不是「证明策略好」,而是「尽最大努力试图证伪它,证伪不掉就放进下一关」。

八点五、把 checklist 写进 CI 流水线

最后一个工程上的建议:把这 30 条 checklist 写成自动化检查,挂在策略发布流水线(CI)上。具体做法:

八点六、回测之外:纸面交易与小额实盘的衔接

回测自检过关只意味着「这条净值不是一眼假的」,距离上线还有两个无法跳过的关卡:纸面交易(paper trading)与小额实盘。这两件事经常被研究员视为「形式主义」,但实际上它们能识别一类回测永远无法识别的问题——实盘环境与回测环境的偏差

纸面交易解决的是「研究和生产代码一致性」。研究环境用 pandas 跑批,生产环境用流式计算(Python on Tornado、Go on NATS、Java on Flink)跑实时——两份代码即便 spec 完全相同,几乎一定会有 numerical 差异:浮点精度、time-zone 处理、缺失数据策略、特征到达顺序。纸面交易至少跑 1 个月,每天对比研究环境的「shadow PnL」与生产环境的「paper PnL」,差异超过阈值就要排查。

小额实盘解决的是「真实市场反馈」。即使纸面交易完美一致,真实下单还会暴露几个回测假设的脆弱性:撮合排队、对手方反应、做市商让价、订单簿深度、停牌实战等。小额实盘建议用回测预测仓位的 1%–5% 跑至少 1 个月。如果 1% 仓位下的真实 Sharpe 比回测 Sharpe 衰减超过 50%,必须找到原因后再放量。

把纸面交易、小额实盘、放量实盘三阶段都加进 checklist,回测的工程闭环才算完整。回测从来不是一个研究问题,而是一个工程问题——而工程问题需要的是层层验证,不是一次性的「跑完就上线」。

八点七、长期视角:策略半衰期与回测的「再回测」

最后一个工程现实:所有策略都有半衰期。McLean & Pontiff(2016)在 Does Academic Research Destroy Stock Return Predictability? 里的实证显示,已发表因子在论文 publication 后的 OOS 表现平均比 IS 衰减 26%,其中相当部分发生在论文发表后的前 2–3 年。原因有两个:一是 selection bias(IS 表现里有 26% 是噪声),二是真实 alpha 也在被市场套利掉。

这意味着「回测自检」不是一次性的事件,而是持续过程。已经上线的策略每隔 6 个月需要做一次「再回测」:用最新一段 6 个月数据作为 OOS,重新计算 Sharpe、回撤、IC、参数稳定性,与上线前的预期对照。表现衰减超过预设阈值(比如 OOS Sharpe 跌到 IS Sharpe 的 50% 以下)就触发 review,决定是降仓、换参数、还是下架。

把「再回测」做成自动化任务、每月输出一份策略健康度报告,是成熟量化基金的标配。回测的意义不在于「上线那一刻的 Sharpe」,而在于「持续观察策略生命周期」的工程能力。这一点也是把回测从「研究练习」上升到「生产组件」的本质区别。

九点零、本文的几个核心断言

把全文压缩成五个可独立带走的断言,作为给团队培训的材料:

断言一:回测的 Sharpe 不是一个点估计,而是一个分布。任何只报 Sharpe 不报 PSR/DSR 的回测报告都是不完整的。

断言二:N(试验次数)必须被精确计量、被自动日志化。研究员凭印象给的 N 几乎一定低估真实值。

断言三:前视偏差几乎一定让 Sharpe 上升,所以「Sharpe 突然变好」永远先怀疑前视偏差。

断言四:IS 与 OOS Sharpe 差距大于 1 一定是过拟合信号;差距小于 1 不一定不过拟合,但至少不是一眼假。

断言五:DSR ≥ 0.95、walk-forward 多段 OOS 同方向、独立样本一致——这三件证据合在一起,是「值得纸面交易」的最低门槛。仍不是「值得上线」——上线还要走纸面交易、小额实盘、放量三阶段。

把这五条贴在团队墙上、写进每篇研究 wiki 的 page header,比记住任何具体公式都更有长期价值。


九、参考文献

论文与书

  1. Bailey D H, López de Prado M. The Sharpe Ratio Efficient Frontier. Journal of Risk, 2012, 15(2): 3-44.
  2. Bailey D H, López de Prado M. The Deflated Sharpe Ratio: Correcting for Selection Bias, Backtest Overfitting, and Non-Normality. Journal of Portfolio Management, 2014, 40(5): 94-107.
  3. Bailey D H, Borwein J, López de Prado M, Zhu Q J. The Probability of Backtest Overfitting. Journal of Computational Finance, 2017, 20(4): 39-69.
  4. Bailey D H, Borwein J, López de Prado M, Zhu Q J. Pseudo-Mathematics and Financial Charlatanism: The Effects of Backtest Overfitting on Out-of-Sample Performance. Notices of the American Mathematical Society, 2014, 61(5): 458-471.
  5. Lo A W. The Statistics of Sharpe Ratios. Financial Analysts Journal, 2002, 58(4): 36-52.
  6. Harvey C R, Liu Y. Backtesting. Journal of Portfolio Management, 2015, 42(1): 13-28.
  7. Harvey C R, Liu Y, Zhu H. … and the Cross-Section of Expected Returns. Review of Financial Studies, 2016, 29(1): 5-68.
  8. Harvey C R, Liu Y. False (and Missed) Discoveries in Financial Economics. Journal of Finance, 2020, 75(5): 2503-2553.
  9. Benjamini Y, Hochberg Y. Controlling the False Discovery Rate. Journal of the Royal Statistical Society B, 1995, 57(1): 289-300.
  10. Benjamini Y, Yekutieli D. The Control of the False Discovery Rate in Multiple Testing under Dependency. Annals of Statistics, 2001, 29(4): 1165-1188.
  11. White H. A Reality Check for Data Snooping. Econometrica, 2000, 68(5): 1097-1126.
  12. Romano J P, Wolf M. Stepwise Multiple Testing as Formalized Data Snooping. Econometrica, 2005, 73(4): 1237-1282.
  13. Sullivan R, Timmermann A, White H. Data-Snooping, Technical Trading Rule Performance, and the Bootstrap. Journal of Finance, 1999, 54(5): 1647-1691.
  14. de Prado M L. Advances in Financial Machine Learning. Wiley, 2018.
  15. de Prado M L. Machine Learning for Asset Managers. Cambridge University Press, 2020.
  16. Hastie T, Tibshirani R, Friedman J. The Elements of Statistical Learning. 2nd ed. Springer, 2009.
  17. Newey W K, West K D. A Simple, Positive Semi-Definite, Heteroskedasticity and Autocorrelation Consistent Covariance Matrix. Econometrica, 1987, 55(3): 703-708.
  18. Politis D N, Romano J P. The Stationary Bootstrap. Journal of the American Statistical Association, 1994, 89(428): 1303-1313.
  19. Hansen P R. A Test for Superior Predictive Ability. Journal of Business & Economic Statistics, 2005, 23(4): 365-380.
  20. Fama E F, French K R. Dissecting Anomalies. Journal of Finance, 2008, 63(4): 1653-1678.
  21. McLean R D, Pontiff J. Does Academic Research Destroy Stock Return Predictability? Journal of Finance, 2016, 71(1): 5-32.
  22. Hou K, Xue C, Zhang L. Replicating Anomalies. Review of Financial Studies, 2020, 33(5): 2019-2133.
  23. Chen A Y, Zimmermann T. Open Source Cross-Sectional Asset Pricing. Critical Finance Review, 2022, 11(2): 207-264.

软件

  1. numpy 项目文档. https://numpy.org/doc/
  2. pandas 项目文档. https://pandas.pydata.org/docs/
  3. scipy 项目文档. https://docs.scipy.org/doc/scipy/
  4. statsmodels 项目文档. https://www.statsmodels.org/
  5. arch 项目(HAC、GARCH 估计)文档. https://arch.readthedocs.io/

导航:上一篇 【量化交易】回测引擎:事件驱动、撮合规则、滑点模型 | 下一篇 【量化交易】Walk-forward 与时间序列 CV


写到这里,全文从「回测可信度的四个层次」开始,到「30 条 checklist + CI 自动化 + 纸面交易 + 小额实盘 + 再回测」结束,构成一条完整的回测推断链路。每一个具体公式(Bonferroni、BH、Holm、PSR、DSR、SPA、HAC)都可以独立带走、独立实现、独立验证;但它们合在一起才能挡住「IS Sharpe 5、OOS Sharpe 0」这类一眼假的策略。回测的工程目标从来不是「让回测更好看」,而是「让回测更可信」。研究员的职业荣誉感,应该建立在「我的回测 OOS 表现与 IS 一致」上,而不是建立在「我跑出了一个 IS Sharpe 5」上。

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-05-01 · quant

【量化交易】量化交易全景:从信号到订单的工程链路

量化交易不是策略写得好就能赚钱,更难的是把数据、特征、因子、信号、组合、执行、风控、复盘这八段链路在工程上连成一条不漏数据、不串时间、不丢订单的流水线。本文是【量化交易】系列的总目录与读图,给出八段链路的输入输出、失败模式、不变量清单,并用研究流程图把从一个想法到一笔实盘订单之间所有该过的卡点串起来。

2026-05-01 · quant

【量化交易】数据陷阱:幸存者偏差、复权、前视、未来函数

系统拆解量化回测里最常见的几类数据陷阱:幸存者偏差、前视偏差、未来函数、数据窥视、复权陷阱、停牌与流动性陷阱、时区与日历对齐。给出 Point-In-Time 财报库的最小可运行实现,演示前视回测与 PIT 回测之间的真实差距,并整理一份回测前自检清单。

2026-05-01 · quant

【量化交易】回测引擎设计:事件驱动与向量化

把回测引擎当成一套工程系统讲清楚:事件驱动架构、撮合保真度、滑点嵌入、多频率多账户、并行加速、回放对账。给出可运行的最小事件驱动回测器与 vectorbt 向量化对照实现。


By .