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

90 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"""Factor risk model. 

6 

7This module provides the FactorModel class, which implements a factor-based 

8risk model for portfolio optimization. Factor models decompose portfolio risk 

9into systematic (factor) risk and idiosyncratic (residual) risk. 

10 

11Example: 

12 Create a factor model and estimate portfolio risk: 

13 

14 >>> import numpy as np 

15 >>> from cvx.risk.factor import FactorModel 

16 >>> # Create factor model with 10 assets and 3 factors 

17 >>> model = FactorModel(assets=10, k=3) 

18 >>> # Set up factor exposure and covariance 

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

20 >>> exposure = np.random.randn(3, 10) # 3 factors x 10 assets 

21 >>> factor_cov = np.eye(3) # Factor covariance matrix 

22 >>> idio_risk = np.abs(np.random.randn(10)) # Idiosyncratic risk 

23 >>> model.update( 

24 ... exposure=exposure, 

25 ... cov=factor_cov, 

26 ... idiosyncratic_risk=idio_risk, 

27 ... lower_assets=np.zeros(10), 

28 ... upper_assets=np.ones(10), 

29 ... lower_factors=-0.1 * np.ones(3), 

30 ... upper_factors=0.1 * np.ones(3) 

31 ... ) 

32 >>> # Model is ready for optimization 

33 >>> w = np.zeros(10) 

34 >>> w[:5] = 0.2 

35 >>> risk = model.estimate(w) 

36 >>> isinstance(risk, float) 

37 True 

38 

39""" 

40 

41from __future__ import annotations 

42 

43from dataclasses import dataclass 

44from typing import Any 

45 

46import clarabel 

47import numpy as np 

48from cvx.linalg import cholesky, norm 

49from scipy import sparse 

50 

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

52 

53 

54@dataclass 

55class FactorModel(Model): 

56 """Factor risk model for portfolio optimization. 

57 

58 Factor models decompose portfolio risk into systematic risk (from factor 

59 exposures) and idiosyncratic risk (residual risk). The total portfolio 

60 variance is: 

61 

62 Var(w) = w' @ exposure' @ cov @ exposure @ w + sum((idio_risk * w)^2) 

63 

64 This implementation uses the Cholesky decomposition of the factor covariance 

65 matrix for efficient risk computation. 

66 

67 Attributes: 

68 assets: Maximum number of assets in the portfolio. 

69 k: Maximum number of factors in the model. 

70 

71 Example: 

72 Create and use a factor model: 

73 

74 >>> import numpy as np 

75 >>> from cvx.risk.factor import FactorModel 

76 >>> # Create model 

77 >>> model = FactorModel(assets=5, k=2) 

78 >>> # Factor exposure: 2 factors x 5 assets 

79 >>> exposure = np.array([[1.0, 0.8, 0.6, 0.4, 0.2], 

80 ... [0.2, 0.4, 0.6, 0.8, 1.0]]) 

81 >>> # Factor covariance 

82 >>> factor_cov = np.array([[1.0, 0.3], [0.3, 1.0]]) 

83 >>> # Idiosyncratic risk per asset 

84 >>> idio_risk = np.array([0.1, 0.1, 0.1, 0.1, 0.1]) 

85 >>> model.update( 

86 ... exposure=exposure, 

87 ... cov=factor_cov, 

88 ... idiosyncratic_risk=idio_risk, 

89 ... lower_assets=np.zeros(5), 

90 ... upper_assets=np.ones(5), 

91 ... lower_factors=-0.5 * np.ones(2), 

92 ... upper_factors=0.5 * np.ones(2) 

93 ... ) 

94 >>> w = np.array([0.2, 0.2, 0.2, 0.2, 0.2]) 

95 >>> risk = model.estimate(w) 

96 >>> isinstance(risk, float) 

97 True 

98 

99 Mathematical verification of risk decomposition: 

100 

101 >>> model = FactorModel(assets=3, k=2) 

102 >>> # Factor exposure: how much each asset is exposed to each factor 

103 >>> exposure = np.array([[1.0, 0.5, 0.0], # Market factor 

104 ... [0.0, 0.5, 1.0]]) # Sector factor 

105 >>> # Factor covariance (diagonal = uncorrelated factors) 

106 >>> factor_cov = np.array([[0.04, 0.0], # Market vol = 20% 

107 ... [0.0, 0.0225]]) # Sector vol = 15% 

108 >>> # Idiosyncratic risk per asset 

109 >>> idio = np.array([0.10, 0.12, 0.08]) 

110 >>> model.update( 

111 ... exposure=exposure, 

112 ... cov=factor_cov, 

113 ... idiosyncratic_risk=idio, 

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

115 ... upper_assets=np.ones(3), 

116 ... lower_factors=-np.ones(2), 

117 ... upper_factors=np.ones(2) 

118 ... ) 

119 >>> # Equal weight portfolio 

120 >>> w = np.array([1/3, 1/3, 1/3]) 

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

122 >>> # Manual: total_var = y^T @ cov @ y + sum((idio * w)^2) 

123 >>> y = exposure @ w # Factor exposures 

124 >>> systematic_var = y @ factor_cov @ y 

125 >>> idio_var = np.sum((idio * w)**2) 

126 >>> manual_risk = np.sqrt(systematic_var + idio_var) 

127 >>> bool(np.isclose(model_risk, manual_risk, rtol=1e-5)) 

128 True 

129 

130 Error handling for dimension violations: 

131 

132 >>> model = FactorModel(assets=3, k=2) 

133 >>> try: 

134 ... model.update( 

135 ... exposure=np.random.randn(5, 3), # 5 factors > k=2 

136 ... cov=np.eye(5), 

137 ... idiosyncratic_risk=np.ones(3), 

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

139 ... upper_assets=np.ones(3), 

140 ... lower_factors=-np.ones(5), 

141 ... upper_factors=np.ones(5) 

142 ... ) 

143 ... except ValueError as e: 

144 ... print("Caught:", str(e)) 

145 Caught: Too many factors 

146 

147 """ 

