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
« 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.
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
54import cvxpy as cvx
55import numpy as np
57from cvx.risk.bounds import Bounds
58from cvx.risk.linalg import cholesky
59from cvx.risk.model import Model
62@dataclass
63class FactorModel(Model):
64 """Factor risk model for portfolio optimization.
66 Factor models decompose portfolio risk into systematic risk (from factor
67 exposures) and idiosyncratic risk (residual risk). The total portfolio
68 variance is:
70 Var(w) = w' @ exposure' @ cov @ exposure @ w + sum((idio_risk * w)^2)
72 This implementation uses the Cholesky decomposition of the factor covariance
73 matrix for efficient risk computation.
75 Attributes:
76 assets: Maximum number of assets in the portfolio.
77 k: Maximum number of factors in the model.
79 Example:
80 Create and use a factor model:
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
108 """
110 assets: int = 0
111 """Maximum number of assets in the portfolio."""
113 k: int = 0
114 """Maximum number of factors in the model."""
116 def __post_init__(self):
117 """Initialize the parameters after the class is instantiated.
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.
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)
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 )
141 self.parameter["idiosyncratic_risk"] = cvx.Parameter(
142 shape=self.assets, name="idiosyncratic risk", value=np.zeros(self.assets)
143 )
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 )
151 self.bounds_assets = Bounds(m=self.assets, name="assets")
152 self.bounds_factors = Bounds(m=self.k, name="factors")
154 def estimate(self, weights: cvx.Variable, **kwargs) -> cvx.Expression:
155 """Compute the total portfolio risk using the factor model.
157 Combines systematic risk (from factor exposures) and idiosyncratic risk
158 to calculate the total portfolio risk. The formula is:
160 risk = sqrt(||chol @ y||^2 + ||idio_risk * w||^2)
162 where y = exposure @ weights (factor exposures).
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.
170 Returns:
171 CVXPY expression representing the total portfolio risk.
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
193 """
194 var_residual = cvx.norm2(cvx.multiply(self.parameter["idiosyncratic_risk"], weights))
196 y = kwargs.get("y", self.parameter["exposure"] @ weights)
198 return cvx.norm2(cvx.vstack([cvx.norm2(self.parameter["chol"] @ y), var_residual]))
200 def update(self, **kwargs) -> None:
201 """Update the factor model parameters.
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.
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.
217 Raises:
218 ValueError: If number of factors or assets exceeds maximum.
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 ... )
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)
240 # get the exposure
241 exposure = kwargs["exposure"]
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")
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)
256 def constraints(self, weights: cvx.Variable, **kwargs) -> list[cvx.Constraint]:
257 """Return constraints for the factor model.
259 Returns constraints including asset bounds, factor exposure bounds,
260 and the constraint that relates factor exposures to asset weights.
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.
268 Returns:
269 List of CVXPY constraints including asset bounds, factor bounds,
270 and the constraint that y equals exposure @ weights.
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
292 """
293 y = kwargs.get("y", self.parameter["exposure"] @ weights)
295 return (
296 self.bounds_assets.constraints(weights)
297 + self.bounds_factors.constraints(y)
298 + [y == self.parameter["exposure"] @ weights]
299 )