Coverage for src/jsharpe/sharpe/generators.py: 100%

58 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-11 10:09 +0000

1"""Synthetic return-data generators and autocorrelation estimation. 

2 

3This module groups the simulation helpers used to generate 

4(autocorrelated) non-Gaussian return series and block-structured random 

5correlation matrices, plus the mean first-order autocorrelation estimator. 

6""" 

7# ruff: noqa: N802, N803, N806, TRY003 

8 

9import numpy as np 

10import scipy 

11 

12from .linalg import ppoints 

13 

14 

15def _sample_block_sizes(number_of_trials: int, effective_number_of_trials: int) -> np.ndarray: 

16 """Sample positive block sizes partitioning ``number_of_trials`` into blocks. 

17 

18 Draws random block boundaries until every one of the 

19 ``effective_number_of_trials`` blocks is non-empty. 

20 

21 Args: 

22 number_of_trials: Total number of series to partition. 

23 effective_number_of_trials: Number of (non-empty) blocks to produce. 

24 

25 Returns: 

26 Array of ``effective_number_of_trials`` positive block sizes summing to 

27 ``number_of_trials``. 

28 """ 

29 while True: 

30 block_positions = [ 

31 0, 

32 *sorted(np.random.choice(number_of_trials, effective_number_of_trials - 1, replace=True)), 

33 number_of_trials, 

34 ] 

35 block_sizes = np.diff(block_positions) 

36 if np.all(block_sizes > 0): 

37 return block_sizes 

38 

39 

40def generate_autocorrelated_non_gaussian_data( 

41 N: int, 

42 n: int, 

43 SR0: float = 0, 

44 name: str = "gaussian", 

45 rho: float | None = None, 

46 gaussian_autocorrelation: float = 0, 

47) -> np.ndarray: 

48 """Generate autocorrelated non-Gaussian return data for simulation. 

49 

50 Creates a matrix of simulated returns with specified autocorrelation 

51 and marginal distribution characteristics (skewness/kurtosis). 

52 

53 Uses a copula-like approach: 

54 1. Generate AR(1) Gaussian processes 

55 2. Transform to uniform via Gaussian CDF 

56 3. Transform to target marginals via inverse CDF 

57 

58 Args: 

59 N: Number of time periods (rows). 

60 n: Number of assets/strategies (columns). 

61 SR0: Target Sharpe ratio. Default 0. 

62 name: Distribution type. One of "gaussian", "mild", "moderate", 

63 "severe". Default "gaussian". 

64 rho: Autocorrelation coefficient. If None, uses gaussian_autocorrelation. 

65 gaussian_autocorrelation: Autocorrelation for Gaussian case. Default 0. 

66 

67 Returns: 

68 Array of shape (N, n) containing simulated returns. 

69 

70 Example: 

71 >>> np.random.seed(42) 

72 >>> X = generate_autocorrelated_non_gaussian_data(100, 2, SR0=0.1, name="mild") 

73 >>> X.shape 

74 (100, 2) 

75 """ 

76 if rho is None: 

77 # With the distributions we consider the autocorrelation is almost the same. 

78 rho = gaussian_autocorrelation 

79 

80 shape = (N, n) 

81 

82 # Marginal distribution: ppf 

83 R = 10_000 

84 marginal = generate_non_gaussian_data(R, 1, SR0=SR0, name=name)[:, 0] 

85 ppf = scipy.interpolate.interp1d(ppoints(R), sorted(marginal), fill_value="extrapolate") 

86 

87 # AR(1) processes 

88 X = np.random.normal(size=shape) 

89 for i in range(1, shape[0]): 

90 X[i, :] = rho * X[i - 1, :] + np.sqrt(1 - rho**2) * X[i, :] 

91 

92 # Convert the margins to uniform, with the Gaussian cdf 

93 X = scipy.stats.norm.cdf(X) 

94 

95 # Convert the uniforms to the target margins, using the ppf 

96 result: np.ndarray = ppf(X) 

97 

98 return result 

99 

100 

101def get_random_correlation_matrix( 

102 number_of_trials: int = 100, 

103 effective_number_of_trials: int = 10, 

104 number_of_observations: int = 200, 

105 noise: float = 0.1, 

106) -> tuple[np.ndarray, np.ndarray, np.ndarray]: 

107 """Generate a random correlation matrix with block structure. 

108 

109 Creates a correlation matrix representing clustered strategies, where 

110 strategies within the same cluster are highly correlated and strategies 

111 across clusters have lower correlation. 

112 

113 Args: 

114 number_of_trials: Number of time series (strategies). Default 100. 

115 effective_number_of_trials: Number of clusters. Default 10. 

116 number_of_observations: Number of time periods to simulate. Default 200. 

117 noise: Noise level added to each series. Default 0.1. 

118 

119 Returns: 

120 Tuple of (C, X, clusters): 

121 - C: Correlation matrix of shape (number_of_trials, number_of_trials) 

122 - X: Data matrix of shape (number_of_observations, number_of_trials) 

123 - clusters: Cluster assignment for each strategy 

124 

125 Example: 

126 >>> np.random.seed(42) 

127 >>> C, X, clusters = get_random_correlation_matrix( 

128 ... number_of_trials=20, effective_number_of_trials=4 

129 ... ) 

130 >>> C.shape 

131 (20, 20) 

132 >>> np.allclose(np.diag(C), 1) # Diagonal is all ones 

133 True 

134 """ 

135 block_sizes = _sample_block_sizes(number_of_trials, effective_number_of_trials) 

136 clusters = np.array([block_number for block_number, size in enumerate(block_sizes) for _ in range(size)]) 

137 X0 = np.random.normal(size=(number_of_observations, effective_number_of_trials)) 

