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

131 statements  

« prev     ^ index     » next       coverage.py v7.14.0, created at 2026-05-13 06:46 +0000

1# Copyright 2023 Stanford University Convex Optimization Group 

2# 

3# Licensed under the Apache License, Version 2.0 (the "License"); 

4# you may not use this file except in compliance with the License. 

5# You may obtain a copy of the License at 

6# 

7# http://www.apache.org/licenses/LICENSE-2.0 

8# 

9# Unless required by applicable law or agreed to in writing, software 

10# distributed under the License is distributed on an "AS IS" BASIS, 

11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 

12# See the License for the specific language governing permissions and 

13# limitations under the License. 

14"""Factor risk model. 

15 

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

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

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

19 

20Example: 

21 Create a factor model and estimate portfolio risk: 

22 

23 >>> import numpy as np 

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

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

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

27 >>> # Set up factor exposure and covariance 

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

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

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

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

32 >>> model.update( 

33 ... exposure=exposure, 

34 ... cov=factor_cov, 

35 ... idiosyncratic_risk=idio_risk, 

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

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

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

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

40 ... ) 

41 >>> # Model is ready for optimization 

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

43 >>> w[:5] = 0.2 

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

45 >>> isinstance(risk, float) 

46 True 

47 

48""" 

49 

50from __future__ import annotations 

51 

52from dataclasses import dataclass 

53from typing import Any 

54 

55import clarabel 

56import numpy as np 

57from cvx.linalg import cholesky 

58from scipy import sparse 

59 

60from cvx.core import Bounds, Model, Parameter, Variable 

61 

62 

63@dataclass 

64class FactorModel(Model): 

65 """Factor risk model for portfolio optimization. 

66 

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

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

69 variance is: 

70 

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

72 

73 This implementation uses the Cholesky decomposition of the factor covariance 

74 matrix for efficient risk computation. 

75 

76 Attributes: 

77 assets: Maximum number of assets in the portfolio. 

78 k: Maximum number of factors in the model. 

79 

80 Example: 

81 Create and use a factor model: 

82 

83 >>> import numpy as np 

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

85 >>> # Create model 

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

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

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

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

90 >>> # Factor covariance 

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

92 >>> # Idiosyncratic risk per asset 

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

94 >>> model.update( 

95 ... exposure=exposure, 

96 ... cov=factor_cov, 

97 ... idiosyncratic_risk=idio_risk, 

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

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

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

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

102 ... ) 

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

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

105 >>> isinstance(risk, float) 

106 True 

107 

108 Mathematical verification of risk decomposition: 

109 

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

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

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

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

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

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

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

117 >>> # Idiosyncratic risk per asset 

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

119 >>> model.update( 

120 ... exposure=exposure, 

121 ... cov=factor_cov, 

122 ... idiosyncratic_risk=idio, 

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

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

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

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

127 ... ) 

128 >>> # Equal weight portfolio 

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

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

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

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

133 >>> systematic_var = y @ factor_cov @ y 

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

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

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

137 True 

138 

139 Error handling for dimension violations: 

140 

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

142 >>> try: 

143 ... model.update( 

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

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

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

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

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

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

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

151 ... ) 

152 ... except ValueError as e: 

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

154 Caught: Too many factors 

155 

156 """ 

157 

158 assets: int = 0 

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

160 

161 k: int = 0 

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

163 

164 def __post_init__(self) -> None: 

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

166 

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

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

169 both assets and factors. 

170 

171 Example: 

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

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

174 >>> # Parameters are automatically created 

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

176 (3, 10) 

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

178 10 

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

180 (3, 3) 

181 

182 """ 

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

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

185 name="exposure", 

186 ) 

187 

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

189 

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

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

192 name="cholesky of covariance", 

193 ) 

194 

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

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

197 

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

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

200 

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

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

203 

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

205 

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

207 

208 Args: 

209 weights: Numpy array representing portfolio weights. 

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

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

212 as exposure @ weights. 

213 

214 Returns: 

215 Float representing the total portfolio risk. 

216 

217 Example: 

218 >>> import numpy as np 

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

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

221 >>> model.update( 

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

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

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

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

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

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

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

229 ... ) 

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

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

232 >>> isinstance(risk, float) 

233 True 

234 

235 """ 

236 w = np.asarray(weights) 

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

238 

239 var_systematic = np.linalg.norm(self.parameter["chol"].value @ y) 

240 var_residual = np.linalg.norm(self.parameter["idiosyncratic_risk"].value * w) 

241 

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

243 

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

245 """Update the factor model parameters. 

246 

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

248 factor covariance Cholesky decomposition. The input dimensions can 

249 be smaller than the maximum dimensions. 

250 

251 Args: 

252 **kwargs: Keyword arguments containing: 

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

254 - idiosyncratic_risk: Vector of idiosyncratic risks. 

255 - cov: Factor covariance matrix. 

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

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

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

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

260 

261 Raises: 

262 ValueError: If number of factors or assets exceeds maximum. 

263 

264 Example: 

265 >>> import numpy as np 

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

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

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

269 >>> model.update( 

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

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

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

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

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

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

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

277 ... ) 

278 

279 """ 

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

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

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

283 

284 # get the exposure 

285 exposure = kwargs["exposure"] 

286 

287 # extract dimensions 

288 k, assets = exposure.shape 

289 if k > self.k: 

290 msg = "Too many factors" 

291 raise ValueError(msg) 

292 if assets > self.assets: 

