量化投资中的”假发现”陷阱
假设你测试了 1000 个量化因子,每次使用 \(\alpha = 0.05\) 的显著性水平:
- 即使所有因子都无效(\(H_0\) 全为真),期望的”显著”因子数 = \(1000 \times 0.05 = 50\) 个
- 至少出现 1 个假阳性的概率:
\[\text{FWER} = 1 - (1 - \alpha)^m = 1 - (1-0.05)^{1000} \approx 100\%\]
核心问题:当同时进行大量假设检验时,第一类错误(假阳性)发生的概率急剧膨胀。
为什么“显著”不等于“真的有用”
刚接触统计检验时,容易把“显著”直接理解成“这个结论已经很可靠”。实际上,至少要区分三层含义:
- 统计显著:结果不像纯噪声那样随机
- 经济显著:效应大小足以覆盖交易成本、冲击成本和风险暴露
- 样本外稳健:换一段数据、换一个市场后,结论仍大体成立
本章讨论的多重检验,主要是在修补第一层。当你一次测试很多候选因子时,原本看似显著的结果中会混入大量纯噪声。
为什么“单次 5%”不等于“整体 5%”
如果每次检验都使用 \(\alpha = 0.05\),那么至少出现 1 次假阳性的概率会随着检验次数快速上升:
| 1 |
\(1 - (1-0.05)^1 = 5\%\) |
| 10 |
\(1 - (1-0.05)^{10} \approx 40\%\) |
| 100 |
\(1 - (1-0.05)^{100} \approx 99.4\%\) |
| 1000 |
\(1 - (1-0.05)^{1000} \approx 100\%\) |
所以,单次检验中的“5% 误报率”,绝不能直接理解为整轮筛选中的“5% 误报率”。
假设检验的基本框架回顾
| 不拒绝 \(H_0\) |
正确接受 (U) |
第二类错误 (T) |
| 拒绝 \(H_0\) |
第一类错误 (V) |
正确拒绝 (S) |
关键定义:
- 第一类错误(\(\alpha\)):拒绝了一个真实的原假设(假阳性)
- 第二类错误(\(\beta\)):未能拒绝一个虚假的原假设(假阴性)
- 检验效力 (Power):\(1 - \beta\),正确拒绝虚假原假设的概率
p 值不是“原假设为真”的概率
很多初学者最容易误解的是 p 值的含义。
p 值真正表示的是:
- 在原假设 \(H_0\) 为真的前提下
- 观察到当前样本,或更极端样本结果的概率
p 值并不表示:
- 原假设为真的概率
- 这个因子未来继续有效的概率
- 这个结果有多大的经济价值
因此,p 值小只能说明“这份样本与原假设不太相容”,并不自动推出“这个结论一定真实有效”。
两类错误的经济学含义
统计学的两类错误:
| 判定”有效” |
I类错误(假阳性) |
✅ 正确发现 |
| 判定”无效” |
✅ 正确排除 |
II类错误(假阴性) |
在量化投资中的代价:
- I类错误(假发现):把无效因子当作Alpha → 亏钱
- II类错误(遗漏):错过真实Alpha → 少赚钱
非对称损失:
- 在学术发表中,I类错误代价更大(错误结论难以撤回)
- 在量化投资中,I类错误直接导致资金损失
- 因此,多重检验校正在金融中格外重要
模拟实验:1000 个无效因子的检验
Code
import numpy as np # 导入numpy用于数值计算
import matplotlib.pyplot as plt # 导入绑图库
from scipy import stats # 导入统计模块
plt.rcParams['font.family'] = ['Source Han Serif SC', 'Noto Serif CJK SC', 'SimSun'] # 设置中文字体优先级
plt.rcParams['axes.unicode_minus'] = False # 解决负号显示问题
np.random.seed(42) # 设置随机种子保证可重现
num_tests = 1000 # 假设检验次数(1000个因子)
null_test_statistics = np.random.standard_normal(num_tests) # 从标准正态分布生成检验统计量(所有H0为真)
null_p_values = 2 * (1 - stats.norm.cdf(np.abs(null_test_statistics))) # 计算双侧p值
num_false_positives = np.sum(null_p_values < 0.05) # 统计p<0.05的假阳性个数
fig, axes = plt.subplots(1, 2, figsize=(12, 4.5)) # 创建1行2列子图
# 左图:p值直方图
axes[0].hist(null_p_values, bins=50, color='#008080', alpha=0.7, edgecolor='white') # 绘制p值频率直方图
axes[0].axvline(x=0.05, color='#E3120B', linestyle='--', linewidth=2, label='α = 0.05') # 标注显著性阈值
axes[0].set_xlabel('p 值') # x轴标签
axes[0].set_ylabel('频次') # y轴标签
axes[0].set_title(f'p值分布(假阳性: {num_false_positives} 个)') # 标题(含假阳性数)
axes[0].legend() # 显示图例
# 右图:-log10(p)散点图
axes[1].scatter(range(num_tests), -np.log10(null_p_values), s=5, alpha=0.5, color='gray') # 绘制-log10(p)散点
axes[1].axhline(y=-np.log10(0.05), color='#E3120B', linestyle='--', linewidth=2, label='α = 0.05') # 显著性阈值线
axes[1].set_xlabel('因子编号') # x轴标签
axes[1].set_ylabel('$-\\log_{10}(p)$') # y轴标签
axes[1].set_title('所有因子都无效,但假阳性大量出现') # 标题
axes[1].legend() # 显示图例
plt.tight_layout() # 自动调整间距
plt.show() # 显示图表
一批检验为什么要被看成同一个 family
多重检验里的 family,可以理解为“应当一起结算错误预算的一组检验”。
常见例子:
- 对同一只股票测试 50 条均线策略
- 对 80 多个行业同时检验 Alpha
- 对 300 个候选因子做一轮统一筛选
只要这些检验共同服务于同一轮研究结论或投资决策,就应该被看成同一个 family,不能只盯着单个 p 值解释。
FWER 与 FDR:两种不同的错误控制策略
Bonferroni 的直觉是“把总犯错预算切成 m 份”
把总错误预算 \(\alpha\) 理解成一笔固定预算:
- 如果只做 1 次检验,这 0.05 可以全部给这 1 次
- 如果做 10 次检验,每次最多分到 0.005
- 如果做 100 次检验,每次最多分到 0.0005
| 5 |
0.0100 |
| 50 |
0.0010 |
| 500 |
0.0001 |
这能保护整体错误率,但代价是:检验越多,每次分到的“单次犯错额度”越小,真信号也会越来越难通过。
FWER控制的数学原理
FWER(族错误率):
\[
\text{FWER} = P(\text{至少一个假阳性}) = 1 - (1-\alpha)^m
\]
当 \(m\) 很大时,FWER趋近于1
例子:\(\alpha = 0.05\),测试 \(m = 100\) 个假设
\[
\text{FWER} = 1 - (1-0.05)^{100} \approx 0.994
\]
几乎必然出现假阳性!
FDR(假发现率):
\[
\text{FDR} = E\left[\frac{\text{假阳性数}}{\text{所有拒绝数}}\right] = E\left[\frac{V}{R}\right]
\]
- FDR关注的是”在你宣称的发现中,有多少比例是假的”
- FDR \(\leq\) FWER,控制FDR更宽松但更实用
Bonferroni:最简单的 FWER 控制方法
核心思想:将显著性水平均匀分配给每一次检验。
\[\alpha_{\text{adjusted}} = \frac{\alpha}{m}\]
等价地,对 p 值进行调整:
\[p_{\text{adjusted}} = \min(m \times p_{\text{原始}}, \; 1)\]
优点:简单、直观、对任何依赖结构都有效
缺点:当 \(m\) 很大时极度保守——例如 \(m = 1000\) 时,\(\alpha_{\text{adjusted}} = 0.00005\)
案例:A 股强势股票筛选
import pandas as pd # 导入pandas用于数据操作
import os # 导入os用于路径处理
DATA_DIR = 'C:/qiufei/data' if os.name == 'nt' else '/home/ubuntu/r2_data_mount/data' # 跨平台数据路径
# 只读取2020年的前复权股价数据
stock_close_2020 = pd.read_hdf( # 按年份选择性读取2020年分析所需的股价子集
os.path.join(DATA_DIR, 'stock/stock_price_pre_adjusted.h5'), # 指定前复权股价文件
key='data', # 指定HDF5中的数据键
where=["date>=Timestamp('2020-01-01')", "date<Timestamp('2021-01-01')"], # 只读取2020年数据
columns=['close'] # 只保留计算收益率所需的收盘价列
).copy() # 保留MultiIndex以减少不必要的索引重建
# 先根据收盘价样本量筛出有效股票,再只对目标股票计算收益率
close_count_2020 = stock_close_2020.groupby(level='order_book_id')['close'].count() # 按股票统计非缺失收盘价数量
valid_stock_list = close_count_2020[close_count_2020 >= 101].index.tolist() # 日收益率至少100个意味着收盘价至少101个
selected_stock_list = valid_stock_list[:500] # 只保留后续检验会用到的前500只股票
selected_stock_mask_2020 = stock_close_2020.index.get_level_values('order_book_id').isin(selected_stock_list) # 标记需要保留的股票样本
selected_stock_close_2020 = stock_close_2020.loc[selected_stock_mask_2020, ['close']].copy() # 只提取目标股票的收盘价子集
selected_stock_close_2020['daily_return'] = selected_stock_close_2020.groupby(level='order_book_id')['close'].pct_change() # 仅对目标股票计算日收益率
stock_price_2020 = selected_stock_close_2020[['daily_return']].reset_index() # 只保留后续检验需要的股票代码和收益率
print(f'2020年有效股票数量: {len(valid_stock_list)}') # 输出有效股票数
未校正 vs Bonferroni 校正效果对比
Code
from scipy import stats # 导入统计库
from statsmodels.stats.multitest import multipletests # 导入多重检验校正工具
# 对每只股票执行单侧 t 检验 (H1: μ > 0)
selected_stock_frame = stock_price_2020.loc[ # 一次性提取参与检验的股票收益率
:, ['order_book_id', 'daily_return'] # 数据准备阶段已限制为前500只股票,这里直接复用
].dropna().copy() # 去除缺失收益率并复制
selected_stock_stats = selected_stock_frame.groupby('order_book_id', sort=False)['daily_return'].agg(['count', 'mean', 'std']).reset_index() # 一次性汇总样本量、均值和标准差
selected_stock_stats = selected_stock_stats[selected_stock_stats['count'] > 1].copy() # 只保留能计算标准差的股票
selected_stock_stats['std'] = selected_stock_stats['std'].replace(0, np.nan) # 标准差为0时记为缺失以避免除零
selected_stock_stats['t_statistic'] = selected_stock_stats['mean'] / (selected_stock_stats['std'] / np.sqrt(selected_stock_stats['count'])) # 用分组统计量直接计算t统计量
selected_stock_stats['two_sided_p'] = 2 * stats.t.sf(np.abs(selected_stock_stats['t_statistic']), df=selected_stock_stats['count'] - 1) # 基于t分布计算双侧p值
selected_stock_stats['one_sided_p'] = np.where( # 将双侧p值转换为单侧p值
selected_stock_stats['t_statistic'].isna(), 1.0, np.where(selected_stock_stats['t_statistic'] > 0, selected_stock_stats['two_sided_p'] / 2, 1 - selected_stock_stats['two_sided_p'] / 2) # 缺失统计量视为不显著
) # 完成单侧p值构造
p_values_array = selected_stock_stats['one_sided_p'].to_numpy() # 转为numpy数组
t_stat_array = selected_stock_stats['t_statistic'].to_numpy() # 转为numpy数组备用
# Bonferroni 校正
bonferroni_reject, bonferroni_p_adjusted, _, _ = multipletests(p_values_array, alpha=0.05, method='bonferroni') # Bonferroni校正
fig, axes = plt.subplots(1, 2, figsize=(13, 5)) # 创建1行2列子图
# 左图:未校正
is_significant_raw = p_values_array < 0.05 # 未校正下的显著性判断
axes[0].scatter(range(len(p_values_array)), -np.log10(p_values_array), # 绘制-log10(p)散点
c=['#E3120B' if s else 'gray' for s in is_significant_raw], s=8, alpha=0.6) # 显著=红色
axes[0].axhline(y=-np.log10(0.05), color='blue', linestyle='--', label='α=0.05') # 原始阈值线
axes[0].set_title(f'未校正(显著: {sum(is_significant_raw)} 只)') # 标题
axes[0].set_xlabel('股票编号') # x轴
axes[0].set_ylabel('$-\\log_{10}(p)$') # y轴
axes[0].legend() # 图例
# 右图:Bonferroni 校正后
axes[1].scatter(range(len(p_values_array)), -np.log10(p_values_array), # 同样的-log10(p)
c=['#E3120B' if s else 'gray' for s in bonferroni_reject], s=8, alpha=0.6) # Bonferroni显著=红色
bonferroni_threshold = 0.05 / len(p_values_array) # 计算Bonferroni校正阈值
axes[1].axhline(y=-np.log10(bonferroni_threshold), color='blue', linestyle='--', label=f'α/m={bonferroni_threshold:.2e}') # 校正阈值线
axes[1].set_title(f'Bonferroni 校正(显著: {sum(bonferroni_reject)} 只)') # 标题
axes[1].set_xlabel('股票编号') # x轴
axes[1].set_ylabel('$-\\log_{10}(p)$') # y轴
axes[1].legend() # 图例
plt.tight_layout() # 自动调整间距
plt.show() # 显示图表
为什么 BH 一定要先给 p 值排序
BH 方法的阈值不是看“这个假设是谁”,而是看“这个 p 值排第几名”。
先排序的原因:
- 最小的 p 值最像真信号
- 中间的 p 值真假难分
- 最大的 p 值更像噪声
BH 的做法是:
- 按从小到大排序
- 给第 1 名最严格但仍可接受的阈值
- 随着名次往后,阈值线性放宽
- 找到“最后一个还能过线的位置”
所以,BH 不是逐个独立判定,而是在整批结果里寻找一条整体截断线。
BH 实战:注入 Alpha 信号的检测
# 读取2021年的前复权股价数据
stock_close_2021 = pd.read_hdf( # 按年份选择性读取2021年分析所需的股价子集
os.path.join(DATA_DIR, 'stock/stock_price_pre_adjusted.h5'), # 指定前复权股价文件
key='data', # 指定HDF5中的数据键
where=["date>=Timestamp('2021-01-01')", "date<Timestamp('2022-01-01')"], # 只读取2021年数据
columns=['close'] # 只保留计算收益率所需的收盘价列
).copy() # 保留MultiIndex以减少不必要的索引重建
# 先根据收盘价样本量筛出有效股票,再只对有效股票计算收益率
close_count_2021 = stock_close_2021.groupby(level='order_book_id')['close'].count() # 按股票统计非缺失收盘价数量
valid_2021_stocks = close_count_2021[close_count_2021 >= 101].index.tolist() # 日收益率至少100个意味着收盘价至少101个
valid_stock_mask_2021 = stock_close_2021.index.get_level_values('order_book_id').isin(valid_2021_stocks) # 标记有效股票样本
valid_stock_close_2021 = stock_close_2021.loc[valid_stock_mask_2021, ['close']].copy() # 只提取有效股票的收盘价子集
valid_stock_close_2021['daily_return'] = valid_stock_close_2021.groupby(level='order_book_id')['close'].pct_change() # 只对有效股票计算日收益率
stock_price_2021 = valid_stock_close_2021[['daily_return']].reset_index() # 只保留后续分析需要的股票代码和收益率
# 随机注入100只股票的正Alpha(+0.5%/天)
np.random.seed(2024) # 设置随机种子
injected_alpha_stocks = np.random.choice(valid_2021_stocks, size=100, replace=False) # 随机选100只注入Alpha
injected_stock_set = set(injected_alpha_stocks) # 转为集合便于查询
# 为注入Alpha的股票修改收益率
stock_price_2021_modified = stock_price_2021.copy() # 复制数据
alpha_mask = stock_price_2021_modified['order_book_id'].isin(injected_stock_set) # 标记被注入的股票
stock_price_2021_modified.loc[alpha_mask, 'daily_return'] += 0.005 # 注入每日+0.5%的正Alpha
# 预先汇总股票层面的收益率统计量,避免后续逐只股票反复筛表
valid_2021_return_frame = stock_price_2021_modified.loc[ # 一次性提取有效股票的收益率样本
stock_price_2021_modified['order_book_id'].isin(valid_2021_stocks), ['order_book_id', 'daily_return'] # 只保留股票代码和收益率
].dropna().copy() # 去除缺失收益率并复制
valid_2021_stock_stats = valid_2021_return_frame.groupby('order_book_id', sort=False)['daily_return'].agg(['count', 'mean', 'std']).reset_index() # 汇总样本量、均值和标准差
valid_2021_stock_stats = valid_2021_stock_stats[valid_2021_stock_stats['count'] >= 30].copy() # 只保留样本量足够的股票
valid_2021_stock_stats['std'] = valid_2021_stock_stats['std'].replace(0, np.nan) # 标准差为0时记为缺失以避免除零
valid_2021_stock_stats['t_stat'] = valid_2021_stock_stats['mean'] / (valid_2021_stock_stats['std'] / np.sqrt(valid_2021_stock_stats['count'])) # 直接用分组统计量计算t统计量
valid_2021_stock_stats['two_sided_p'] = 2 * stats.t.sf(np.abs(valid_2021_stock_stats['t_stat']), df=valid_2021_stock_stats['count'] - 1) # 基于t分布计算双侧p值
valid_2021_stock_stats['one_sided_p'] = np.where( # 将双侧p值转换为单侧p值
valid_2021_stock_stats['t_stat'].isna(), 1.0, np.where(valid_2021_stock_stats['t_stat'] > 0, valid_2021_stock_stats['two_sided_p'] / 2, 1 - valid_2021_stock_stats['two_sided_p'] / 2) # 缺失统计量视为不显著
) # 完成单侧p值构造
valid_2021_stock_stats['is_true_alpha'] = valid_2021_stock_stats['order_book_id'].isin(injected_stock_set) # 标记哪些股票被注入了真实Alpha
print(f'2021年有效股票总数: {len(valid_2021_stocks)}') # 输出总股票数
print(f'注入 Alpha 的股票数: {len(injected_alpha_stocks)}') # 输出注入Alpha的股票数
2021年有效股票总数: 4428
注入 Alpha 的股票数: 100
三种方法对比:BH 的优势一目了然
# 计算各方法的关键指标
def calculate_performance_metrics(reject_decisions, true_labels): # 定义性能计算函数
true_positive = np.sum(reject_decisions & true_labels) # 真阳性(正确拒绝)
false_positive = np.sum(reject_decisions & ~true_labels) # 假阳性(错误拒绝)
total_rejections = np.sum(reject_decisions) # 总拒绝数
fdr = false_positive / total_rejections if total_rejections > 0 else 0 # 错误发现率
tpr = true_positive / np.sum(true_labels) if np.sum(true_labels) > 0 else 0 # 真阳性率(检验效力)
return {'TP': true_positive, 'FP': false_positive, 'R': total_rejections, 'FDR': f'{fdr:.2%}', 'TPR': f'{tpr:.2%}'} # 返回指标字典
results_comparison = pd.DataFrame({ # 创建对比表
'未校正 (α=0.05)': calculate_performance_metrics(raw_reject, truth_arr), # 未校正结果
'Bonferroni': calculate_performance_metrics(bonf_reject, truth_arr), # Bonferroni结果
'BH (q=0.05)': calculate_performance_metrics(bh_reject, truth_arr) # BH结果
}) # 三列三行的性能对比表
results_comparison # 显示对比表格
可视化:BH 自适应阈值线与火山图
Code
fig, axes = plt.subplots(1, 2, figsize=(13, 5)) # 创建1行2列子图
# 左图:BH 自适应阈值线
sorted_indices = np.argsort(p_arr) # 按p值从小到大排序的索引
sorted_p = p_arr[sorted_indices] # 排序后的p值
m_total = len(p_arr) # 总检验次数
bh_threshold_line = np.arange(1, m_total + 1) / m_total * 0.05 # BH阈值线: k/(m*q),线性递增
axes[0].scatter(range(m_total), sorted_p, s=3, c='gray', alpha=0.5, label='排序后的p值') # 排序p值散点图
axes[0].plot(range(m_total), bh_threshold_line, color='#008080', linewidth=2, label='BH 阈值线') # BH阈值线
axes[0].set_xlabel('排序位置 k') # x轴
axes[0].set_ylabel('p 值') # y轴
axes[0].set_title('BH 自适应阈值线') # 标题
axes[0].set_ylim(0, 0.1) # 限制y轴范围便于观察
axes[0].legend() # 图例
# 右图:火山图
positive_bh_mask = bh_reject & (t_arr > 0) # 标记BH校正后显著的正向效应
negative_bh_mask = bh_reject & (t_arr < 0) # 标记BH校正后显著的负向效应
neutral_bh_mask = ~(positive_bh_mask | negative_bh_mask) # 标记其余未显著或接近0的点
axes[1].scatter(t_arr[neutral_bh_mask], -np.log10(p_arr[neutral_bh_mask]), s=6, c='gray', alpha=0.5) # 批量绘制未显著散点
axes[1].scatter(t_arr[positive_bh_mask], -np.log10(p_arr[positive_bh_mask]), s=6, c='#E3120B', alpha=0.5) # 批量绘制显著正效应散点
axes[1].scatter(t_arr[negative_bh_mask], -np.log10(p_arr[negative_bh_mask]), s=6, c='#008080', alpha=0.5) # 批量绘制显著负效应散点
axes[1].set_xlabel('t 统计量') # x轴
axes[1].set_ylabel('$-\\log_{10}(p)$') # y轴
axes[1].set_title('火山图(BH 校正)') # 标题
plt.tight_layout() # 自动调整间距
plt.show() # 显示图表
校正后的 p 值应该怎样解释
原始 p 值回答的是:单个检验在原假设下有多极端。
校正后的 p 值回答的是:当你把这组检验放在一起看时,它是否仍然足够显著。
阅读规则:
- 校正后 p 值通常会比原始 p 值更大
- 如果校正后仍小于阈值,说明它在“整轮筛选”里仍站得住
- 如果原始显著但校正后不显著,说明它更像“筛选过程里的幸运结果”
报告结果时,最好同时给出原始 p 值与校正后 p 值,而不是只汇报最漂亮的那个版本。
P-Hacking 与数据偷窥的危害
P-Hacking(p值黑客)是量化金融研究中的严重问题:
- 在大量回测中”挖掘”出看起来显著的策略
- Harvey, Liu & Zhu (2016) 发现:超过一半的金融”异象因子”可能是假的
- 他们建议将 t 统计量的显著性阈值提高到 t > 3.0
常见的数据偷窥手法:
- 在同一数据集上测试大量策略,只报告显著的
- 不断调整回测参数直到出现好结果
- 对同一假设尝试多种统计检验方法
防范措施:
- 预先注册研究计划和假设
- 样本外验证:将数据分为训练集和测试集
- 报告所有结果,包括不显著的检验
- 使用 BH 或 Bonferroni 进行系统性的多重检验校正
样本外验证为什么不能替代多重检验校正
很多同学会问:‘我已经做了样本外验证,还需要多重检验校正吗?’
答案通常是:仍然需要。
| 多重检验校正 |
同一轮筛选里测试太多,假阳性累积 |
| 样本外验证 |
历史规律能否跨时间继续成立 |
这两个工具不能互相替代,因为:
- 你可能在样本内先试了很多策略,已经发生了选择偏差
- 即使最后只拿 1 个策略去做样本外,前面的“筛选运气”仍可能残留
- 最稳妥的做法是:先做多重检验控制,再做样本外验证
案例:海康威视技术指标的多重检验
Code
# 获取海康威视(002415.XSHE)的股价数据
hikvision_data = pd.read_hdf( # 直接读取海康威视在案例所需时段的股价子集
os.path.join(DATA_DIR, 'stock/stock_price_pre_adjusted.h5'), # 指定前复权股价文件
key='data', # 指定HDF5中的数据键
where=["order_book_id=='002415.XSHE'", "date>=Timestamp('2020-01-01')", "date<Timestamp('2022-01-01')"], # 只读取海康威视2020-2021年数据
columns=['close'] # 只保留计算策略收益所需的收盘价列
).reset_index().sort_values('date').copy() # 恢复索引、按日期排序并复制
hikvision_data['daily_return'] = hikvision_data['close'].pct_change() # 计算日收益率
# 测试 50 条均线策略 (MA5 到 MA54)
ma_periods = range(5, 55) # MA周期从5到54
strategy_p_values = [] # 存储各策略的p值
for ma_period in ma_periods: # 遍历每个MA周期
hikvision_data[f'MA_{ma_period}'] = hikvision_data['close'].rolling(ma_period).mean() # 计算移动平均
signal_mask = hikvision_data['close'] > hikvision_data[f'MA_{ma_period}'] # 价格在均线上方时持仓
strategy_returns = hikvision_data.loc[signal_mask, 'daily_return'].dropna() # 持仓期间的收益率
if len(strategy_returns) > 30: # 样本量充足时
t_stat, p_val = stats.ttest_1samp(strategy_returns, 0) # t检验:均值是否>0
strategy_p_values.append(p_val / 2 if t_stat > 0 else 1) # 转为单侧p值
else: # 样本量不足
strategy_p_values.append(1.0) # 设为不显著
strategy_p_arr = np.array(strategy_p_values) # 转numpy数组
bonf_reject_ma, _, _, _ = multipletests(strategy_p_arr, alpha=0.05, method='bonferroni') # Bonferroni校正
bh_reject_ma, _, _, _ = multipletests(strategy_p_arr, alpha=0.05, method='fdr_bh') # BH校正
fig, ax = plt.subplots(figsize=(11, 5)) # 创建图表
x_pos = list(ma_periods) # x轴位置
ax.bar(x_pos, -np.log10(strategy_p_arr), color=[ # 绘制-log10(p)柱状图
'#E3120B' if strategy_p_arr[i] < 0.05 else 'gray' for i in range(len(strategy_p_arr)) # 未校正显著=红色
], alpha=0.7) # 透明度0.7
ax.axhline(y=-np.log10(0.05), color='blue', linestyle='--', label='未校正 α=0.05') # 未校正阈值
ax.axhline(y=-np.log10(0.05/len(strategy_p_arr)), color='#E3120B', linestyle='-', label='Bonferroni') # Bonferroni阈值
ax.set_xlabel('移动平均周期 (MA)') # x轴
ax.set_ylabel('$-\\log_{10}(p)$') # y轴
ax.set_title(f'海康威视50条MA策略: 未校正显著 {sum(strategy_p_arr < 0.05)} 条 → 校正后 {sum(bonf_reject_ma)} 条') # 标题
ax.legend() # 图例
plt.tight_layout() # 自动调整间距
plt.show() # 显示图表
关键发现:大量未校正下”显著”的策略,在 Bonferroni 或 BH 校正后全部变为不显著 → 典型的数据偷窥结果!
案例:行业 Alpha 的多重检验
Code
# 三种校正
industry_raw_sig = industry_p_arr < 0.05 # 未校正显著判断
industry_bonf_rej, _, _, _ = multipletests(industry_p_arr, alpha=0.05, method='bonferroni') # Bonferroni
industry_bh_rej, _, _, _ = multipletests(industry_p_arr, alpha=0.05, method='fdr_bh') # BH
fig, axes = plt.subplots(1, 3, figsize=(15, 5)) # 1行3列子图
method_configs = [ # 三种方法的配置
('未校正 (α=0.05)', industry_raw_sig, int(sum(industry_raw_sig))), # 未校正配置
('Bonferroni', industry_bonf_rej, int(sum(industry_bonf_rej))), # Bonferroni配置
('BH (q=0.05)', industry_bh_rej, int(sum(industry_bh_rej))) # BH配置
] # 三种方法
for ax_idx, (method_name, reject_mask, count) in enumerate(method_configs): # 遍历三种方法
colors = ['#E3120B' if r else 'gray' for r in reject_mask] # 显著=红色,不显著=灰色
axes[ax_idx].barh(range(len(industry_p_arr)), -np.log10(industry_p_arr), color=colors, alpha=0.7) # 水平柱状图
axes[ax_idx].set_title(f'{method_name}(显著: {count})') # 标题含显著数量
axes[ax_idx].set_xlabel('$-\\log_{10}(p)$') # x轴标签
plt.tight_layout() # 自动调整间距
plt.show() # 显示图表
方法选择决策框架
默认起点:如果你拿不准应该多保守,就先用 BH 做主结果,再用更严格的 Holm 或 Bonferroni 做稳健性对照。
多重检验在不同金融场景的应用
场景1:因子发现
- 测试上百个潜在Alpha因子
- 推荐:BH方法,控制FDR在5%-10%
- 目标:在控制假发现率的前提下,尽量多发现有效因子
场景2:风险监控
- 同时监控多个交易策略的风险指标
- 推荐:Bonferroni,控制FWER
- 理由:任何一个假警报都可能导致不必要的平仓
场景3:学术研究
- 资产定价实证中的多重假设检验
- 推荐:Holm方法或BH方法
- 需要在论文中明确报告校正方法
核心原则:测试的假设越多,校正越重要