返回交易笔记
TR Day 23

三大回测偏差 — Survivorship / Look-ahead / Selection

三大回测偏差的定义、机制、识别、修复;Data Snooping / Future Function 进阶

2026-06-01
Phase 1: 基础与工具链
SurvivorshipBiasLookAheadBiasSelectionBiasDataSnoopingPHackingPIT

日期: 2026-06-01 方向: 回测严谨性 / 偏差 阶段: Phase 1: 基础与工具链 标签: #SurvivorshipBias #LookAheadBias #SelectionBias #DataSnooping #PHacking #PIT


今日目标

类型内容
学习三大回测偏差的定义、机制、识别、修复;Data Snooping / Future Function 进阶
实操写一个 audit_backtest() 函数,自动扫描回测代码与数据流的偏差风险
产出TR-DAY23 笔记 + 偏差自检清单 + Sharpe 3.0 怀疑链 + 代码工具

零、为什么 Week 4 第一课就讲偏差

到 Day 22 我们已经能跑通:数据拉取(Day 8-11)、信号生成(Day 12-15)、组合构建(Day 16-19)、绩效评估(Day 20-22)。从这一刻起,工具链上的「能跑出 Sharpe 数字」的策略,全都是廉价的——一个会用 pandas 的人一周内可以「制造」出 Sharpe 3 的回测结果。

真正稀缺的是「回测可信」这件事

回测里有三类问题,严重性递增:

Tier 1: Bug 类         (程序写错了 → 回测崩 / 不收敛 / 数字明显异常)→ 容易发现
Tier 2: 偏差类         (程序对,但用了不该有的信息 → 回测看起来很好)→ 难发现
Tier 3: 哲学类         (样本不代表未来,过去不再重复)→ 不可消除,只能管理

Tier 1 一跑就崩,反而最不危险。Tier 2 最致命:因为它会让一个本来没有 alpha、甚至是负 alpha 的策略,在回测里呈现出诱人的 Sharpe,于是你把钱投进去,实盘三个月归零。

今天就专注 Tier 2 的三大杀手。


一、为什么这三个偏差最致命

偏差现象后果实盘表现
Survivorship回测只看活下来的股票高估 return 1-2%/年缓慢失血,看起来「策略变差了」
Look-ahead用了未来才知道的信息高估 Sharpe 0.3-1.5+立刻表现差,几个月归零
Selection选了巧合好看的子集高估各项指标实盘像 Random Walk

三者共同点:

  1. 不会让程序报错——所有数学都「自洽」
  2. 不会让回测曲线难看——恰恰相反,曲线漂亮
  3. 会被自己说服——「我反复检查过代码,没问题」
  4. 实盘暴露——少则一个月,多则一年

行业里有句话:「The market doesn't have survivorship bias. Your backtest does.」实盘交易的时候,破产、停牌、退市、收购的股票每天都在发生,但你回测的数据集里它们「干净地消失了」。


二、Survivorship Bias(幸存者偏差)

2.1 定义与机制

定义:回测使用的股票池是「当前还存在」的股票,自动剔除了所有破产、退市、被收购、合并、改名的股票。

为什么会发生:所有数据源(Yahoo Finance、AKShare、yfinance、tushare)默认提供的是「current universe」——它的逻辑是「我现在能查到的股票」。已经退市的股票要么完全没有,要么需要单独的「delisted」字段付费才能拿到。

SP500 当前 500 只股票
     ↓ 回测 1995-2025
你以为:「过去 30 年这 500 只股票的表现」
真实:  「过去 30 年 → 今天还在 SP500 → 这 500 只」
        = 一个「未来知道结局」的幸存者集合

2.2 后果有多大

时间窗口高估幅度(年化 return)
1 年0.3-0.8%
5 年1.0-1.5%
10 年1.5-2.5%
20 年2.5-4.0%

对 Sharpe 的影响视策略而定,但保守估计 0.2-0.5 的 Sharpe 高估,足以让一个 break-even 的策略看起来像 Sharpe 1.0

2.3 真实案例:1995-2000 互联网股

