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
« 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.
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.
20Example:
21 Create a factor model and estimate portfolio risk:
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
48"""
50from __future__ import annotations
52from dataclasses import dataclass
53from typing import Any, cast
55import cvxpy as cvx
56import numpy as np
58from cvx.risk.bounds import Bounds
59from cvx.risk.linalg import cholesky
60from cvx.risk.model import Model
63@dataclass
64class FactorModel(Model):
65 """Factor risk model for portfolio optimization.
67 Factor models decompose portfolio risk into systematic risk (from factor
68 exposures) and idiosyncratic risk (residual risk). The total portfolio
69 variance is:
71 Var(w) = w' @ exposure' @ cov @ exposure @ w + sum((idio_risk * w)^2)
73 This implementation uses the Cholesky decomposition of the factor covariance
74 matrix for efficient risk computation.
76 Attributes:
77 assets: Maximum number of assets in the portfolio.
78 k: Maximum number of factors in the model.
80 Example:
81 Create and use a factor model:
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
109 Mathematical verification of risk decomposition:
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
140 The y parameter allows pre-computed factor exposures:
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
148 Error handling for dimension violations:
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
165 """
167 assets: int = 0
168 """Maximum number of assets in the portfolio."""
170 k: int = 0
171 """Maximum number of factors in the model."""
173 def __post_init__(self) -> None:
174 """Initialize the parameters after the class is instantiated.
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.
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)
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 )
198 self.parameter["idiosyncratic_risk"] = cvx.Parameter(
199 shape=self.assets, name="idiosyncratic risk", value=np.zeros(self.assets)
200 )
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 )
208 self.bounds_assets = Bounds(m=self.assets, name="assets")
209 self.bounds_factors = Bounds(m=self.k, name="factors")
211 def estimate(self, weights: cvx.Variable, **kwargs: Any) -> cvx.Expression:
212 """Compute the total portfolio risk using the factor model.
214 Combines systematic risk (from factor exposures) and idiosyncratic risk
215 to calculate the total portfolio risk. The formula is:
217 risk = sqrt(||chol @ y||^2 + ||idio_risk * w||^2)
219 where y = exposure @ weights (factor exposures).
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.
227 Returns:
228 CVXPY expression representing the total portfolio risk.
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
250 """
251 var_residual = cvx.norm2(cvx.multiply(self.parameter["idiosyncratic_risk"], weights))
253 y = kwargs.get("y", self.parameter["exposure"] @ weights)
255 return cast(
256 cvx.Expression,
257 cvx.norm2(cvx.vstack([cvx.norm2(self.parameter["chol"] @ y), var_residual])),
258 )
260 def update(self, **kwargs: Any) -> None:
261 """Update the factor model parameters.
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.
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.
277 Raises:
278 ValueError: If number of factors or assets exceeds maximum.
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 ... )
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)
300 # get the exposure
301 exposure = kwargs["exposure"]
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)
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)
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)
328 def constraints(self, weights: cvx.Variable, **kwargs: Any) -> list[cvx.Constraint]:
329 """Return constraints for the factor model.
331 Returns constraints including asset bounds, factor exposure bounds,
332 and the constraint that relates factor exposures to asset weights.
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.
340 Returns:
341 List of CVXPY constraints including asset bounds, factor bounds,
342 and the constraint that y equals exposure @ weights.
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
364 """
365 y = kwargs.get("y", self.parameter["exposure"] @ weights)
367 return (
368 self.bounds_assets.constraints(weights)
369 + self.bounds_factors.constraints(y)
370 + [y == self.parameter["exposure"] @ weights]
371 )