293 msg = "Too many assets" 

294 raise ValueError(msg) 

295 

296 self.parameter["exposure"].value[:k, :assets] = kwargs["exposure"] 

297 self.parameter["idiosyncratic_risk"].value[:assets] = kwargs["idiosyncratic_risk"] 

298 self.parameter["chol"].value[:k, :k] = cholesky(kwargs["cov"]) 

299 self.bounds_assets.update(**kwargs) 

300 self.bounds_factors.update(**kwargs) 

301 

302 def solve_minrisk( 

303 self, 

304 weights: Variable, 

305 base: np.ndarray, 

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

307 y_var: Variable | None = None, 

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

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

310 n = weights.n 

311 k = self.k 

312 

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

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

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

316 

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

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

319 

320 n_vars = 1 + n + k 

321 P = sparse.csc_matrix((n_vars, n_vars)) # noqa: N806 

322 q = np.zeros(n_vars) 

323 q[0] = 1.0 

324 

325 A_rows: list[np.ndarray] = [] # noqa: N806 

326 b_rows: list[np.ndarray] = [] 

327 cones: list[Any] = [] 

328 

329 soc_size = 1 + k + n 

330 A_soc = np.zeros((soc_size, n_vars)) # noqa: N806 

331 A_soc[0, 0] = -1.0 

332 A_soc[1 : 1 + k, 1 + n :] = -chol 

333 A_soc[1 + k :, 1 : 1 + n] = -np.diag(idio) 

334 b_soc = np.zeros(soc_size) 

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

336 A_rows.append(A_soc) 

337 b_rows.append(b_soc) 

338 cones.append(clarabel.SecondOrderConeT(soc_size)) 

339 

340 A_eq_sum = np.zeros((1, n_vars)) # noqa: N806 

341 A_eq_sum[0, 1 : 1 + n] = 1.0 

342 A_rows.append(A_eq_sum) 

343 b_rows.append(np.array([1.0])) 

344 cones.append(clarabel.ZeroConeT(1)) 

345 

346 A_eq_exp = np.zeros((k, n_vars)) # noqa: N806 

347 A_eq_exp[:, 1 : 1 + n] = -exposure 

348 A_eq_exp[:, 1 + n :] = np.eye(k) 

349 A_rows.append(A_eq_exp) 

350 b_rows.append(np.zeros(k)) 

351 cones.append(clarabel.ZeroConeT(k)) 

352 

353 A_wlb = np.zeros((n, n_vars)) # noqa: N806 

354 A_wlb[:, 1 : 1 + n] = -np.eye(n) 

355 A_rows.append(A_wlb) 

356 b_rows.append(-lb_w) 

357 cones.append(clarabel.NonnegativeConeT(n)) 

358 

359 A_wub = np.zeros((n, n_vars)) # noqa: N806 

360 A_wub[:, 1 : 1 + n] = np.eye(n) 

361 A_rows.append(A_wub) 

362 b_rows.append(ub_w) 

363 cones.append(clarabel.NonnegativeConeT(n)) 

364 

365 A_ylb = np.zeros((k, n_vars)) # noqa: N806 

366 A_ylb[:, 1 + n :] = -np.eye(k) 

367 A_rows.append(A_ylb) 

368 b_rows.append(-lb_y) 

369 cones.append(clarabel.NonnegativeConeT(k)) 

370 

371 A_yub = np.zeros((k, n_vars)) # noqa: N806 

372 A_yub[:, 1 + n :] = np.eye(k) 

373 A_rows.append(A_yub) 

374 b_rows.append(ub_y) 

375 cones.append(clarabel.NonnegativeConeT(k)) 

376 

377 for a, lb_val, ub_val in extra_constraints: 

378 a = np.asarray(a) 

379 if lb_val is not None and ub_val is not None and lb_val == ub_val: 

380 A_extra = np.zeros((1, n_vars)) # noqa: N806 

381 A_extra[0, 1 : 1 + n] = a 

382 A_rows.append(A_extra) 

383 b_rows.append(np.array([lb_val])) 

384 cones.append(clarabel.ZeroConeT(1)) 

385 else: 

386 if lb_val is not None: 

387 A_extra = np.zeros((1, n_vars)) # noqa: N806 

388 A_extra[0, 1 : 1 + n] = -a 

389 A_rows.append(A_extra) 

390 b_rows.append(np.array([-lb_val])) 

391 cones.append(clarabel.NonnegativeConeT(1)) 

392 if ub_val is not None: 

393 A_extra = np.zeros((1, n_vars)) # noqa: N806 

394 A_extra[0, 1 : 1 + n] = a 

395 A_rows.append(A_extra) 

396 b_rows.append(np.array([ub_val])) 

397 cones.append(clarabel.NonnegativeConeT(1)) 

398 

399 A = sparse.csc_matrix(np.vstack(A_rows)) # noqa: N806 

400 b = np.concatenate(b_rows) 

401 

402 settings = clarabel.DefaultSettings() 

403 settings.verbose = False 

404 sol = clarabel.DefaultSolver(P, q, A, b, cones, settings).solve() 

405 status = str(sol.status) 

406 

407 if "Solved" in status: 

408 weights.value = np.array(sol.x[1 : 1 + n]) 

409 if y_var is not None: 

410 y_var.value = np.array(sol.x[1 + n : 1 + n + k]) 

411 return float(sol.obj_val), float(sol.x[0]), status 

412 return None, None, status