用今天的 NASDAQ 100 成分股回测 1995-2000,会得到一个荒诞美好的结果。原因:

  • 1999 年市值前 100 的公司:Pets.com、Webvan、eToys、Boo.com、Drkoop.com、Kozmo.com……
  • 2002 年这些公司:全部退市归零
  • 今天 NASDAQ 100:Apple、Microsoft、Nvidia、Google……
  • 回测时这些泡沫股根本不在 universe 里

实际历史:1995-2000 NASDAQ 整体高位,但分散持有 NASDAQ 100 的散户 50% 以上的资金归零。回测里看不到这一段

2.4 类似案例:A 股 ST 与退市

  • 中国证券市场早期退市制度松,2010s 之前真正退市的极少
  • 但 ST 制度(戴帽 → 停牌 → 风险警示 → 退市)每年仍有数十只股票
  • AKShare / tushare 默认股票池有时不包含已退市的,回测过去 10 年小盘股策略很容易高估

2.5 解决方案

理想方案:Point-in-Time(PIT)成分股 + 完整 delisted 数据

数据源价格PIT 覆盖
CRSP(学术)院校订阅 $$$1925- 完整
Compustat$$$全球
Sharadar$50-150/月美股完整 delisted
Norgate Data$59/月起美股 + 加拿大,PIT 成分股
Quandl / Nasdaq Data Link部分免费不完整
Yahoo / yfinance免费❌ 不行
AKShare免费部分覆盖,需要自己拼

散户折中方案

  1. 意识到偏差存在——这是 80% 的修复
  2. 给回测年化 return 打 8-9 折 作为粗略修正
  3. 用 ETF 替代股票池——ETF 自己处理退市,回测 SPY/QQQ 本身不存在 survivorship
  4. 手动维护 universe 历史——每个月份用历史成分股快照,工程量大但可行
  5. 检查策略对退市股票的鲁棒性——故意手动加入几只历史上的「问题股」(如 Lehman Brothers 2008、Wirecard 2020)看策略是否有逻辑去识别

2.6 散户级 PIT 简化方案

# 用 Wikipedia 历史快照拼 SP500 成分股变更
# https://en.wikipedia.org/wiki/List_of_S%26P_500_companies#Selected_changes_to_the_list_of_S&P_500_components
# 该页面有完整的 added / removed 历史
# 自己写爬虫每个月 snapshot 成分股,回测时按日期查询

def get_universe_as_of(date):
    """返回 date 当时的 SP500 成分股集合"""
    snapshot = load_universe_snapshot(date)  # 自己维护的 JSON
    return set(snapshot['tickers'])

工程量约 1-2 天,能省掉一辈子的 survivorship bias。


三、Look-ahead Bias(前视偏差)

3.1 定义

定义:在 t 时刻的决策用到了 t+x(x>0)才能知道的信息。

这是最常见、最隐蔽、最致命的偏差。它会在你完全没意识到的情况下,让 Sharpe 暴涨。

3.2 经典错误清单

错误 1:基本面数据用 fiscal date 而不是 announcement date

# ❌ 错误
df = df.merge(fundamentals, left_on='date', right_on='fiscal_year_end')
# 财年 2023-12-31 的财报实际是 2024-03-15 才公布
# 用 2024-01-15 的价格 + 2023-12-31 的财报 = 提前 2 个月知道未公布的数据
# ✅ 正确
df = df.merge(fundamentals, left_on='date', right_on='announcement_date')
# 或者保守一点:announcement_date + 1 day(避免盘中公布)

错误 2:用当日收盘价决定当日交易

# ❌ 错误:T 日收盘后才能知道收盘价,但回测里假设 T 日开盘就交易了
df['signal'] = df['close'].rolling(20).mean() / df['close'] - 1
df['position'] = df['signal'].apply(lambda x: 1 if x > 0 else -1)
df['return'] = df['position'] * df['close'].pct_change()  # 当天买当天卖
# ✅ 正确:T-1 日收盘后生成信号,T 日开盘交易
df['signal'] = df['close'].rolling(20).mean() / df['close'] - 1
df['position'] = df['signal'].shift(1).apply(lambda x: 1 if x > 0 else -1)
df['return'] = df['position'] * (df['open'].shift(-1) / df['open'] - 1)  # T 开盘到 T+1 开盘

更严格的:用 T-1 close → T open 的策略需要看「次日早上能不能在 T-1 收盘价附近买到」,常常买不到(跳空)。

错误 3:标准化用了全样本统计量