138 X = np.zeros(shape=(number_of_observations, number_of_trials)) 

139 for i, cluster in enumerate(clusters): 

140 X[:, i] = X0[:, cluster] + noise * np.random.normal(size=number_of_observations) 

141 C = np.asarray(np.corrcoef(X, rowvar=False)) 

142 np.fill_diagonal(C, 1) # rounding errors 

143 C = np.clip(C, -1, 1) 

144 return C, X, clusters 

145 

146 

147def generate_non_gaussian_data( 

148 nr: int, 

149 nc: int, 

150 *, 

151 SR0: float = 0, 

152 name: str = "severe", 

153) -> np.ndarray: 

154 """Generate non-Gaussian return data with specified characteristics. 

155 

156 Creates a matrix of simulated returns from a mixture distribution that 

157 exhibits the specified skewness and kurtosis characteristics while 

158 maintaining the target Sharpe ratio. 

159 

160 Args: 

161 nr: Number of rows (observations/time periods). 

162 nc: Number of columns (assets/strategies). 

163 SR0: Target Sharpe ratio. Default 0. 

164 name: Distribution severity. One of: 

165 - "gaussian": No skewness or kurtosis 

166 - "mild": Slight negative skew and excess kurtosis 

167 - "moderate": Moderate negative skew and excess kurtosis 

168 - "severe": Strong negative skew and excess kurtosis 

169 Default "severe". 

170 

171 Returns: 

172 Array of shape (nr, nc) containing simulated returns. 

173 

174 Raises: 

175 ValueError: If name is not a valid distribution type. 

176 

177 Example: 

178 >>> np.random.seed(42) 

179 >>> X = generate_non_gaussian_data(1000, 1, SR0=0.2, name="mild") 

180 >>> X.shape 

181 (1000, 1) 

182 """ 

183 configs = { 

184 "gaussian": (0, 0, 0.015, 0.010), 

185 "mild": (0.04, -0.03, 0.015, 0.010), 

186 "moderate": (0.03, -0.045, 0.020, 0.010), 

187 "severe": (0.02, -0.060, 0.025, 0.010), 

188 } 

189 if name not in configs: 

190 raise ValueError(f"Unknown distribution name {name!r}; valid names are {sorted(configs)}.") 

191 

192 def mixture_variance( 

193 p_tail: float, 

194 mu_tail: float, 

195 sigma_tail: float, 

196 mu_core: float, 

197 sigma_core: float, 

198 ) -> float: 

199 """Compute the variance of a two-component Gaussian mixture. 

200 

201 Args: 

202 p_tail: Mixing weight of the tail component. 

203 mu_tail: Mean of the tail component. 

204 sigma_tail: Standard deviation of the tail component. 

205 mu_core: Mean of the core component. 

206 sigma_core: Standard deviation of the core component. 

207 

208 Returns: 

209 Variance of the mixture distribution. 

210 """ 

211 w = 1.0 - p_tail 

212 mu = w * mu_core + p_tail * mu_tail 

213 m2 = w * (sigma_core**2 + mu_core**2) + p_tail * (sigma_tail**2 + mu_tail**2) 

214 return float(m2 - mu**2) 

215 

216 def gen_with_true_SR0(reps: int, T: int, cfg: tuple[float, float, float, float], SR0: float) -> np.ndarray: 

217 """Generate mixture returns scaled to a target population Sharpe ratio. 

218 

219 Args: 

220 reps: Number of independent return series to generate. 

221 T: Length of each return series. 

222 cfg: Mixture config tuple (p_tail, mu_tail, sigma_tail, sigma_core). 

223 SR0: Target population Sharpe ratio. 

224 

225 Returns: 

226 Array of shape (reps, T) with non-Gaussian returns at the given Sharpe ratio. 

227 """ 

228 p, mu_tail, sig_tail, sig_core = cfg 

229 # Zero-mean baseline mixture (choose mu_core so mean=0) 

230 mu_core0 = -p * mu_tail / (1.0 - p) 

231 std0 = np.sqrt(mixture_variance(p, mu_tail, sig_tail, mu_core0, sig_core)) 

232 mu_shift = SR0 * std0 # sets population Sharpe to SR0, preserves skew/kurt 

233 mask = np.random.uniform(size=(reps, T)) < p 

234 X = np.random.normal(mu_core0 + mu_shift, sig_core, size=(reps, T)) 

235 X[mask] = np.random.normal(mu_tail + mu_shift, sig_tail, size=mask.sum()) 

236 return X 

237 

238 return gen_with_true_SR0(nr, nc, configs[name], SR0) 

239 

240 

241def autocorrelation(X: np.ndarray) -> float: 

242 """Compute mean first-order autocorrelation across columns. 

243 

244 Calculates the lag-1 autocorrelation for each column of the input 

245 matrix and returns the mean across all columns. 

246 

247 Args: 

248 X: Data matrix of shape (n_observations, n_series). 

249 

250 Returns: 

251 Mean autocorrelation coefficient across all columns. 

252 

253 Example: 

254 >>> np.random.seed(42) 

255 >>> # Generate AR(1) process with rho=0.5 

256 >>> n = 1000 

257 >>> X = np.zeros((n, 1)) 

258 >>> X[0] = np.random.normal() 

259 >>> for i in range(1, n): 

260 ... X[i] = 0.5 * X[i-1] + np.sqrt(1-0.25) * np.random.normal() 

261 >>> ac = autocorrelation(X) 

262 >>> bool(0.4 < ac < 0.6) # Should be close to 0.5 

263 True 

264 """ 

265 _nr, nc = X.shape 

266 ac = np.zeros(nc) 

267 for i in range(nc): 

268 ac[i] = np.corrcoef(X[1:, i], X[:-1, i])[0, 1] 

269 return float(ac.mean())