148 

149 assets: int = 0 

150 """Maximum number of assets in the portfolio.""" 

151 

152 k: int = 0 

153 """Maximum number of factors in the model.""" 

154 

155 def __post_init__(self) -> None: 

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

157 

158 Creates parameters for factor exposure, idiosyncratic risk, and the Cholesky 

159 decomposition of the factor covariance matrix. Also initializes bounds for 

160 both assets and factors. 

161 

162 Example: 

163 >>> from cvx.risk.factor import FactorModel 

164 >>> model = FactorModel(assets=10, k=3) 

165 >>> # Parameters are automatically created 

166 >>> model.parameter["exposure"].shape 

167 (3, 10) 

168 >>> model.parameter["idiosyncratic_risk"].shape 

169 10 

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

171 (3, 3) 

172 

173 """ 

174 self.parameter["exposure"] = Parameter( 

175 shape=(self.k, self.assets), 

176 name="exposure", 

177 ) 

178 

179 self.parameter["idiosyncratic_risk"] = Parameter(shape=self.assets, name="idiosyncratic risk") 

180 

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

182 shape=(self.k, self.k), 

183 name="cholesky of covariance", 

184 ) 

185 

186 self.bounds_assets = Bounds(m=self.assets, name="assets") 

187 self.bounds_factors = Bounds(m=self.k, name="factors") 

188 

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

190 """Compute the total portfolio risk using the factor model. 

191 

192 Combines systematic risk (from factor exposures) and idiosyncratic risk 

193 to calculate the total portfolio risk. The formula is: 

194 

195 risk = sqrt(||chol @ y||^2 + ||idio_risk * w||^2) 

196 

197 where y = exposure @ weights (factor exposures). 

198 

199 Args: 

200 weights: Numpy array representing portfolio weights. 

201 **kwargs: Additional keyword arguments, may include: 

202 - y: Factor exposures as a numpy array. If not provided, calculated 

203 as exposure @ weights. 

204 

205 Returns: 

206 Float representing the total portfolio risk. 

207 

208 Example: 

209 >>> import numpy as np 

210 >>> from cvx.risk.factor import FactorModel 

211 >>> model = FactorModel(assets=3, k=2) 

