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

53 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-09 03:39 +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 cvxpy as cp 

24 >>> import numpy as np 

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

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

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

28 >>> # Set up factor exposure and covariance 

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

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

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

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

33 >>> model.update( 

34 ... exposure=exposure, 

35 ... cov=factor_cov, 

36 ... idiosyncratic_risk=idio_risk, 

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

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

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

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

41 ... ) 

42 >>> # Model is ready for optimization 

43 >>> weights = cp.Variable(10) 

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

45 >>> isinstance(risk, cp.Expression) 

46 True 

47 

48""" 

49 

50from __future__ import annotations 

51 

52from dataclasses import dataclass 

53from typing import Any, cast 

54 

55import cvxpy as cvx 

56import numpy as np 

57 

58from cvx.risk.bounds import Bounds 

59from cvx.risk.linalg import cholesky 

60from cvx.risk.model import Model 

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 cvxpy as cp 

84 >>> import numpy as np 

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

86 >>> # Create model 

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

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

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

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

91 >>> # Factor covariance 

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

93 >>> # Idiosyncratic risk per asset 

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

95 >>> model.update( 

96 ... exposure=exposure, 

97 ... cov=factor_cov, 

98 ... idiosyncratic_risk=idio_risk, 

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

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

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

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

103 ... ) 

104 >>> weights = cp.Variable(5) 

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

106 >>> isinstance(risk, cp.Expression) 

107 True 

108 

109 Mathematical verification of risk decomposition: 

110 

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

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

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

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

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

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

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

118 >>> # Idiosyncratic risk per asset 

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

120 >>> model.update( 

121 ... exposure=exposure, 

122 ... cov=factor_cov, 

123 ... idiosyncratic_risk=idio, 

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

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

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

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

128 ... ) 

129 >>> # Equal weight portfolio 

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

131 >>> model_risk = model.estimate(w).value 

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

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

134 >>> systematic_var = y @ factor_cov @ y 

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

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

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

138 True 

139 

140 The y parameter allows pre-computed factor exposures: 

141 

142 >>> weights = cp.Variable(3) 

143 >>> y = cp.Variable(2) # Factor exposure variable 

144 >>> risk_with_y = model.estimate(weights, y=y) 

145 >>> isinstance(risk_with_y, cp.Expression) 

146 True 

147 

148 Error handling for dimension violations: 

149 

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

151 >>> try: 

152 ... model.update( 

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

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

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

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

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

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

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

160 ... ) 

161 ... except ValueError as e: 

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

163 Caught: Too many factors 

164 

165 """ 

166 

167 assets: int = 0 

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

169 

170 k: int = 0 

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

172 

173 def __post_init__(self) -> None: 

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

175 

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

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

178 both assets and factors. 

179 

180 Example: 

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

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

183 >>> # Parameters are automatically created 

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

185 (3, 10) 

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

187 (10,) 

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

189 (3, 3) 

190 

191 """ 

192 self.parameter["exposure"] = cvx.Parameter( 

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

194 name="exposure", 

195 value=np.zeros((self.k, self.assets)), 

196 ) 

197 

198 self.parameter["idiosyncratic_risk"] = cvx.Parameter( 

199 shape=self.assets, name="idiosyncratic risk", value=np.zeros(self.assets) 

200 ) 

201 

202 self.parameter["chol"] = cvx.Parameter( 

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

204 name="cholesky of covariance", 

205 value=np.zeros((self.k, self.k)), 

206 ) 

207 

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

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

210 

211 def estimate(self, weights: cvx.Variable, **kwargs: Any) -> cvx.Expression: 

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

213 

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

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

216 

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

218 

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

220 

221 Args: 

222 weights: CVXPY variable representing portfolio weights. 

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

224 - y: Factor exposures variable. If not provided, calculated 

225 as exposure @ weights. 

226 

227 Returns: 

228 CVXPY expression representing the total portfolio risk. 

229 

230 Example: 

231 >>> import cvxpy as cp 

232 >>> import numpy as np 

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

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

235 >>> model.update( 

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

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

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

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

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

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

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

243 ... ) 

244 >>> weights = cp.Variable(3) 

245 >>> y = cp.Variable(2) # Factor exposures 

246 >>> risk = model.estimate(weights, y=y) 

247 >>> isinstance(risk, cp.Expression) 

248 True 

249 

250 """ 

251 var_residual = cvx.norm2(cvx.multiply(self.parameter["idiosyncratic_risk"], weights)) 

252 

253 y = kwargs.get("y", self.parameter["exposure"] @ weights) 

254 

255 return cast( 

256 cvx.Expression, 

257 cvx.norm2(cvx.vstack([cvx.norm2(self.parameter["chol"] @ y), var_residual])), 

258 ) 

259 

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

261 """Update the factor model parameters. 

262 

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

264 factor covariance Cholesky decomposition. The input dimensions can 

265 be smaller than the maximum dimensions. 

266 

267 Args: 

268 **kwargs: Keyword arguments containing: 

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

270 - idiosyncratic_risk: Vector of idiosyncratic risks. 

271 - cov: Factor covariance matrix. 

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

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

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

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

276 

277 Raises: 

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

279 

280 Example: 

281 >>> import numpy as np 

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

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

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

285 >>> model.update( 

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

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

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

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

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

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

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

293 ... ) 

294 

295 """ 

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

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

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

299 

300 # get the exposure 

301 exposure = kwargs["exposure"] 

302 

303 # extract dimensions 

304 k, assets = exposure.shape 

305 if k > self.k: 

306 msg = "Too many factors" 

307 raise ValueError(msg) 

308 if assets > self.assets: 

309 msg = "Too many assets" 

310 raise ValueError(msg) 

311 

312 if self.parameter["exposure"].value is None: 

313 msg = "Parameter exposure value is not initialized" 

314 raise ValueError(msg) 

315 if self.parameter["idiosyncratic_risk"].value is None: 

316 msg = "Parameter idiosyncratic_risk value is not initialized" 

317 raise ValueError(msg) 

318 if self.parameter["chol"].value is None: 

319 msg = "Parameter chol value is not initialized" 

320 raise ValueError(msg) 

321 

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

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

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

325 self.bounds_assets.update(**kwargs) 

326 self.bounds_factors.update(**kwargs) 

327 

328 def constraints(self, weights: cvx.Variable, **kwargs: Any) -> list[cvx.Constraint]: 

329 """Return constraints for the factor model. 

330 

331 Returns constraints including asset bounds, factor exposure bounds, 

332 and the constraint that relates factor exposures to asset weights. 

333 

334 Args: 

335 weights: CVXPY variable representing portfolio weights. 

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

337 - y: Factor exposures variable. If not provided, calculated 

338 as exposure @ weights. 

339 

340 Returns: 

341 List of CVXPY constraints including asset bounds, factor bounds, 

342 and the constraint that y equals exposure @ weights. 

343 

344 Example: 

345 >>> import cvxpy as cp 

346 >>> import numpy as np 

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

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

349 >>> model.update( 

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

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

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

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

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

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

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

357 ... ) 

358 >>> weights = cp.Variable(3) 

359 >>> y = cp.Variable(2) 

360 >>> constraints = model.constraints(weights, y=y) 

361 >>> len(constraints) == 5 # 2 asset bounds + 2 factor bounds + 1 exposure 

362 True 

363 

364 """ 

365 y = kwargs.get("y", self.parameter["exposure"] @ weights) 

366 

367 return ( 

368 self.bounds_assets.constraints(weights) 

369 + self.bounds_factors.constraints(y) 

370 + [y == self.parameter["exposure"] @ weights] 

371 )