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
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-11 10:09 +0000
1"""Synthetic return-data generators and autocorrelation estimation.
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
9import numpy as np
10import scipy
12from .linalg import ppoints
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.
18 Draws random block boundaries until every one of the
19 ``effective_number_of_trials`` blocks is non-empty.
21 Args:
22 number_of_trials: Total number of series to partition.
23 effective_number_of_trials: Number of (non-empty) blocks to produce.
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
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.
50 Creates a matrix of simulated returns with specified autocorrelation
51 and marginal distribution characteristics (skewness/kurtosis).
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
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.
67 Returns:
68 Array of shape (N, n) containing simulated returns.
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
80 shape = (N, n)
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")
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, :]
92 # Convert the margins to uniform, with the Gaussian cdf
93 X = scipy.stats.norm.cdf(X)
95 # Convert the uniforms to the target margins, using the ppf
96 result: np.ndarray = ppf(X)
98 return result
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.
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.
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.
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
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
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.
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.
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".
171 Returns:
172 Array of shape (nr, nc) containing simulated returns.
174 Raises:
175 ValueError: If name is not a valid distribution type.
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)}.")
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.
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.
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)
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.
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.
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
238 return gen_with_true_SR0(nr, nc, configs[name], SR0)
241def autocorrelation(X: np.ndarray) -> float:
242 """Compute mean first-order autocorrelation across columns.
244 Calculates the lag-1 autocorrelation for each column of the input
245 matrix and returns the mean across all columns.
247 Args:
248 X: Data matrix of shape (n_observations, n_series).
250 Returns:
251 Mean autocorrelation coefficient across all columns.
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())