212 >>> model.update( 

213 ... exposure=np.array([[1.0, 0.5, 0.0], [0.0, 0.5, 1.0]]), 

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

215 ... idiosyncratic_risk=np.array([0.1, 0.1, 0.1]), 

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

217 ... upper_assets=np.ones(3), 

218 ... lower_factors=-np.ones(2), 

219 ... upper_factors=np.ones(2) 

220 ... ) 

221 >>> w = np.array([0.4, 0.3, 0.3]) 

222 >>> risk = model.estimate(w) 

223 >>> isinstance(risk, float) 

224 True 

225 

226 """ 

227 w = np.asarray(weights) 

228 y = np.asarray(kwargs.get("y", self.parameter["exposure"].value @ w)) 

229 

230 var_systematic = norm(self.parameter["chol"].value @ y) 

231 var_residual = norm(self.parameter["idiosyncratic_risk"].value * w) 

232 

233 return float(np.sqrt(var_systematic**2 + var_residual**2)) 

234 

235 @staticmethod 

236 def _require_inputs(kwargs: dict[str, Any]) -> tuple[np.ndarray, np.ndarray, np.ndarray]: 

237 """Return the ``exposure``, ``cov`` and ``idiosyncratic_risk`` arrays from ``kwargs``. 

238 

239 Raises: 

240 ValueError: If any of the three required arguments is missing. 

241 

242 """ 

243 missing = [key for key in ("exposure", "cov", "idiosyncratic_risk") if key not in kwargs] 

244 if missing: 

245 msg = f"update() missing required arguments: {', '.join(missing)}" 

246 raise ValueError(msg) 

247 return ( 

248 np.asarray(kwargs["exposure"]), 

249 np.asarray(kwargs["cov"]), 

250 np.asarray(kwargs["idiosyncratic_risk"]), 

251 ) 

252 

253 def _validate_shapes( 

254 self, 

255 exposure: np.ndarray, 

256 cov: np.ndarray, 

257 idiosyncratic_risk: np.ndarray, 

258 ) -> tuple[int, int]: 

259 """Check the input shapes against the model capacity and each other. 

260 

261 Returns: 

262 The active ``(num_factors, num_assets)`` read from ``exposure``. 

263 

264 Raises: 

265 ValueError: If the number of factors or assets exceeds the model 

266 capacity, or ``cov``/``idiosyncratic_risk`` are inconsistent 

267 with ``exposure``. 

268 

269 """ 

270 num_factors, num_assets = exposure.shape 

271 if num_factors > self.k: 

272 msg = "Too many factors" 

273 raise ValueError(msg) 

274 if num_assets > self.assets: 

275 msg = "Too many assets" 

276 raise ValueError(msg) 

277 if cov.shape != (num_factors, num_factors): 

278 msg = f"cov must have shape ({num_factors}, {num_factors}) to match exposure, got {cov.shape}" 

279 raise ValueError(msg) 

280 if idiosyncratic_risk.shape != (num_assets,): 

281 msg = ( 

282 f"idiosyncratic_risk must have shape ({num_assets},) to match exposure, got {idiosyncratic_risk.shape}" 

283 ) 

284 raise ValueError(msg) 

285 return num_factors, num_assets 

286 

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

288 """Update the factor model parameters. 

289 

290 Updates the factor exposure matrix, idiosyncratic risk vector, and 

291 factor covariance Cholesky decomposition. The input dimensions can 

292 be smaller than the maximum dimensions. 

293 

294 Args: 

295 **kwargs: Keyword arguments containing: 

296 - exposure: Factor exposure matrix (k x assets). 

297 - idiosyncratic_risk: Vector of idiosyncratic risks. 

298 - cov: Factor covariance matrix. 

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

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

301 - lower_factors: Array of lower bounds for factor exposures. 

302 - upper_factors: Array of upper bounds for factor exposures. 

303 

304 Raises: 

305 ValueError: If a required argument is missing, the number of 

306 factors or assets exceeds the maximum, or the shapes of 

307 ``cov`` and ``idiosyncratic_risk`` are inconsistent with 

308 ``exposure``. 

309 

310 Example: 

311 >>> import numpy as np 

312 >>> from cvx.risk.factor import FactorModel 

