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

1"""Sharpe-ratio variance, track record, and probabilistic Sharpe ratio. 

2 

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 

9 

10import math 

11 

12import numpy as np 

13import scipy 

14 

15from .quadrature import moments_Mk 

16 

17 

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. 

28 

29 Accounts for non-Gaussian returns (skewness, kurtosis) and autocorrelation. 

30 Under Gaussian iid returns, reduces to (1 + SR^2/2) / T. 

31 

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. 

39 

40 Returns: 

41 Variance of the Sharpe ratio estimator. 

42 

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]) 

59 

60 

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. 

63 

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. 

67 

68 Args: 

69 number_of_trials: Number of strategies (K >= 1). 

70 variance: Base variance of individual Sharpe ratio estimates. 

71 

72 Returns: 

73 Inflated variance accounting for selection from K strategies. 

74 

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) 

85 

86 

87def expected_maximum_sharpe_ratio(number_of_trials: int, variance: float, SR0: float = 0) -> float: 

88 """Compute expected maximum Sharpe ratio under multiple testing. 

89 

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. 

93 

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. 

98 

99 Returns: 

100 Expected value of the maximum Sharpe ratio across K strategies. 

101 

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 ) 

119 

120 

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. 

131 

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. 

135 

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. 

143 

144 Returns: 

145 Minimum track record length (number of periods) required. 

146 

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) 

156 

157 

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. 

169 

170 Determines the threshold SR_c for the one-sided test: 

171 H0: SR = SR0 vs H1: SR > SR0 

172 at significance level alpha. 

173 

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. 

182 

183 Returns: 

184 Critical Sharpe ratio threshold. Reject H0 if observed SR > SR_c. 

185 

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)) 

193 

194 

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). 

207 

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]. 

211 

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. 

221 

222 Returns: 

223 Probabilistic Sharpe Ratio in [0, 1]. Values near 1 indicate 

224 strong evidence that the true SR exceeds SR0. 

225 

226 Raises: 

227 AssertionError: If both variance and T are provided. 

228 

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))) 

244 

245 

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. 

258 

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. 

262 

263 Note: Power is equivalent to recall in classification: 

264 Power = P[reject H0 | H1] = TP / (TP + FN) 

265 

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. 

275 

276 Returns: 

277 Statistical power in [0, 1]. 

278 

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)