Coverage for src/jsharpe/sharpe/psr.py: 100%
35 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-29 13:57 +0000
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-29 13:57 +0000
1"""Sharpe-ratio variance, track record, and probabilistic Sharpe ratio.
3This module groups the core statistical-inference routines for the Sharpe
4ratio: its asymptotic variance, the variance/expectation of the maximum
5across many trials, the minimum track record length, the critical Sharpe
6ratio, the Probabilistic Sharpe Ratio (PSR), and the associated power.
7"""
8# ruff: noqa: N802, N803, N806, S101
10import math
12import numpy as np
13import scipy
15from .quadrature import moments_Mk
18def sharpe_ratio_variance(
19 SR: float,
20 T: int,
21 *,
22 gamma3: float = 0.0,
23 gamma4: float = 3.0,
24 rho: float = 0.0,
25 K: int = 1,
26) -> float:
27 """Compute the asymptotic variance of the Sharpe ratio estimator.
29 Accounts for non-Gaussian returns (skewness, kurtosis) and autocorrelation.
30 Under Gaussian iid returns, reduces to (1 + SR^2/2) / T.
32 Args:
33 SR: Sharpe ratio value (annualized or per-period).
34 T: Number of observations (time periods).
35 gamma3: Skewness of returns. Default 0 (symmetric).
36 gamma4: Kurtosis of returns (non-excess). Default 3 (Gaussian).
37 rho: First-order autocorrelation of returns. Default 0 (iid).
38 K: Number of strategies for multiple testing adjustment. Default 1.
40 Returns:
41 Variance of the Sharpe ratio estimator.
43 Example:
44 >>> # Variance under Gaussian iid assumptions
45 >>> var_gaussian = sharpe_ratio_variance(SR=0.5, T=24)
46 >>> # Variance with non-Gaussian returns (higher kurtosis)
47 >>> var_nongauss = sharpe_ratio_variance(SR=0.5, T=24, gamma4=6.0)
48 >>> bool(var_nongauss > var_gaussian) # Higher kurtosis increases variance
49 True
50 """
51 A = 1
52 B = rho / (1 - rho)
53 C = rho**2 / (1 - rho**2)
54 a = A + 2 * B
55 b = A + B + C
56 c = A + 2 * C
57 V = (a * 1 - b * gamma3 * SR + c * (gamma4 - 1) / 4 * SR**2) / T
58 return float(V * moments_Mk(K)[2])
61def variance_of_the_maximum_of_k_Sharpe_ratios(number_of_trials: int, variance: float) -> float:
62 """Compute the variance of the maximum Sharpe ratio across K strategies.
64 Selection across a larger pool increases the uncertainty of the selected
65 (maximum) Sharpe ratio estimate. This function applies a logarithmic
66 variance inflation factor that increases monotonically with K.
68 Args:
69 number_of_trials: Number of strategies (K >= 1).
70 variance: Base variance of individual Sharpe ratio estimates.
72 Returns:
73 Inflated variance accounting for selection from K strategies.
75 Example:
76 >>> # More trials increases variance due to selection bias
77 >>> v1 = variance_of_the_maximum_of_k_Sharpe_ratios(1, 0.1)
78 >>> v10 = variance_of_the_maximum_of_k_Sharpe_ratios(10, 0.1)
79 >>> bool(v10 > v1)
80 True
81 """
82 # Monotone increasing variance inflation with K (K>=1)
83 inflation = 1.0 + np.log(max(1, int(number_of_trials)))
84 return float(variance * inflation)
87def expected_maximum_sharpe_ratio(number_of_trials: int, variance: float, SR0: float = 0) -> float:
88 """Compute expected maximum Sharpe ratio under multiple testing.
90 Estimates E[max(SR_1, ..., SR_K)] assuming K independent Sharpe ratio
91 estimates, each with the same variance. Uses the Gumbel approximation
92 for the expected maximum of normals.
94 Args:
95 number_of_trials: Number of strategies (K).
96 variance: Variance of individual Sharpe ratio estimates.
97 SR0: Baseline Sharpe ratio to add. Default 0.
99 Returns:
100 Expected value of the maximum Sharpe ratio across K strategies.
102 Example:
103 >>> # Expected maximum increases with number of trials
104 >>> e1 = expected_maximum_sharpe_ratio(1, 0.1)
105 >>> e10 = expected_maximum_sharpe_ratio(10, 0.1)
106 >>> bool(e10 > e1)
107 True
108 """
109 return float(
110 SR0
111 + (
112 np.sqrt(variance)
113 * (
114 (1 - np.euler_gamma) * scipy.stats.norm.ppf(1 - 1 / number_of_trials)
115 + np.euler_gamma * scipy.stats.norm.ppf(1 - 1 / number_of_trials / np.exp(1))
116 )
117 )
118 )
121def minimum_track_record_length(
122 SR: float,
123 SR0: float,
124 *,
125 gamma3: float = 0.0,
126 gamma4: float = 3.0,
127 rho: float = 0.0,
128 alpha: float = 0.05,
129) -> float:
130 """Compute minimum track record length for statistical significance.
132 Determines the minimum number of observations T required for the observed
133 Sharpe ratio SR to be significantly greater than SR0 at confidence level
134 1 - alpha.
136 Args:
137 SR: Observed Sharpe ratio.
138 SR0: Sharpe ratio under null hypothesis H0.
139 gamma3: Skewness of returns. Default 0.
140 gamma4: Kurtosis of returns (non-excess). Default 3 (Gaussian).
141 rho: Autocorrelation of returns. Default 0.
142 alpha: Significance level. Default 0.05.
144 Returns:
145 Minimum track record length (number of periods) required.
147 Example:
148 >>> # Higher Sharpe ratio needs shorter track record
149 >>> mtrl_high = minimum_track_record_length(SR=1.0, SR0=0)
150 >>> mtrl_low = minimum_track_record_length(SR=0.5, SR0=0)
151 >>> bool(mtrl_high < mtrl_low)
152 True
153 """
154 var = sharpe_ratio_variance(SR0, T=1, gamma3=gamma3, gamma4=gamma4, rho=rho, K=1)
155 return float(var * (scipy.stats.norm.ppf(1 - alpha) / (SR - SR0)) ** 2)
158def critical_sharpe_ratio(
159 SR0: float,
160 T: int,
161 *,
162 gamma3: float = 0.0,
163 gamma4: float = 3.0,
164 rho: float = 0.0,
165 alpha: float = 0.05,
166 K: int = 1,
167) -> float:
168 """Compute critical Sharpe ratio for hypothesis testing.
170 Determines the threshold SR_c for the one-sided test:
171 H0: SR = SR0 vs H1: SR > SR0
172 at significance level alpha.
174 Args:
175 SR0: Sharpe ratio under null hypothesis.
176 T: Number of observations.
177 gamma3: Skewness of returns. Default 0.
178 gamma4: Kurtosis of returns (non-excess). Default 3 (Gaussian).
179 rho: Autocorrelation of returns. Default 0.
180 alpha: Significance level. Default 0.05.
181 K: Number of strategies for variance adjustment. Default 1.
183 Returns:
184 Critical Sharpe ratio threshold. Reject H0 if observed SR > SR_c.
186 Example:
187 >>> SR_c = critical_sharpe_ratio(SR0=0, T=24, alpha=0.05)
188 >>> bool(SR_c > 0) # Need positive SR to reject H0: SR=0
189 True
190 """
191 variance = sharpe_ratio_variance(SR0, T, gamma3=gamma3, gamma4=gamma4, rho=rho, K=K)
192 return float(SR0 + scipy.stats.norm.ppf(1 - alpha) * math.sqrt(variance))
195def probabilistic_sharpe_ratio(
196 SR: float,
197 SR0: float,
198 *,
199 variance: float | None = None,
200 T: int | None = None,
201 gamma3: float = 0.0,
202 gamma4: float = 3.0,
203 rho: float = 0.0,
204 K: int = 1,
205) -> float:
206 """Compute the Probabilistic Sharpe Ratio (PSR).
208 The PSR is 1 - p, where p is the p-value of testing H0: SR = SR0 vs
209 H1: SR > SR0. It can be interpreted as a "Sharpe ratio on a probability
210 scale", i.e., mapping the SR to [0, 1].
212 Args:
213 SR: Observed Sharpe ratio.
214 SR0: Sharpe ratio under null hypothesis.
215 variance: Variance of SR estimator. Provide this OR (T, gamma3, ...).
216 T: Number of observations (if variance not provided).
217 gamma3: Skewness of returns. Default 0.
218 gamma4: Kurtosis of returns (non-excess). Default 3 (Gaussian).
219 rho: Autocorrelation of returns. Default 0.
220 K: Number of strategies for variance adjustment. Default 1.
222 Returns:
223 Probabilistic Sharpe Ratio in [0, 1]. Values near 1 indicate
224 strong evidence that the true SR exceeds SR0.
226 Raises:
227 AssertionError: If both variance and T are provided.
229 Example:
230 >>> psr = probabilistic_sharpe_ratio(SR=0.5, SR0=0, T=24)
231 >>> bool(0 < psr < 1)
232 True
233 >>> # Higher observed SR gives higher PSR
234 >>> psr_high = probabilistic_sharpe_ratio(SR=1.0, SR0=0, T=24)
235 >>> bool(psr_high > psr)
236 True
237 """
238 if variance is None:
239 assert T is not None, "T must be provided if variance is not provided"
240 variance = sharpe_ratio_variance(SR0, T, gamma3=gamma3, gamma4=gamma4, rho=rho, K=K)
241 else:
242 assert T is None, "Provide either the variance or (T, gamma3, gamma4, rho)"
243 return float(scipy.stats.norm.cdf((SR - SR0) / math.sqrt(variance)))
246def sharpe_ratio_power(
247 SR0: float,
248 SR1: float,
249 T: int,
250 *,
251 gamma3: float = 0.0,
252 gamma4: float = 3.0,
253 rho: float = 0.0,
254 alpha: float = 0.05,
255 K: int = 1,
256) -> float:
257 """Compute statistical power for Sharpe ratio hypothesis test.
259 Calculates the power (1 - β) of the test H0: SR = SR0 vs H1: SR = SR1,
260 which is the probability of correctly rejecting H0 when the true
261 Sharpe ratio is SR1.
263 Note: Power is equivalent to recall in classification:
264 Power = P[reject H0 | H1] = TP / (TP + FN)
266 Args:
267 SR0: Sharpe ratio under null hypothesis.
268 SR1: Sharpe ratio under alternative hypothesis (should be > SR0).
269 T: Number of observations.
270 gamma3: Skewness of returns. Default 0.
271 gamma4: Kurtosis of returns (non-excess). Default 3 (Gaussian).
272 rho: Autocorrelation of returns. Default 0.
273 alpha: Significance level. Default 0.05.
274 K: Number of strategies for variance adjustment. Default 1.
276 Returns:
277 Statistical power in [0, 1].
279 Example:
280 >>> # More observations increases power
281 >>> power_short = sharpe_ratio_power(SR0=0, SR1=0.5, T=12)
282 >>> power_long = sharpe_ratio_power(SR0=0, SR1=0.5, T=48)
283 >>> bool(power_long > power_short)
284 True
285 """
286 critical_SR = critical_sharpe_ratio(SR0, T, gamma3=gamma3, gamma4=gamma4, rho=rho, alpha=alpha)
287 variance = sharpe_ratio_variance(SR1, T, gamma3=gamma3, gamma4=gamma4, rho=rho, K=K)
288 beta = scipy.stats.norm.cdf((critical_SR - SR1) / math.sqrt(variance))
289 return float(1 - beta)