# ❌ 错误:z-score 用了未来的 mean / std
df['feature_z'] = (df['feature'] - df['feature'].mean()) / df['feature'].std()
# 2010 年的 z-score 用了 2010-2025 的 mean → 提前知道了未来 15 年的分布
# ✅ 正确:expanding 或 rolling window
df['feature_z'] = (df['feature'] - df['feature'].expanding(min_periods=252).mean()) / \
                   df['feature'].expanding(min_periods=252).std()
# 或 rolling(252),根据信号衰减特征选

错误 4:除权除息修正用了未来拆股信息

# ❌ 错误
# Yahoo 提供的「adjusted close」是用今天 retroactive 修正的
# 一只股票 2023 年拆股 2:1,2020 年的 adj_close 会被除 2
# 但 2020 年回测时你并不知道 2023 年会拆股
# 用 adj_close 做信号 → 提前知道未来拆股
# ✅ 正确
# 用 unadjusted close + 实时拆股事件流,每个时点的「调整因子」只包含此前的事件
# 大多数 retail 数据源不提供这个,必须用 Sharadar / Polygon / IEX Cloud
# 散户折中:意识到这个偏差对短期信号(≤1 个月)影响小,长期回测影响大

错误 5:universe filter 用了未来信息

# ❌ 错误:选 market cap > $1B 的股票回测
universe = stocks[stocks['current_market_cap'] > 1e9]
# 这是「现在」市值 > $1B,2010 年它们可能还很小
# 等同自动筛选了「2010-2025 涨上来的那批」
# ✅ 正确:按当时的 market cap
df = df[df['market_cap_at_time'] > 1e9]

错误 6:处理停牌 / 异常返回值用了未来信息

# ❌ 错误:丢弃了所有「returns 异常」的样本
df = df[df['return'].abs() < 0.5]  # 排除日涨跌 >50%
# 在 2008-09-29(雷曼后一周)有股票日跌 40%+
# 你回测时如果系统性丢掉这些点,等同假装「我能预知避开这天」

错误 7:滚动训练 ML 模型用了 leakage 特征

# ❌ 错误:用「未来 20 日 return」做 label,用「同期波动率」做 feature
df['label'] = df['close'].shift(-20) / df['close'] - 1
df['feature_vol'] = df['close'].pct_change().rolling(20).std()
# 训练时 feature 的窗口可能和 label 重叠 → 偷看未来波动

3.3 代码侦察清单

每次 review 回测代码,盯着这些 pattern 看:

Pattern检查点
.shift(n)n 是正还是负?方向对吗?时间索引对齐了吗?
.rolling(n)是「过去 n」还是「以当前为中心的 n」?默认是过去 ✓
.expanding()起点对吗?min_periods 够不够大?
.mean() / .std()是否用了全样本?
merge / join时间 key 对齐了吗?需要 merge_asof 而不是 merge
dropna()dropna 之后还有 lookahead 信息吗?
groupby().transform()transform 后的值用了 group 全部数据吗?
pct_change()默认是 (t)/(t-1) - 1,作为 T+1 的 feature 没问题;作为 T 的 feature 不行

3.4 merge_asof 是关键工具

import pandas as pd

# prices 是日度,fundamentals 是季度(announcement_date 不规则)
# 想给每个 price 行附上「最新已公布的财报」

# ❌ 错误:merge on date 会丢失 + 错位
df = prices.merge(fundamentals, on='date', how='left')

# ✅ 正确:merge_asof 找「不晚于当前日期」的最新公告
df = pd.merge_asof(
    prices.sort_values('date'),
    fundamentals.sort_values('announcement_date'),
    left_on='date',
    right_on='announcement_date',
    direction='backward',  # 关键:只看过去
    allow_exact_matches=True
)

direction='backward' 是 look-ahead-safe 的护栏,默认必须用 backward


四、Selection Bias(选样偏差)

4.1 定义

定义:你选了一个「巧合表现好」的子集——universe、时间段、参数组合——来展示策略,而这个子集对未来没有代表性。

4.2 三种表现形式

4.2.1 Universe Selection(股票池选样)

「我用我熟悉的 20 只股票回测,Sharpe 2.5」
→ 你之所以「熟悉」它们,是因为它们这几年表现好被你 picked up 了
→ 等同于 lookahead 在选 universe 阶段就发生了

