Coverage for src/jsharpe/sharpe/corrections.py: 100%
59 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-11 10:09 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-11 10:09 +0000
1"""Multiple-testing corrections and false-discovery-rate control.
3This module groups the family-wise error rate corrections (Bonferroni,
4Šidák, Holm) and the false-discovery-rate routines (critical value, FDR
5control, predictive pFDR and observed oFDR) used when screening many
6candidate strategies.
7"""
8# ruff: noqa: N802, N803, N806, TRY003
10import math
11import warnings
13import numpy as np
14import scipy
16from .psr import probabilistic_sharpe_ratio, sharpe_ratio_variance
19def _valid_fdr_inputs(q: float, SR0: float, SR1: float, sigma0: float, sigma1: float, p_H1: float) -> bool:
20 """Return whether the FDR-critical-value inputs are within their valid ranges.
22 Args:
23 q: Desired False Discovery Rate; must be in (0, 1).
24 SR0: Mean under the null hypothesis; must be < SR1.
25 SR1: Mean under the alternative hypothesis.
26 sigma0: Standard deviation under the null hypothesis; must be > 0.
27 sigma1: Standard deviation under the alternative hypothesis; must be > 0.
28 p_H1: Prior probability of the alternative hypothesis; must be in (0, 1).
30 Returns:
31 ``True`` if every parameter is within its valid range.
32 """
33 return SR0 < SR1 and 0 < q < 1 and 0 < p_H1 < 1 and sigma0 > 0 and sigma1 > 0
36def _fdr_posterior(c: float, SR0: float, SR1: float, sigma0: float, sigma1: float, p_H1: float) -> float:
37 """Posterior false-discovery probability P[H=0 | X > c] under the two-normal mixture.
39 Args:
40 c: Candidate critical value.
41 SR0: Mean under the null hypothesis.
42 SR1: Mean under the alternative hypothesis.
43 sigma0: Standard deviation under the null hypothesis.
44 sigma1: Standard deviation under the alternative hypothesis.
45 p_H1: Prior probability of the alternative hypothesis.
47 Returns:
48 Posterior false discovery probability at threshold ``c`` (0 where non-finite).
49 """
50 a = 1 / (1 + scipy.stats.norm.sf((c - SR1) / sigma1) / scipy.stats.norm.sf((c - SR0) / sigma0) * p_H1 / (1 - p_H1))
51 return float(np.where(np.isfinite(a), a, 0))
54def adjusted_p_values_bonferroni(ps: np.ndarray) -> np.ndarray:
55 """Adjust p-values using Bonferroni correction for FWER control.
57 Multiplies each p-value by the number of tests M, capping at 1.
58 This is the most conservative multiple testing correction.
60 Args:
61 ps: Array of unadjusted p-values.
63 Returns:
64 Array of Bonferroni-adjusted p-values, each in [0, 1].
66 Example:
67 >>> p_vals = np.array([0.01, 0.03, 0.05])
68 >>> adj_p = adjusted_p_values_bonferroni(p_vals)
69 >>> np.allclose(adj_p, [0.03, 0.09, 0.15])
70 True
71 """
72 M = len(ps)
73 result: np.ndarray = np.minimum(1, M * ps)
74 return result
77def adjusted_p_values_sidak(ps: np.ndarray) -> np.ndarray:
78 """Adjust p-values using Šidák correction for FWER control.
80 Uses the formula: 1 - (1 - p)^M, which is slightly less conservative
81 than Bonferroni when tests are independent.
83 Args:
84 ps: Array of unadjusted p-values.
86 Returns:
87 Array of Šidák-adjusted p-values, each in [0, 1].
89 Example:
90 >>> p_vals = np.array([0.01, 0.03, 0.05])
91 >>> adj_p = adjusted_p_values_sidak(p_vals)
92 >>> np.all(adj_p <= adjusted_p_values_bonferroni(p_vals))
93 np.True_
94 """
95 M = len(ps)
96 return 1 - (1 - ps) ** M
99def adjusted_p_values_holm(ps: np.ndarray, *, variant: str = "bonferroni") -> np.ndarray:
100 """Adjust p-values using Holm's step-down procedure for FWER control.
102 A step-down procedure that is uniformly more powerful than Bonferroni
103 while still controlling the Family-Wise Error Rate.
105 Args:
106 ps: Array of unadjusted p-values.
107 variant: Correction method for each step. Either "bonferroni"
108 (default) or "sidak".
110 Returns:
111 Array of Holm-adjusted p-values, each in [0, 1].
113 Raises:
114 ValueError: If variant is not "bonferroni" or "sidak".
116 Example:
117 >>> p_vals = np.array([0.01, 0.04, 0.03])
118 >>> adj_p = adjusted_p_values_holm(p_vals)
119 >>> float(adj_p[0]) # Smallest p-value adjusted most
120 0.03
121 """
122 if variant not in ("bonferroni", "sidak"):
123 raise ValueError(f"Unknown Holm variant {variant!r}; expected 'bonferroni' or 'sidak'.")
124 i = np.argsort(ps)
125 M = len(ps)
126 p_adjusted = np.zeros(M)
127 previous = 0
128 for j, idx in enumerate(i):
129 candidate = min(1, ps[idx] * (M - j)) if variant == "bonferroni" else 1 - (1 - ps[idx]) ** (M - j)
130 p_adjusted[idx] = max(previous, candidate)
131 previous = p_adjusted[idx]
132 return p_adjusted
135def FDR_critical_value(q: float, SR0: float, SR1: float, sigma0: float, sigma1: float, p_H1: float) -> float:
136 """Compute critical value for FDR control in hypothesis testing.
138 Given a mixture model where H ~ Bernoulli(p_H1) determines whether
139 X follows N(SR0, sigma0^2) or N(SR1, sigma1^2), finds the critical
140 value c such that P[H=0 | X > c] = q.
142 Args:
143 q: Desired False Discovery Rate in (0, 1).
144 SR0: Mean under null hypothesis (must be < SR1).
145 SR1: Mean under alternative hypothesis.
146 sigma0: Standard deviation under null hypothesis (must be > 0).
147 sigma1: Standard deviation under alternative hypothesis (must be > 0).
148 p_H1: Prior probability of alternative hypothesis in (0, 1).
150 Returns:
151 Critical value c. Returns -inf if solution is outside [-10, 10],
152 or nan if no solution exists.
154 Raises:
155 ValueError: If parameters are out of valid ranges.
157 Example:
158 >>> c = FDR_critical_value(q=0.2, SR0=0, SR1=0.5, sigma0=0.2, sigma1=0.3, p_H1=0.1)
159 >>> c > 0 # Critical value should be positive
160 True
161 """
162 if not _valid_fdr_inputs(q, SR0, SR1, sigma0, sigma1, p_H1):
163 raise ValueError(
164 "Invalid FDR inputs: require SR0 < SR1, 0 < q < 1, 0 < p_H1 < 1, sigma0 > 0, and sigma1 > 0; "
165 f"got q={q}, SR0={SR0}, SR1={SR1}, sigma0={sigma0}, sigma1={sigma1}, p_H1={p_H1}."
166 )
168 with warnings.catch_warnings():
169 warnings.filterwarnings("ignore", message="invalid value encountered in scalar divide")
170 warnings.filterwarnings("ignore", message="divide by zero encountered in scalar divide")
172 f_lo = _fdr_posterior(-10, SR0, SR1, sigma0, sigma1, p_H1)
173 f_hi = _fdr_posterior(10, SR0, SR1, sigma0, sigma1, p_H1)
175 if f_lo < q: # Solution outside of the search interval
176 return float(-np.inf)
178 if (f_lo - q) * (f_hi - q) > 0: # No solution, for instance if σ₀≫σ₁ and q small
179 return float(np.nan)
181 return float(scipy.optimize.brentq(lambda c: _fdr_posterior(c, SR0, SR1, sigma0, sigma1, p_H1) - q, -10, 10))
184def control_for_FDR(
185 q: float,
186 *,
187 SR0: float = 0,
188 SR1: float = 0.5,
189 p_H1: float = 0.05,
190 T: int = 24,
191 gamma3: float = 0.0,
192 gamma4: float = 3.0,
193 rho: float = 0.0,
194 K: int = 1,
195) -> tuple[float, float, float, float]:
196 """Compute critical value to test multiple Sharpe ratios controlling FDR.
198 Determines the critical Sharpe ratio threshold and associated error rates
199 to control the False Discovery Rate at level q when testing multiple
200 strategies.
202 Args:
203 q: Desired False Discovery Rate level in (0, 1).
204 SR0: Sharpe ratio under null hypothesis H0. Default 0.
205 SR1: Sharpe ratio under alternative hypothesis H1. Default 0.5.
206 p_H1: Prior probability that H1 is true. Default 0.05.
207 T: Number of observations. Default 24.
208 gamma3: Skewness of returns. Default 0.
209 gamma4: Kurtosis of returns (non-excess). Default 3 (Gaussian).
210 rho: Autocorrelation of returns. Default 0.
211 K: Number of strategies (K=1 for FDR; K>1 for FWER-FDR). Default 1.
213 Returns:
214 Tuple of (alpha, beta, SR_c, q_hat):
215 - alpha: Significance level P[SR > SR_c | H0]
216 - beta: Type II error P[SR <= SR_c | H1]; power is 1 - beta
217 - SR_c: Critical Sharpe ratio threshold
218 - q_hat: Estimated FDR (should be close to q)
220 Example:
221 >>> alpha, beta, SR_c, q_hat = control_for_FDR(q=0.25, T=24)
222 >>> bool(0 < alpha < 1)
223 True
224 >>> bool(SR_c > 0) # Critical value is positive
225 True
226 """
227 Z = scipy.stats.norm.cdf
229 s0 = math.sqrt(sharpe_ratio_variance(SR0, T, gamma3=gamma3, gamma4=gamma4, rho=rho, K=K))
230 s1 = math.sqrt(sharpe_ratio_variance(SR1, T, gamma3=gamma3, gamma4=gamma4, rho=rho, K=K))
231 SRc = FDR_critical_value(q, SR0, SR1, s0, s1, p_H1)
233 beta = Z((SRc - SR1) / s1)
234 alpha = q / (1 - q) * p_H1 / (1 - p_H1) * (1 - beta)
235 q_hat = 1 / (1 + (1 - beta) / alpha * p_H1 / (1 - p_H1))
237 return alpha, beta, SRc, q_hat
240def pFDR(
241 p_H1: float,
242 alpha: float,
243 beta: float,
244) -> float:
245 """Compute posterior FDR given test outcome exceeds critical value.
247 Calculates P[H0 | SR > SR_c], the probability that the null hypothesis
248 is true given that the observed Sharpe ratio exceeds the critical value.
249 This is the "predictive" FDR based on the critical value, not the
250 observed value.
252 Args:
253 p_H1: Prior probability that H1 is true.
254 alpha: Significance level (Type I error rate).
255 beta: Type II error rate (1 - power).
257 Returns:
258 Posterior probability of H0 given rejection.
260 Example:
261 >>> # With 5% prior on H1 and 5% significance
262 >>> fdr = pFDR(p_H1=0.05, alpha=0.05, beta=0.3)
263 >>> 0 < fdr < 1
264 True
265 """
266 p_H0 = 1 - p_H1
267 return 1 / (1 + (1 - beta) * p_H1 / alpha / p_H0)
270def oFDR(
271 SR: float,
272 SR0: float,
273 SR1: float,
274 T: int,
275 p_H1: float,
276 *,
277 gamma3: float = 0.0,
278 gamma4: float = 3.0,
279 rho: float = 0.0,
280 K: int = 1,
281) -> float:
282 """Compute observed FDR given the observed Sharpe ratio.
284 Calculates P[H0 | SR > SR_obs], the probability that the null hypothesis
285 is true given the observed Sharpe ratio value. This is the "observed"
286 FDR which conditions on the actual observation rather than just the
287 critical value.
289 Args:
290 SR: Observed Sharpe ratio.
291 SR0: Sharpe ratio under null hypothesis.
292 SR1: Sharpe ratio under alternative hypothesis.
293 T: Number of observations.
294 p_H1: Prior probability that H1 is true.
295 gamma3: Skewness of returns. Default 0.
296 gamma4: Kurtosis of returns (non-excess). Default 3 (Gaussian).
297 rho: Autocorrelation of returns. Default 0.
298 K: Number of strategies for variance adjustment. Default 1.
300 Returns:
301 Posterior probability of H0 given the observed SR.
303 Example:
304 >>> # Higher observed SR should give lower probability of H0
305 >>> fdr_low = oFDR(SR=0.3, SR0=0, SR1=0.5, T=24, p_H1=0.1)
306 >>> fdr_high = oFDR(SR=0.8, SR0=0, SR1=0.5, T=24, p_H1=0.1)
307 >>> bool(fdr_high < fdr_low)
308 True
309 """
310 p0 = 1 - probabilistic_sharpe_ratio(SR, SR0, T=T, gamma3=gamma3, gamma4=gamma4, rho=rho, K=K)
311 p1 = 1 - probabilistic_sharpe_ratio(SR, SR1, T=T, gamma3=gamma3, gamma4=gamma4, rho=rho, K=K)
312 p_H0 = 1 - p_H1
313 return p0 * p_H0 / (p0 * p_H0 + p1 * p_H1)