Coverage for src/cvx/risk/sample/sample.py: 100%

53 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-07-22 10:34 +0000

1# Copyright (c) 2025 Jebel Quant Research 

2# 

3# Licensed under the MIT License. See the LICENSE file in the project root 

4# for the full license text. 

5"""Risk models based on the sample covariance matrix. 

6 

7This module provides the SampleCovariance class, which implements a risk model 

8based on the Cholesky decomposition of the sample covariance matrix. This is 

9one of the most common approaches to portfolio risk estimation. 

10 

11Example: 

12 Create and use a sample covariance risk model: 

13 

14 >>> import numpy as np 

15 >>> from cvx.risk.sample import SampleCovariance 

16 >>> # Create risk model for up to 3 assets 

17 >>> model = SampleCovariance(num=3) 

18 >>> # Update with a covariance matrix 

19 >>> cov = np.array([[1.0, 0.5, 0.0], [0.5, 1.0, 0.5], [0.0, 0.5, 1.0]]) 

20 >>> model.update( 

21 ... cov=cov, 

22 ... lower_assets=np.zeros(3), 

23 ... upper_assets=np.ones(3) 

24 ... ) 

25 >>> # Estimate risk for a given portfolio 

26 >>> weights = np.array([0.4, 0.3, 0.3]) 

27 >>> risk = model.estimate(weights) 

28 >>> isinstance(risk, float) 

29 True 

30 

31""" 

32 

33from __future__ import annotations 

34 

35from dataclasses import dataclass 

36from typing import Any 

37 

38import clarabel 

39import numpy as np 

40from cvx.linalg import cholesky, norm 

41 

42from cvx.core import Bounds, ConeProgramBuilder, Model, Parameter, Variable 

43 

44 

45@dataclass 

46class SampleCovariance(Model): 

47 """Risk model based on the Cholesky decomposition of the sample covariance matrix. 

48 

49 This model computes portfolio risk as the L2 norm of the product of the 

50 Cholesky factor and the weights vector. Mathematically, if R is the upper 

51 triangular Cholesky factor of the covariance matrix (R^T @ R = cov), then: 

52 

53 risk = ||R @ w||_2 = sqrt(w^T @ cov @ w) 

54 

55 This represents the portfolio standard deviation (volatility). 

56 

57 Attributes: 

58 num: Maximum number of assets the model can handle. The model can be 

59 updated with fewer assets, but not more. 

60 

61 Example: 

62 Basic usage: 

63 

64 >>> import numpy as np 

65 >>> from cvx.risk.sample import SampleCovariance 

66 >>> model = SampleCovariance(num=2) 

67 >>> model.update( 

68 ... cov=np.array([[1.0, 0.5], [0.5, 2.0]]), 

69 ... lower_assets=np.zeros(2), 

70 ... upper_assets=np.ones(2) 

71 ... ) 

72 >>> # Equal weight portfolio 

73 >>> weights = np.array([0.5, 0.5]) 

74 >>> risk = model.estimate(weights) 

75 >>> # Risk should be sqrt(0.5^2 * 1 + 0.5^2 * 2 + 2 * 0.5 * 0.5 * 0.5) 

76 >>> bool(np.isclose(risk, 1.0)) 

77 True 

78 

79 Using in optimization: 

80 

81 >>> from cvx.risk.portfolio import minrisk_problem 

82 >>> from cvx.core.variable import Variable 

83 >>> weights = Variable(2) 

84 >>> problem = minrisk_problem(model, weights) 

85 >>> problem.solve() 

86 >>> # Lower variance asset gets higher weight 

87 >>> bool(weights.value[0] > weights.value[1]) 

88 True 

89 

90 Mathematical verification - the risk estimate equals sqrt(w^T @ cov @ w): 

91 

92 >>> model = SampleCovariance(num=3) 

93 >>> cov = np.array([[0.04, 0.01, 0.02], 

94 ... [0.01, 0.09, 0.01], 

95 ... [0.02, 0.01, 0.16]]) 

96 >>> model.update( 

97 ... cov=cov, 

98 ... lower_assets=np.zeros(3), 

99 ... upper_assets=np.ones(3) 

100 ... ) 

101 >>> w = np.array([0.4, 0.35, 0.25]) 

102 >>> # Model estimate 

103 >>> model_risk = model.estimate(w) 

104 >>> # Manual calculation: sqrt(w^T @ cov @ w) 

105 >>> manual_risk = np.sqrt(w @ cov @ w) 

106 >>> bool(np.isclose(model_risk, manual_risk, rtol=1e-6)) 

107 True 

108 

109 """ 

110 

111 num: int = 0 

112 """Maximum number of assets the model can handle.""" 

113 

114 def __post_init__(self) -> None: 

115 """Initialize the parameters after the class is instantiated. 

116 

117 Creates the Cholesky decomposition parameter and initializes the bounds. 

118 The Cholesky parameter is a square matrix of size (num, num), and bounds 

119 are created for asset weights. 

120 

121 Example: 

122 >>> from cvx.risk.sample import SampleCovariance 

123 >>> model = SampleCovariance(num=5) 

124 >>> # Parameters are automatically created 

125 >>> model.parameter["chol"].shape 

126 (5, 5) 

127 

128 """ 

129 self.parameter["chol"] = Parameter( 

130 shape=(self.num, self.num), 

131 name="cholesky of covariance", 

132 ) 

133 self.bounds = Bounds(m=self.num, name="assets") 

134 

