Premchan369 commited on
Commit
6f0860b
·
verified ·
1 Parent(s): 69bae84

Add data snooping guard - White's Reality Check, FDR, Bonferroni for multiple testing

Browse files
Files changed (1) hide show
  1. 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))