例子:

  • 「回测 FAANG 的动量策略」→ FAANG 这个组合本身就是事后命名的
  • 「我们只在中国半导体板块回测 AI 策略」→ 半导体过去 5 年涨 5 倍,什么策略都 work
  • 「Web3 alpha 在 top 10 marketcap token 上回测」→ top 10 是当下的,三年前的 top 10 完全不同

修复:用 rule-based universe(按规则选股票池),而非 named universe(按名字)。例如「all stocks above $100M market cap at each rebalance date」。

4.2.2 Period Selection(时间段选样)

「策略在 2010-2020 Sharpe 2.5」
→ 这是美股史上最长牛市,long-bias 策略全都看起来好
→ 把 2000-2003 / 2008 / 2022 加进去看看?
时段市场环境策略偏好
2000-2003互联网泡沫破裂Long-only momentum 死亡
2003-2007复苏牛市几乎一切都赚
2008-2009金融危机Trend-following 大胜 / mean-reversion 死亡
2010-2019量化宽松牛市Long-only 全胜
2020-Q1闪崩 + 反弹任何 stop-loss 全废
2020-2021meme 狂热retail 反向 = alpha
2022加息熊市Defensive / 反向因子
2023-2025AI 牛市集中等权重失效 / 头部集中 alpha

经验法则:任何回测周期 <10 年的策略都该被打个问号,<5 年的回测基本无意义。

4.2.3 Strategy Selection(策略选样)

最阴险也最常见的一种——「我试了 100 个想法,挑了表现最好的 5 个写笔记」。

你以为:     「这 5 个策略 Sharpe 都 >2,看起来很 robust」
真实是:     「100 个 random walk 里选最好的 5 个,Sharpe >2 是必然现象」

学术界叫 garden of forking paths(分叉花园)。每次你做一个小选择(用 20 日还是 50 日均线?rebalance 月度还是周度?卡 top 10% 还是 top 20%?),你都在分叉树上走一步。等你走到「这个看起来很好」的叶子,整棵树上 99% 的叶子是「不好的」。

4.3 修复方案

修复做法
预注册在跑回测前写下「我要测什么」,跑完不允许改 universe / period
完整时间段强制回测包含至少一个完整经济周期(≥10 年)
out-of-sample留出最近 20-30% 时段绝不看,只在最终验证用一次
多个 universe同一个策略在 US / EU / EM / Crypto 上跑,至少 2 个 work 才信
诚实记录失败试了 100 个变体,全记下来,而不是只展示最好的 5 个

五、Data Snooping / P-Hacking

5.1 多重比较问题

你试 N 个参数组合,从中选 Sharpe 最高的——即使所有 N 个底层数据都是 random walk,最高那个的 Sharpe 也会很高,仅仅是因为试得多。

单次实验:     Sharpe ~ N(0, 1),P(Sharpe > 2) ≈ 2.5%
试 100 次取最高:  E[max Sharpe] ≈ 2.5+
试 1000 次取最高: E[max Sharpe] ≈ 3.3+
试 10000 次取最高:E[max Sharpe] ≈ 4.0+

回测网格搜索(如 5 个均线参数 × 4 个止损 × 3 个仓位 × 6 个 universe = 360 组合)等同试 360 次。

5.2 Bonferroni 校正

最简单的修正:

原显著性水平:     α = 0.05(5% 置信度判断「策略有 alpha」)
试 N 次后:       α' = α / N = 0.05 / 100 = 0.0005
对应 Sharpe 阈值: 从 1.96σ 提高到 3.29σ

实际意义:试了 100 个参数,发现 Sharpe 2.0 的那个,不应该认为有 alpha——因为 100 次随机尝试本来就有较高概率产生 Sharpe 2.0。

5.3 López de Prado "Deflated Sharpe"

更精细的方法(适合工业级):

import numpy as np
from scipy.stats import norm