135 def estimate(self, weights: np.ndarray, **kwargs: Any) -> float: 

136 """Estimate the portfolio risk using the Cholesky decomposition. 

137 

138 Computes the L2 norm of the product of the Cholesky factor and the 

139 weights vector. This is equivalent to the square root of the portfolio 

140 variance (i.e., portfolio volatility). 

141 

142 Args: 

143 weights: Numpy array representing portfolio weights. 

144 **kwargs: Additional keyword arguments (not used). 

145 

146 Returns: 

147 Float representing the portfolio risk (standard deviation). 

148 

149 Example: 

150 >>> import numpy as np 

151 >>> from cvx.risk.sample import SampleCovariance 

152 >>> model = SampleCovariance(num=2) 

153 >>> # Identity covariance (uncorrelated assets with unit variance) 

154 >>> model.update( 

155 ... cov=np.eye(2), 

156 ... lower_assets=np.zeros(2), 

157 ... upper_assets=np.ones(2) 

158 ... ) 

159 >>> risk = model.estimate(np.array([0.5, 0.5])) 

160 >>> isinstance(risk, float) 

161 True 

162 

163 """ 

164 return norm(self.parameter["chol"].value @ np.asarray(weights)) 

165 

166 def update(self, **kwargs: Any) -> None: 

167 """Update the Cholesky decomposition parameter and bounds. 

168 

169 Computes the Cholesky decomposition of the provided covariance matrix 

170 and updates the model parameters. The covariance matrix can be smaller 

171 than num x num. 

172 

173 Args: 

174 **kwargs: Keyword arguments containing: 

175 - cov: Covariance matrix (numpy.ndarray). Must be positive definite. 

176 - lower_assets: Array of lower bounds for asset weights. 

177 - upper_assets: Array of upper bounds for asset weights. 

178 

179 Raises: 

180 ValueError: If ``cov`` is missing, not square, or larger than the 

181 model capacity ``num``. 

182 

183 Example: 

184 >>> import numpy as np 

185 >>> from cvx.risk.sample import SampleCovariance 

186 >>> model = SampleCovariance(num=5) 

187 >>> # Update with a 3x3 covariance (smaller than max) 

188 >>> cov = np.array([[1.0, 0.3, 0.1], 

189 ... [0.3, 1.0, 0.2], 

190 ... [0.1, 0.2, 1.0]]) 

191 >>> model.update( 

192 ... cov=cov, 

193 ... lower_assets=np.zeros(3), 

194 ... upper_assets=np.ones(3) 

195 ... ) 

196 >>> # Cholesky factor is updated 

197 >>> model.parameter["chol"].value[:3, :3].shape 

198 (3, 3) 

199 

200 """ 

201 if "cov" not in kwargs: 

202 msg = "update() requires a 'cov' argument" 

203 raise ValueError(msg) 

204 cov = np.asarray(kwargs["cov"]) 

205 if cov.ndim != 2 or cov.shape[0] != cov.shape[1]: 

206 msg = f"cov must be a square matrix, got shape {cov.shape}" 

207 raise ValueError(msg) 

208 if cov.shape[0] > self.num: 

209 msg = f"Too many assets: cov is {cov.shape[0]}x{cov.shape[0]} but the model capacity is num={self.num}" 

210 raise ValueError(msg) 

211 num_assets = cov.shape[0] 

212 

213 padded_chol = np.zeros((self.num, self.num)) 

214 padded_chol[:num_assets, :num_assets] = cholesky(cov) 

215 self.parameter["chol"].value = padded_chol 

216 self.bounds.update(**kwargs) 

217 

218 def solve_minrisk( 

219 self, 

220 weights: Variable, 

221 base: np.ndarray, 

222 extra_constraints: list[tuple[np.ndarray, float | None, float | None]], 

223 y_var: Variable | None = None, # noqa: ARG002 -- shared solve_minrisk interface; only factor models use y_var 

224 ) -> tuple[float | None, float | None, str]: 

225 """Build and solve the Clarabel SOC problem for this model. 

226 

227 Raises: 

228 ValueError: If the weights dimension does not match the model 

229 capacity ``num``. 

230 

231 """ 

232 n = weights.n 

233 if n != self.num: 

234 msg = f"weights has dimension {n} but the model capacity is num={self.num}" 

235 raise ValueError(msg) 

236 chol = self.parameter["chol"].value 

237 lb, ub = self.bounds.get_bounds() 

238 

239 # Variables: x = [t, w] with t bounding the portfolio volatility. 

240 w_cols = slice(1, 1 + n) 

241 builder = ConeProgramBuilder(n_vars=1 + n) 

242 

243 # SOC: || chol @ (w - base) ||_2 <= t 

244 a_soc = builder.block(n + 1) 

245 a_soc[0, 0] = -1.0 

246 a_soc[1:, w_cols] = -chol 

247 b_soc = np.zeros(n + 1) 

248 b_soc[1:] = -chol @ base 

249 builder.add(a_soc, b_soc, clarabel.SecondOrderConeT(n + 1)) 

250 

251 builder.add_sum_constraint(w_cols) 

252 builder.add_variable_bounds(w_cols, lb, ub) 

253 builder.add_linear_constraints(extra_constraints, w_cols) 

254 

255 q = np.zeros(builder.n_vars) 

256 q[0] = 1.0 

257 return self._solve_and_unpack( 

258 builder, 

259 q, 

260 weights, 

261 w_cols, 

262 lambda sol: (float(sol.obj_val), float(sol.x[0])), 

263 )