Add data snooping guard - White's Reality Check, FDR, Bonferroni for multiple testing
Browse files- data_snooping_guard.py +112 -0
data_snooping_guard.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""data_snooping_guard.py — Multiple Testing & Data Snooping Protection
|
| 2 |
+
|
| 3 |
+
Protects against false discovery from multiple strategy testing using
|
| 4 |
+
White's Reality Check, False Discovery Rate (FDR) control, and
|
| 5 |
+
Bonferroni/Holm correction. Essential for honest quant research.
|
| 6 |
+
|
| 7 |
+
References:
|
| 8 |
+
- White 2000: "A Reality Check for Data Snooping"
|
| 9 |
+
- Romano & Wolf 2005: "Stepwise Multiple Testing as Formalized Data Snooping"
|
| 10 |
+
- Benjamini & Hochberg 1995: "Controlling the False Discovery Rate"
|
| 11 |
+
"""
|
| 12 |
+
import numpy as np, pandas as pd
|
| 13 |
+
from scipy import stats
|
| 14 |
+
|
| 15 |
+
class DataSnoopingGuard:
|
| 16 |
+
"""Guards against data snooping bias in strategy backtests."""
|
| 17 |
+
|
| 18 |
+
def __init__(self, n_strategies_tested=1):
|
| 19 |
+
self.n = n_strategies_tested
|
| 20 |
+
|
| 21 |
+
def bonferroni(self, p_values, alpha=0.05):
|
| 22 |
+
"""Bonferroni correction: reject if p < alpha/n."""
|
| 23 |
+
corrected = np.minimum(np.array(p_values) * self.n, 1.0)
|
| 24 |
+
reject = corrected < alpha
|
| 25 |
+
return pd.DataFrame({'p_value': p_values, 'corrected_p': corrected, 'reject': reject})
|
| 26 |
+
|
| 27 |
+
def holm(self, p_values, alpha=0.05):
|
| 28 |
+
"""Holm-Bonferroni (step-down, less conservative)."""
|
| 29 |
+
p = np.array(p_values)
|
| 30 |
+
m = len(p)
|
| 31 |
+
idx = np.argsort(p)
|
| 32 |
+
sorted_p = p[idx]
|
| 33 |
+
thresholds = alpha / (m - np.arange(m) + 1)
|
| 34 |
+
reject = np.zeros(m, dtype=bool)
|
| 35 |
+
for i in range(m):
|
| 36 |
+
if sorted_p[i] < thresholds[i]:
|
| 37 |
+
reject[idx[i]] = True
|
| 38 |
+
else:
|
| 39 |
+
break
|
| 40 |
+
return pd.DataFrame({'p_value': p_values, 'reject': reject})
|
| 41 |
+
|
| 42 |
+
def benjamini_hochberg(self, p_values, alpha=0.05):
|
| 43 |
+
"""FDR control: controls expected proportion of false discoveries."""
|
| 44 |
+
p = np.array(p_values)
|
| 45 |
+
m = len(p)
|
| 46 |
+
idx = np.argsort(p)
|
| 47 |
+
sorted_p = p[idx]
|
| 48 |
+
thresholds = alpha * (np.arange(1, m+1) / m)
|
| 49 |
+
# Find largest k where p(k) <= threshold(k)
|
| 50 |
+
k = 0
|
| 51 |
+
for i in range(m-1, -1, -1):
|
| 52 |
+
if sorted_p[i] <= thresholds[i]:
|
| 53 |
+
k = i + 1
|
| 54 |
+
break
|
| 55 |
+
reject = np.zeros(m, dtype=bool)
|
| 56 |
+
if k > 0:
|
| 57 |
+
reject[idx[:k]] = True
|
| 58 |
+
return pd.DataFrame({'p_value': p_values, 'reject': reject,
|
| 59 |
+
'fdr_threshold': np.take(thresholds, np.argsort(idx))})
|
| 60 |
+
|
| 61 |
+
def whites_reality_check(self, strategy_returns, benchmark_returns, n_boot=1000):
|
| 62 |
+
"""White's Reality Check: bootstrap test for best strategy.
|
| 63 |
+
|
| 64 |
+
Tests whether the best-performing strategy outperforms by chance.
|
| 65 |
+
"""
|
| 66 |
+
s = np.array(strategy_returns).flatten()
|
| 67 |
+
b = np.array(benchmark_returns).flatten()
|
| 68 |
+
if len(s) != len(b): b = np.resize(b, len(s))
|
| 69 |
+
excess = s - b
|
| 70 |
+
t_obs = excess.mean() / (excess.std() + 1e-10) * np.sqrt(len(excess))
|
| 71 |
+
# Bootstrap under null (centered)
|
| 72 |
+
centered = excess - excess.mean()
|
| 73 |
+
t_boot = []
|
| 74 |
+
for _ in range(n_boot):
|
| 75 |
+
sample = np.random.choice(centered, size=len(centered), replace=True)
|
| 76 |
+
t = sample.mean() / (sample.std() + 1e-10) * np.sqrt(len(sample))
|
| 77 |
+
t_boot.append(t)
|
| 78 |
+
t_boot = np.array(t_boot)
|
| 79 |
+
p_value = (t_boot >= t_obs).mean()
|
| 80 |
+
return {'t_stat': float(t_obs), 'p_value': float(p_value),
|
| 81 |
+
'reject_null': bool(p_value < 0.05),
|
| 82 |
+
'n_bootstraps': n_boot,
|
| 83 |
+
'percentile': float(stats.percentileofscore(t_boot, t_obs))}
|
| 84 |
+
|
| 85 |
+
def familywise_error_report(self, sharpe_ratios, n_strategies=None, alpha=0.05):
|
| 86 |
+
"""Full report on multiple testing corrections for Sharpe ratios."""
|
| 87 |
+
if n_strategies is None: n_strategies = len(sharpe_ratios)
|
| 88 |
+
# Approximate p-values from Sharpe (asymptotic normal)
|
| 89 |
+
p_values = [2 * (1 - stats.norm.cdf(abs(s) * np.sqrt(252))) for s in sharpe_ratios]
|
| 90 |
+
bonf = self.bonferroni(p_values, alpha)
|
| 91 |
+
holm_r = self.holm(p_values, alpha)
|
| 92 |
+
bh = self.benjamini_hochberg(p_values, alpha)
|
| 93 |
+
report = f"""## Multiple Testing Correction Report
|
| 94 |
+
|
| 95 |
+
Strategies tested: {n_strategies} | Significance level: {alpha*100:.0f}%
|
| 96 |
+
|
| 97 |
+
| Strategy | Sharpe | p-value | Bonferroni | Holm | FDR (BH) |
|
| 98 |
+
|----------|--------|---------|------------|------|----------|
|
| 99 |
+
"""
|
| 100 |
+
for i, s in enumerate(sharpe_ratios):
|
| 101 |
+
report += f"| #{i+1} | {s:.2f} | {p_values[i]:.4f} | {'✅' if bonf['reject'].iloc[i] else '❌'} | {'✅' if holm_r['reject'].iloc[i] else '❌'} | {'✅' if bh['reject'].iloc[i] else '❌'} |\n"
|
| 102 |
+
report += f"\n**Bonferroni corrected threshold:** {alpha/n_strategies:.6f}\n"
|
| 103 |
+
report += f"**Significant strategies (FDR-controlled):** {bh['reject'].sum()} of {n_strategies}\n"
|
| 104 |
+
report += f"\n⚠️ **Data Snooping Warning:** If you tested {n_strategies} strategies, the probability of at least one false positive at {alpha*100:.0f}% is {1-(1-alpha)**n_strategies*100:.1f}% uncorrected."
|
| 105 |
+
return report
|
| 106 |
+
|
| 107 |
+
if __name__ == '__main__':
|
| 108 |
+
np.random.seed(42)
|
| 109 |
+
# Simulate 50 strategies, only 5 have true alpha
|
| 110 |
+
sharpe = [0.05 + np.random.normal(0, 0.3) for _ in range(50)]
|
| 111 |
+
guard = DataSnoopingGuard(n_strategies_tested=50)
|
| 112 |
+
print(guard.familywise_error_report(sharpe))
|