def deflated_sharpe(sharpe_obs, n_trials, skew, kurt, n_obs):
    """
    Deflated Sharpe Ratio (López de Prado 2014)
    sharpe_obs: 你观察到的最高 Sharpe
    n_trials: 你试过的策略变体数
    skew: 收益偏度
    kurt: 收益峰度
    n_obs: 样本数(天数)
    返回:DSR(在 multi-testing 修正后的真实显著性概率)
    """
    expected_max_sharpe = np.sqrt(2 * np.log(n_trials))  # null hypothesis 下的期望最大
    var_adjustment = (1 - skew*sharpe_obs + (kurt-1)/4*sharpe_obs**2) / (n_obs - 1)
    dsr_z = (sharpe_obs - expected_max_sharpe) / np.sqrt(var_adjustment)
    return norm.cdf(dsr_z)

# 例:试了 100 次,Sharpe 2.0,3 年日数据
print(deflated_sharpe(2.0, 100, -0.5, 5, 750))
# 输出可能 < 0.5 → 完全不显著

5.4 实践守则

  1. 数清楚自己试过多少次——包括「我没记录但脑子里试过的」
  2. 每次试错都要记录——一份 experiments.csv 列所有 backtest 配置和结果
  3. 保留一个永不看的 holdout——比如「2024 年后所有数据」,整个研究期间不允许查看
  4. 2-3 倍冗余——如果想要真 Sharpe ≥1,回测里得看到 Sharpe ≥ 2 以上(粗糙规则)

六、Future Function(未来函数)

6.1 定义

定义:数据本身就包含未来信息。这不是「你写错代码」,而是「数据源给你的就是未来污染过的」。

6.2 经典例子

6.2.1 Bloomberg / Capital IQ 的「restated」财报

公司发布 2023 Q3 财报后,2024 Q2 又「重述」了 Q3 数字(因为查到错账、合并调整、税法变更等),Bloomberg 会用 retroactive 修正后的数字覆盖原始 Q3 数字。

你以为查的是:     「2023-10-15 当时市场看到的财报」
实际查到的是:     「今天的最新 retroactive 版本」
偏差程度:        中等大公司每年 5-15% 的财报会有重述

6.2.2 Index 成分股的事后追溯

某只股票 2020 年 6 月加入 SP500,6 月之前不在 SP500 里。如果数据源把「SP500 历史」标成「现在的 SP500 + 当时的 OHLC」,你回测 2018 年的 SP500 策略时,它已经包含了未来才会加入的那只股票(且通常是因为表现好才加入的)。

6.2.3 已修正的拆股 / 除权数据

如前述错误 4。

6.2.4 Crypto 的「historical token list」

Coingecko / Coinmarketcap 默认列出「现在活跃的 token」的历史价格。所有跑路 / rug / 归零的 token 不在 list 里。某个「Sharpe 3.0 的山寨币因子」可能完全建立在「不算归零的那些」上。

6.3 解决:PIT(Point-in-Time)数据

PIT 数据源的承诺:
"if you query as of date D, you get exactly what someone would have known on date D"

数据成本:
- Compustat PIT:      $$$$ (需机构订阅)
- S&P Capital IQ PIT: $$$$
- Bloomberg PIT:      包含在终端订阅 ($24k/year)
- Sharadar SF1:       $150/mo,有 PIT 财报
- Quandl WIKI:        已停止维护
- AKShare(中国):    部分有,需要手动校验

散户路径:
1. 用 Sharadar / Norgate(最便宜的 PIT 选项)
2. 或在 Yahoo 数据上明确接受「我有未来函数偏差,结果打 7-8 折看」
3. 或避开 fundamental 策略,专做 technical / price-only(受 future function 影响小)

七、完整 self-check checklist

每次跑完一个回测,对着这份清单逐项过。任何 ❓ 都要解决才能信结果。

7.1 数据层

  • 我的股票池是 PIT 的吗?退市股票是否包含?
  • 我的财报数据用的是 announcement date 还是 fiscal date?
  • 我用的是 adjusted close 还是 unadjusted + 实时拆股事件?
  • 任何 join 操作我用的是 merge_asof(direction='backward') 吗?
  • 数据源是否有 retroactive 修正历史?

7.2 信号层

  • 每个 feature 的输入是 T-? 日已知的?
  • rolling / expanding 窗口的对齐方向对吗?
  • 标准化用的是 expanding 还是 in-sample?
  • 是否有 transform / fillna / dropna 隐含未来信息?