313 >>> model = FactorModel(assets=5, k=3) 

314 >>> # Update with 2 factors and 4 assets 

315 >>> model.update( 

316 ... exposure=np.random.randn(2, 4), 

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

318 ... idiosyncratic_risk=np.abs(np.random.randn(4)), 

319 ... lower_assets=np.zeros(4), 

320 ... upper_assets=np.ones(4), 

321 ... lower_factors=-np.ones(2), 

322 ... upper_factors=np.ones(2) 

323 ... ) 

324 

325 """ 

326 exposure, cov, idiosyncratic_risk = self._require_inputs(kwargs) 

327 num_factors, num_assets = self._validate_shapes(exposure, cov, idiosyncratic_risk) 

328 

329 self.parameter["exposure"].value = np.zeros((self.k, self.assets)) 

330 self.parameter["chol"].value = np.zeros((self.k, self.k)) 

331 self.parameter["idiosyncratic_risk"].value = np.zeros(self.assets) 

332 

333 self.parameter["exposure"].value[:num_factors, :num_assets] = exposure 

334 self.parameter["idiosyncratic_risk"].value[:num_assets] = idiosyncratic_risk 

335 self.parameter["chol"].value[:num_factors, :num_factors] = cholesky(cov) 

336 self.bounds_assets.update(**kwargs) 

337 self.bounds_factors.update(**kwargs) 

338 

339 def solve_minrisk( 

340 self, 

341 weights: Variable, 

342 base: np.ndarray, 

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

344 y_var: Variable | None = None, 

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

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

347 

348 Raises: 

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

350 capacity ``assets``. 

351 

352 """ 

353 n = weights.n 

354 if n != self.assets: 

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

356 raise ValueError(msg) 

357 k = self.k 

358 

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

360 exposure = self.parameter["exposure"].value 

361 idio = self.parameter["idiosyncratic_risk"].value 

362 

363 lb_w, ub_w = self.bounds_assets.get_bounds() 

364 lb_y, ub_y = self.bounds_factors.get_bounds() 

365 

366 # Variables: x = [t, w, y] with t bounding the total volatility 

367 # and y the factor exposures. 

368 w_cols = slice(1, 1 + n) 

369 y_cols = slice(1 + n, 1 + n + k) 

370 builder = ConeProgramBuilder(n_vars=1 + n + k) 

371 

372 # SOC: || [chol @ exposure @ (w - base); idio * (w - base)] ||_2 <= t 

373 # encoded via y = exposure @ w, so the systematic term is chol @ (y - exposure @ base). 

374 # Built directly in sparse form: the block has only O(n + k^2) nonzeros. 

375 soc_size = 1 + k + n 

376 a_soc = sparse.bmat( 

377 [ 

378 [sparse.csr_matrix(np.array([[-1.0]])), None, None], 

379 [None, None, sparse.csr_matrix(-chol)], 

380 [None, sparse.diags(-idio), None], 

381 ], 

382 format="csr", 

383 ) 

384 b_soc = np.zeros(soc_size) 

385 b_soc[1 : 1 + k] = -chol @ (exposure @ base) 

386 b_soc[1 + k :] = -idio * base 

387 builder.add(a_soc, b_soc, clarabel.SecondOrderConeT(soc_size)) 

388 

389 builder.add_sum_constraint(w_cols) 

390 

391 # Equality: y = exposure @ w 

392 a_exp = builder.block(k) 

393 a_exp[:, w_cols] = -exposure 

394 a_exp[:, y_cols] = np.eye(k) 

395 builder.add(a_exp, np.zeros(k), clarabel.ZeroConeT(k)) 

396 

397 builder.add_variable_bounds(w_cols, lb_w, ub_w) 

398 builder.add_variable_bounds(y_cols, lb_y, ub_y) 

399 builder.add_linear_constraints(extra_constraints, w_cols) 

400 

401 q = np.zeros(builder.n_vars) 

402 q[0] = 1.0 

403 return self._solve_and_unpack( 

404 builder, 

405 q, 

406 weights, 

407 w_cols, 

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

409 y_var=y_var, 

410 y_cols=y_cols, 

411 )