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

41 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2025-12-15 12:21 +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 

53 

54import cvxpy as cvx 

55import numpy as np 

56 

57from cvx.risk.bounds import Bounds 

58from cvx.risk.linalg import cholesky 

59from cvx.risk.model import Model 

60 

61 

62@dataclass 

63class FactorModel(Model): 

64 """Factor risk model for portfolio optimization. 

65 

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

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

68 variance is: 

69 

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

71 

72 This implementation uses the Cholesky decomposition of the factor covariance 

73 matrix for efficient risk computation. 

74 

75 Attributes: 

76 assets: Maximum number of assets in the portfolio. 

77 k: Maximum number of factors in the model. 

78 

79 Example: 

80 Create and use a factor model: 

81 

82 >>> import cvxpy as cp 

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 >>> weights = cp.Variable(5) 

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

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

106 True 

107 

108 """ 

109 

110 assets: int = 0 

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

112 

113 k: int = 0 

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

115 

116 def __post_init__(self): 

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

118 

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

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

121 both assets and factors. 

122 

123 Example: 

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

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

126 >>> # Parameters are automatically created 

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

128 (3, 10) 

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

130 (10,) 

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

132 (3, 3) 

133 

134 """ 

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

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

137 name="exposure", 

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

139 ) 

140 

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

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

143 ) 

144 

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

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

147 name="cholesky of covariance", 

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

149 ) 

150 

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

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

153 

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

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

156 

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

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

159 

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

161 

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

163 

164 Args: 

165 weights: CVXPY variable representing portfolio weights. 

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

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

168 as exposure @ weights. 

169 

170 Returns: 

171 CVXPY expression representing the total portfolio risk. 

172 

173 Example: 

174 >>> import cvxpy as cp 

175 >>> import numpy as np 

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

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

178 >>> model.update( 

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

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

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

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

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

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

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

186 ... ) 

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

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

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

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

191 True 

192 

193 """ 

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

195 

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

197 

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

199 

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

201 """Update the factor model parameters. 

202 

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

204 factor covariance Cholesky decomposition. The input dimensions can 

205 be smaller than the maximum dimensions. 

206 

207 Args: 

208 **kwargs: Keyword arguments containing: 

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

210 - idiosyncratic_risk: Vector of idiosyncratic risks. 

211 - cov: Factor covariance matrix. 

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

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

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

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

216 

217 Raises: 

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

219 

220 Example: 

221 >>> import numpy as np 

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

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

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

225 >>> model.update( 

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

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

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

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

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

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

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

233 ... ) 

234 

235 """ 

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

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

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

239 

240 # get the exposure 

241 exposure = kwargs["exposure"] 

242 

243 # extract dimensions 

244 k, assets = exposure.shape 

245 if k > self.k: 

246 raise ValueError("Number of factors exceeds maximal number of factors") 

247 if assets > self.assets: 

248 raise ValueError("Number of assets exceeds maximal number of assets") 

249 

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

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

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

253 self.bounds_assets.update(**kwargs) 

254 self.bounds_factors.update(**kwargs) 

255 

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

257 """Return constraints for the factor model. 

258 

259 Returns constraints including asset bounds, factor exposure bounds, 

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

261 

262 Args: 

263 weights: CVXPY variable representing portfolio weights. 

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

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

266 as exposure @ weights. 

267 

268 Returns: 

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

270 and the constraint that y equals exposure @ weights. 

271 

272 Example: 

273 >>> import cvxpy as cp 

274 >>> import numpy as np 

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

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

277 >>> model.update( 

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

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

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

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

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

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

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

285 ... ) 

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

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

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

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

290 True 

291 

292 """ 

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

294 

295 return ( 

296 self.bounds_assets.constraints(weights) 

297 + self.bounds_factors.constraints(y) 

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

299 )