三大回测偏差 — Survivorship / Look-ahead / Selection
三大回测偏差的定义、机制、识别、修复;Data Snooping / Future Function 进阶
日期: 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 |
三者共同点:
- 不会让程序报错——所有数学都「自洽」
- 不会让回测曲线难看——恰恰相反,曲线漂亮
- 会被自己说服——「我反复检查过代码,没问题」
- 实盘暴露——少则一个月,多则一年
行业里有句话:「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 | 免费 | 部分覆盖,需要自己拼 |
散户折中方案:
- 意识到偏差存在——这是 80% 的修复
- 给回测年化 return 打 8-9 折 作为粗略修正
- 用 ETF 替代股票池——ETF 自己处理退市,回测 SPY/QQQ 本身不存在 survivorship
- 手动维护 universe 历史——每个月份用历史成分股快照,工程量大但可行
- 检查策略对退市股票的鲁棒性——故意手动加入几只历史上的「问题股」(如 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-2021 | meme 狂热 | retail 反向 = alpha |
| 2022 | 加息熊市 | Defensive / 反向因子 |
| 2023-2025 | AI 牛市集中 | 等权重失效 / 头部集中 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 实践守则
- 数清楚自己试过多少次——包括「我没记录但脑子里试过的」
- 每次试错都要记录——一份 experiments.csv 列所有 backtest 配置和结果
- 保留一个永不看的 holdout——比如「2024 年后所有数据」,整个研究期间不允许查看
- 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 Function | retroactive 修正数据 | 用「当时事件流」复盘,不用「今天的最终数字」 |
PM 决策的稀缺品质:承认自己不知道。这套偏差思维就是结构化地教自己「在什么情况下我应该不相信自己看到的数字」。这对 Web3 PM 尤其重要——因为链上数据「看起来都是 ground truth」,反而更容易让人忘记偏差。
十一、Week 4 这周整体定位
这周(Day 23-28)的核心主题是「让回测变得可信」。从今天到周末的安排:
| Day | 主题 | 与今天的关系 |
|---|---|---|
| Day 23 | 三大偏差(今天) | 识别 |
| Day 24 | 过拟合识别与 walk-forward | 「我多大概率是过拟合的?」 |
| Day 25 | 实盘成本建模(commission/slippage/borrow) | 「真的能赚到这些钱吗?」 |
| Day 26 | Capacity & Liquidity 限制 | 「scale 上去还有 alpha 吗?」 |
| Day 27 | 风控边界(DD limit / stop / circuit breaker) | 「亏到什么程度停?」 |
| Day 28 | Week 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 字 今日完成度:理论 ✓ / 代码工具 ✓ / 自检清单 ✓ / 笔记 ✓