7.3 交易执行层

  • T 日信号 → T+? 日成交?至少要 +1 个 bar 的滞后
  • 用什么价格成交?close 不现实,next open 更合理
  • 是否考虑了停牌 / 涨跌停 / 跳空买不到?
  • commission / slippage 是否合理(不要 0)?

7.4 Universe 层

  • universe 的筛选条件用的是当时的信息(market cap at time, listed at time)?
  • 是否有 hand-picked stock / sector / region?
  • 是否包含至少一个完整经济周期?

7.5 参数层

  • 我试过多少组参数?记录在哪?
  • 是否做了 Bonferroni / Deflated Sharpe 修正?
  • 有 out-of-sample 留存吗?
  • 在最终判断之前,OOS 数据被看过几次?(超过 1 次就开始污染)

八、代码实操:audit_backtest() 自检函数

"""
audit_backtest.py
给定一个 strategy DataFrame 和 metadata,输出可能的偏差风险点。
不可能 100% 自动,但能 catch 60%+ 的低级错误。
"""
import pandas as pd
import numpy as np
import inspect


def audit_backtest(
    df: pd.DataFrame,
    signal_col: str,
    return_col: str,
    universe_source: str = "unknown",
    fundamentals_join_method: str = "unknown",
    n_trials_tried: int = 1,
    oos_start_date: str = None,
    verbose: bool = True,
):
    """
    审计回测的偏差风险。返回 (risk_score, issues_list)。
    risk_score: 0-100,越高越可疑
    """
    issues = []
    score = 0

    # 1. Survivorship bias
    if universe_source.lower() in ("yfinance", "yahoo", "akshare_default", "current_index"):
        issues.append("[CRITICAL] Universe source likely has survivorship bias. "
                      "Discount returns by ~15%.")
        score += 25

    # 2. Look-ahead in signal vs return alignment
    if signal_col in df.columns and return_col in df.columns:
        # 检查 signal 和 return 是否同期 — 应该 signal[t-1] -> return[t]
        corr_same_day = df[signal_col].corr(df[return_col])
        corr_lag1 = df[signal_col].shift(1).corr(df[return_col])
        if abs(corr_same_day) > abs(corr_lag1) * 1.3:
            issues.append(f"[HIGH] Same-day signal-return correlation ({corr_same_day:.3f}) "
                          f"materially exceeds lag-1 correlation ({corr_lag1:.3f}). "
                          f"Probable look-ahead in signal.")
            score += 20

    # 3. Fundamentals join method
    if fundamentals_join_method.lower() in ("fiscal_date", "year_end", "quarter_end"):
        issues.append("[HIGH] Fundamentals joined on fiscal date, not announcement date. "
                      "Typical 30-90 day forward look-ahead.")
        score += 15
    elif fundamentals_join_method.lower() == "merge_forward":
        issues.append("[CRITICAL] merge_asof with direction='forward' = explicit look-ahead.")
        score += 30

    # 4. Multi-testing penalty
    if n_trials_tried > 10:
        from math import log, sqrt
        expected_max = sqrt(2 * log(n_trials_tried))
        issues.append(f"[MEDIUM] {n_trials_tried} variants tried. "
                      f"Null-hypothesis expected max Sharpe ≈ {expected_max:.2f}. "
                      f"Your observed Sharpe must significantly exceed this.")
        score += min(20, n_trials_tried // 10)

    # 5. Sample size
    n_obs = len(df)
    if n_obs < 252 * 3:
        issues.append(f"[HIGH] Only {n_obs} observations (<3 years). "
                      f"Cannot conclude on strategy validity.")
        score += 15
    elif n_obs < 252 * 10:
        issues.append(f"[LOW] {n_obs} observations (<10 years). "
                      f"Missing at least one full market cycle.")
        score += 5

    # 6. OOS hygiene
    if oos_start_date is None:
        issues.append("[MEDIUM] No out-of-sample period defined.")
        score += 10

    # 7. Suspicious returns (data quality)
    if return_col in df.columns:
        r = df[return_col].dropna()
        if (r.abs() > 0.5).sum() > 0:
            issues.append(f"[INFO] {(r.abs() > 0.5).sum()} daily returns > 50%. "
                          f"Check for data errors or genuine fat tails.")
        if r.std() < 1e-6:
            issues.append("[CRITICAL] Return std ≈ 0. Strategy may not be trading.")
            score += 30

    # 8. Sharpe sanity
    if return_col in df.columns:
        r = df[return_col].dropna()
        if r.mean() != 0 and r.std() != 0:
            sharpe = r.mean() / r.std() * np.sqrt(252)
            if sharpe > 3:
                issues.append(f"[CRITICAL-SUSPICION] Sharpe = {sharpe:.2f}. "
                              f"P(real edge | Sharpe>3) < 20% for retail-accessible strategy. "
                              f"Investigate biases before believing.")
                score += 20
            elif sharpe > 2:
                issues.append(f"[CAUTION] Sharpe = {sharpe:.2f}. "
                              f"Above typical retail-achievable range. Audit needed.")
                score += 10

    score = min(100, score)

    if verbose:
        print(f"=== BACKTEST AUDIT REPORT ===")
        print(f"Risk Score: {score}/100")
        print(f"Issues found: {len(issues)}")
        for i, msg in enumerate(issues, 1):
            print(f"  {i}. {msg}")
        if score >= 60:
            print("⚠️  HIGH RISK — do NOT trade this strategy live.")
        elif score >= 30:
            print("⚠️  MEDIUM RISK — multiple issues require resolution.")
        else:
            print("✓ LOW RISK — but human review still required.")

    return score, issues


# 使用示例
if __name__ == "__main__":
    # 模拟一份回测数据
    np.random.seed(42)
    dates = pd.date_range("2022-01-01", periods=500, freq="B")
    df = pd.DataFrame({
        "date": dates,
        "signal": np.random.randn(500),
        "return": np.random.randn(500) * 0.01 + 0.0008,
    })

    score, issues = audit_backtest(
        df=df,
        signal_col="signal",
        return_col="return",
        universe_source="yfinance",            # 触发 survivorship 警告
        fundamentals_join_method="fiscal_date",# 触发 lookahead 警告
        n_trials_tried=50,                     # 触发 multi-testing 警告
        oos_start_date=None,                   # 触发 OOS 警告
    )

预期输出:

=== BACKTEST AUDIT REPORT ===
Risk Score: 75/100
Issues found: 5
  1. [CRITICAL] Universe source likely has survivorship bias...
  2. [HIGH] Fundamentals joined on fiscal date, not announcement date...
  3. [MEDIUM] 50 variants tried. Null-hypothesis expected max Sharpe ≈ 2.80...
  4. [HIGH] Only 500 observations (<3 years)...
  5. [MEDIUM] No out-of-sample period defined.
⚠️  HIGH RISK — do NOT trade this strategy live.

这个函数不能取代 human review,但能在 5 秒内 catch 60% 以上的低级错误,作为「跑回测前最后一道闸」很有用。


九、看到一个 "Sharpe 3.0" 策略,先怀疑什么

这是 quant 圈的经典思维训练。任何人——包括论文作者、KOL、付费课程导师——给你看一个 Sharpe ≥ 3 的策略,默认怀疑链如下:

怀疑顺序可能性提问
① 数据偏差~60%universe 是 PIT 吗?财报用 announcement date 吗?退市股票包含吗?拆股调整是 retroactive 的吗?
② 多重比较~20%你试了多少组参数?这是「最好那个」吗?OOS 表现如何?
③ Look-ahead 代码 bug~10%给我看 signal 生成 → 交易执行的代码片段。shift 方向对吗?merge_asof 方向对吗?
④ 实盘成本未计~5%算了 commission + slippage 吗?组合换手率多少?容量多大?
⑤ 真有 edge<5%这个 edge 为什么没被套利掉?什么结构性原因维持了它?

面试加分思路:当面试官 / 同事问「你怎么看这个 Sharpe 3.0 的策略?」,你不应该说「哇好厉害」,也不应该说「肯定是假的」——正确答案是用上面这条怀疑链结构化提问,证明你是个「会做尽职调查的量化研究员」。


十、PM 视角:用户研究里的同样三大坑

这套偏差思维不是量化独有的,PM 在做用户研究和 A/B 测试时面对完全同构的问题。10 年金融零售 PM 经验可以直接迁移。

10.1 用户研究里的 Survivorship Bias

量化版:     用今天还在的股票回测过去
PM 版:      只研究「目前注册的活跃用户」,自动剔除了流失用户

后果:       「活跃用户都觉得功能 X 好用」← 因为不喜欢 X 的人已经流失了
真相:       X 可能就是导致流失的原因

修复

  • 主动追访流失用户(exit interview)
  • 留存率分析必须按 cohort,不能用「当前用户」推「历史用户」
  • 任何「用户满意度」调查都要看响应率,未响应的那部分通常是真实不满意

10.2 A/B Test 里的 Look-ahead Bias

量化版:     用未来才知道的信息做今天的交易决策
PM 版:      用 A/B 实验结果数据筛 cohort(peeking + cohort filter)

经典错误:   「我们看了 7 天 A/B 实验,B 组转化高 5%,但仔细看是因为某个 segment 表现特别好」
            → 用「实验后的结果」反向定义 segment = lookahead 偏差
真相:       这个 segment 可能就是 noise 选出来的

修复

  • 实验前定义 segment,不允许实验后切片
  • Peeking(实验中途偷看结果)会膨胀假阳性率
  • p-hacking 在 PM 圈一样普遍

10.3 案例展示里的 Selection Bias

量化版:     试 100 个策略,展示最好的 5 个
PM 版:      试 100 个功能 / 文案 / UI 变体,展示效果最好的 case study

经典错误:   「我们最新做的 X 功能让 retention 提升 30%!」
            → 你这年做了多少个功能?其他几十个的效果呢?
真相:       多重比较问题。

修复:      诚实记录所有迭代,区分「有意义的实验」和「巧合好看的结果」

10.4 迁移性框架

量化偏差PM 对应共同对策
Survivorship只看活跃用户主动找流失用户 / 完整 cohort
Look-ahead实验后切片 / peeking预注册(pre-register)实验方案
Selection只展示成功案例完整记录所有尝试
Data Snooping网格搜索调参区分 exploration vs validation
Future Functionretroactive 修正数据用「当时事件流」复盘,不用「今天的最终数字」

PM 决策的稀缺品质:承认自己不知道。这套偏差思维就是结构化地教自己「在什么情况下我应该不相信自己看到的数字」。这对 Web3 PM 尤其重要——因为链上数据「看起来都是 ground truth」,反而更容易让人忘记偏差。


十一、Week 4 这周整体定位

这周(Day 23-28)的核心主题是「让回测变得可信」。从今天到周末的安排:

Day主题与今天的关系
Day 23三大偏差(今天)识别
Day 24过拟合识别与 walk-forward「我多大概率是过拟合的?」
Day 25实盘成本建模(commission/slippage/borrow)「真的能赚到这些钱吗?」
Day 26Capacity & Liquidity 限制「scale 上去还有 alpha 吗?」
Day 27风控边界(DD limit / stop / circuit breaker)「亏到什么程度停?」
Day 28Week 4 综合:完整 risk-aware backtest 框架整合

Day 23 是 Week 4 的入口课,先解决「数据本身可信吗」,下周才能讨论「策略本身有 edge 吗」。


十二、明日预告

Day 24: 过拟合识别 — Walk-forward / Cross-validation / Combinatorial Purged CV

  • 过拟合的数学定义与可视化
  • Walk-forward analysis 的实现与 anchored vs rolling 取舍
  • 时序数据的特殊 CV:Purged K-Fold 防止 leakage
  • López de Prado Combinatorial Purged Cross-Validation(CPCV)方法
  • 用今天写的 audit_backtest() + 明天的 CV 框架,组合成完整的「策略可信度评分」
  • 实操:在一个 mean-reversion 策略上同时跑 train/test、walk-forward、CPCV,对比结论差异

实际执行记录

启动一项填一项,时间戳 + 卡点。

  • [hh:mm] 读完三大偏差章节 — ...
  • [hh:mm] 写完 audit_backtest() 函数并通过示例测试 — ...
  • [hh:mm] 把 Day 22 之前的所有回测代码过一遍 self-check checklist — ...
  • [hh:mm] 把 audit 函数集成进现有 backtest pipeline — ...
  • [hh:mm] 用 Day 14-22 任一回测跑 audit,记录 risk_score — ...
  • 卡点 / 学到的:

总字数:约 7,200 字 今日完成度:理论 ✓ / 代码工具 ✓ / 自检清单 ✓ / 笔记 ✓