Coverage for src/cvx/risk/factor/factor.py: 100%
90 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-07-22 10:34 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-07-22 10:34 +0000
1# Copyright (c) 2025 Jebel Quant Research
2#
3# Licensed under the MIT License. See the LICENSE file in the project root
4# for the full license text.
5"""Factor risk model.
7This module provides the FactorModel class, which implements a factor-based
8risk model for portfolio optimization. Factor models decompose portfolio risk
9into systematic (factor) risk and idiosyncratic (residual) risk.
11Example:
12 Create a factor model and estimate portfolio risk:
14 >>> import numpy as np
15 >>> from cvx.risk.factor import FactorModel
16 >>> # Create factor model with 10 assets and 3 factors
17 >>> model = FactorModel(assets=10, k=3)
18 >>> # Set up factor exposure and covariance
19 >>> np.random.seed(42)
20 >>> exposure = np.random.randn(3, 10) # 3 factors x 10 assets
21 >>> factor_cov = np.eye(3) # Factor covariance matrix
22 >>> idio_risk = np.abs(np.random.randn(10)) # Idiosyncratic risk
23 >>> model.update(
24 ... exposure=exposure,
25 ... cov=factor_cov,
26 ... idiosyncratic_risk=idio_risk,
27 ... lower_assets=np.zeros(10),
28 ... upper_assets=np.ones(10),
29 ... lower_factors=-0.1 * np.ones(3),
30 ... upper_factors=0.1 * np.ones(3)
31 ... )
32 >>> # Model is ready for optimization
33 >>> w = np.zeros(10)
34 >>> w[:5] = 0.2
35 >>> risk = model.estimate(w)
36 >>> isinstance(risk, float)
37 True
39"""
41from __future__ import annotations
43from dataclasses import dataclass
44from typing import Any
46import clarabel
47import numpy as np
48from cvx.linalg import cholesky, norm
49from scipy import sparse
51from cvx.core import Bounds, ConeProgramBuilder, Model, Parameter, Variable
54@dataclass
55class FactorModel(Model):
56 """Factor risk model for portfolio optimization.
58 Factor models decompose portfolio risk into systematic risk (from factor
59 exposures) and idiosyncratic risk (residual risk). The total portfolio
60 variance is:
62 Var(w) = w' @ exposure' @ cov @ exposure @ w + sum((idio_risk * w)^2)
64 This implementation uses the Cholesky decomposition of the factor covariance
65 matrix for efficient risk computation.
67 Attributes:
68 assets: Maximum number of assets in the portfolio.
69 k: Maximum number of factors in the model.
71 Example:
72 Create and use a factor model:
74 >>> import numpy as np
75 >>> from cvx.risk.factor import FactorModel
76 >>> # Create model
77 >>> model = FactorModel(assets=5, k=2)
78 >>> # Factor exposure: 2 factors x 5 assets
79 >>> exposure = np.array([[1.0, 0.8, 0.6, 0.4, 0.2],
80 ... [0.2, 0.4, 0.6, 0.8, 1.0]])
81 >>> # Factor covariance
82 >>> factor_cov = np.array([[1.0, 0.3], [0.3, 1.0]])
83 >>> # Idiosyncratic risk per asset
84 >>> idio_risk = np.array([0.1, 0.1, 0.1, 0.1, 0.1])
85 >>> model.update(
86 ... exposure=exposure,
87 ... cov=factor_cov,
88 ... idiosyncratic_risk=idio_risk,
89 ... lower_assets=np.zeros(5),
90 ... upper_assets=np.ones(5),
91 ... lower_factors=-0.5 * np.ones(2),
92 ... upper_factors=0.5 * np.ones(2)
93 ... )
94 >>> w = np.array([0.2, 0.2, 0.2, 0.2, 0.2])
95 >>> risk = model.estimate(w)
96 >>> isinstance(risk, float)
97 True
99 Mathematical verification of risk decomposition:
101 >>> model = FactorModel(assets=3, k=2)
102 >>> # Factor exposure: how much each asset is exposed to each factor
103 >>> exposure = np.array([[1.0, 0.5, 0.0], # Market factor
104 ... [0.0, 0.5, 1.0]]) # Sector factor
105 >>> # Factor covariance (diagonal = uncorrelated factors)
106 >>> factor_cov = np.array([[0.04, 0.0], # Market vol = 20%
107 ... [0.0, 0.0225]]) # Sector vol = 15%
108 >>> # Idiosyncratic risk per asset
109 >>> idio = np.array([0.10, 0.12, 0.08])
110 >>> model.update(
111 ... exposure=exposure,
112 ... cov=factor_cov,
113 ... idiosyncratic_risk=idio,
114 ... lower_assets=np.zeros(3),
115 ... upper_assets=np.ones(3),
116 ... lower_factors=-np.ones(2),
117 ... upper_factors=np.ones(2)
118 ... )
119 >>> # Equal weight portfolio
120 >>> w = np.array([1/3, 1/3, 1/3])
121 >>> model_risk = model.estimate(w)
122 >>> # Manual: total_var = y^T @ cov @ y + sum((idio * w)^2)
123 >>> y = exposure @ w # Factor exposures
124 >>> systematic_var = y @ factor_cov @ y
125 >>> idio_var = np.sum((idio * w)**2)
126 >>> manual_risk = np.sqrt(systematic_var + idio_var)
127 >>> bool(np.isclose(model_risk, manual_risk, rtol=1e-5))
128 True
130 Error handling for dimension violations:
132 >>> model = FactorModel(assets=3, k=2)
133 >>> try:
134 ... model.update(
135 ... exposure=np.random.randn(5, 3), # 5 factors > k=2
136 ... cov=np.eye(5),
137 ... idiosyncratic_risk=np.ones(3),
138 ... lower_assets=np.zeros(3),
139 ... upper_assets=np.ones(3),
140 ... lower_factors=-np.ones(5),
141 ... upper_factors=np.ones(5)
142 ... )
143 ... except ValueError as e:
144 ... print("Caught:", str(e))
145 Caught: Too many factors
147 """
149 assets: int = 0
150 """Maximum number of assets in the portfolio."""
152 k: int = 0
153 """Maximum number of factors in the model."""
155 def __post_init__(self) -> None:
156 """Initialize the parameters after the class is instantiated.
158 Creates parameters for factor exposure, idiosyncratic risk, and the Cholesky
159 decomposition of the factor covariance matrix. Also initializes bounds for
160 both assets and factors.
162 Example:
163 >>> from cvx.risk.factor import FactorModel
164 >>> model = FactorModel(assets=10, k=3)
165 >>> # Parameters are automatically created
166 >>> model.parameter["exposure"].shape
167 (3, 10)
168 >>> model.parameter["idiosyncratic_risk"].shape
169 10
170 >>> model.parameter["chol"].shape
171 (3, 3)
173 """
174 self.parameter["exposure"] = Parameter(
175 shape=(self.k, self.assets),
176 name="exposure",
177 )
179 self.parameter["idiosyncratic_risk"] = Parameter(shape=self.assets, name="idiosyncratic risk")
181 self.parameter["chol"] = Parameter(
182 shape=(self.k, self.k),
183 name="cholesky of covariance",
184 )
186 self.bounds_assets = Bounds(m=self.assets, name="assets")
187 self.bounds_factors = Bounds(m=self.k, name="factors")
189 def estimate(self, weights: np.ndarray, **kwargs: Any) -> float:
190 """Compute the total portfolio risk using the factor model.
192 Combines systematic risk (from factor exposures) and idiosyncratic risk
193 to calculate the total portfolio risk. The formula is:
195 risk = sqrt(||chol @ y||^2 + ||idio_risk * w||^2)
197 where y = exposure @ weights (factor exposures).
199 Args:
200 weights: Numpy array representing portfolio weights.
201 **kwargs: Additional keyword arguments, may include:
202 - y: Factor exposures as a numpy array. If not provided, calculated
203 as exposure @ weights.
205 Returns:
206 Float representing the total portfolio risk.
208 Example:
209 >>> import numpy as np
210 >>> from cvx.risk.factor import FactorModel
211 >>> model = FactorModel(assets=3, k=2)
212 >>> model.update(
213 ... exposure=np.array([[1.0, 0.5, 0.0], [0.0, 0.5, 1.0]]),
214 ... cov=np.eye(2),
215 ... idiosyncratic_risk=np.array([0.1, 0.1, 0.1]),
216 ... lower_assets=np.zeros(3),
217 ... upper_assets=np.ones(3),
218 ... lower_factors=-np.ones(2),
219 ... upper_factors=np.ones(2)
220 ... )
221 >>> w = np.array([0.4, 0.3, 0.3])
222 >>> risk = model.estimate(w)
223 >>> isinstance(risk, float)
224 True
226 """
227 w = np.asarray(weights)
228 y = np.asarray(kwargs.get("y", self.parameter["exposure"].value @ w))
230 var_systematic = norm(self.parameter["chol"].value @ y)
231 var_residual = norm(self.parameter["idiosyncratic_risk"].value * w)
233 return float(np.sqrt(var_systematic**2 + var_residual**2))
235 @staticmethod
236 def _require_inputs(kwargs: dict[str, Any]) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
237 """Return the ``exposure``, ``cov`` and ``idiosyncratic_risk`` arrays from ``kwargs``.
239 Raises:
240 ValueError: If any of the three required arguments is missing.
242 """
243 missing = [key for key in ("exposure", "cov", "idiosyncratic_risk") if key not in kwargs]
244 if missing:
245 msg = f"update() missing required arguments: {', '.join(missing)}"
246 raise ValueError(msg)
247 return (
248 np.asarray(kwargs["exposure"]),
249 np.asarray(kwargs["cov"]),
250 np.asarray(kwargs["idiosyncratic_risk"]),
251 )
253 def _validate_shapes(
254 self,
255 exposure: np.ndarray,
256 cov: np.ndarray,
257 idiosyncratic_risk: np.ndarray,
258 ) -> tuple[int, int]:
259 """Check the input shapes against the model capacity and each other.
261 Returns:
262 The active ``(num_factors, num_assets)`` read from ``exposure``.
264 Raises:
265 ValueError: If the number of factors or assets exceeds the model
266 capacity, or ``cov``/``idiosyncratic_risk`` are inconsistent
267 with ``exposure``.
269 """
270 num_factors, num_assets = exposure.shape
271 if num_factors > self.k:
272 msg = "Too many factors"
273 raise ValueError(msg)
274 if num_assets > self.assets:
275 msg = "Too many assets"
276 raise ValueError(msg)
277 if cov.shape != (num_factors, num_factors):
278 msg = f"cov must have shape ({num_factors}, {num_factors}) to match exposure, got {cov.shape}"
279 raise ValueError(msg)
280 if idiosyncratic_risk.shape != (num_assets,):
281 msg = (
282 f"idiosyncratic_risk must have shape ({num_assets},) to match exposure, got {idiosyncratic_risk.shape}"
283 )
284 raise ValueError(msg)
285 return num_factors, num_assets
287 def update(self, **kwargs: Any) -> None:
288 """Update the factor model parameters.
290 Updates the factor exposure matrix, idiosyncratic risk vector, and
291 factor covariance Cholesky decomposition. The input dimensions can
292 be smaller than the maximum dimensions.
294 Args:
295 **kwargs: Keyword arguments containing:
296 - exposure: Factor exposure matrix (k x assets).
297 - idiosyncratic_risk: Vector of idiosyncratic risks.
298 - cov: Factor covariance matrix.
299 - lower_assets: Array of lower bounds for asset weights.
300 - upper_assets: Array of upper bounds for asset weights.
301 - lower_factors: Array of lower bounds for factor exposures.
302 - upper_factors: Array of upper bounds for factor exposures.
304 Raises:
305 ValueError: If a required argument is missing, the number of
306 factors or assets exceeds the maximum, or the shapes of
307 ``cov`` and ``idiosyncratic_risk`` are inconsistent with
308 ``exposure``.
310 Example:
311 >>> import numpy as np
312 >>> from cvx.risk.factor import FactorModel
313 >>> model = FactorModel(assets=5, k=3)
314 >>> # Update with 2 factors and 4 assets
315 >>> model.update(
316 ... exposure=np.random.randn(2, 4),
317 ... cov=np.eye(2),
318 ... idiosyncratic_risk=np.abs(np.random.randn(4)),
319 ... lower_assets=np.zeros(4),
320 ... upper_assets=np.ones(4),
321 ... lower_factors=-np.ones(2),
322 ... upper_factors=np.ones(2)
323 ... )
325 """
326 exposure, cov, idiosyncratic_risk = self._require_inputs(kwargs)
327 num_factors, num_assets = self._validate_shapes(exposure, cov, idiosyncratic_risk)
329 self.parameter["exposure"].value = np.zeros((self.k, self.assets))
330 self.parameter["chol"].value = np.zeros((self.k, self.k))
331 self.parameter["idiosyncratic_risk"].value = np.zeros(self.assets)
333 self.parameter["exposure"].value[:num_factors, :num_assets] = exposure
334 self.parameter["idiosyncratic_risk"].value[:num_assets] = idiosyncratic_risk
335 self.parameter["chol"].value[:num_factors, :num_factors] = cholesky(cov)
336 self.bounds_assets.update(**kwargs)
337 self.bounds_factors.update(**kwargs)
339 def solve_minrisk(
340 self,
341 weights: Variable,
342 base: np.ndarray,
343 extra_constraints: list[tuple[np.ndarray, float | None, float | None]],
344 y_var: Variable | None = None,
345 ) -> tuple[float | None, float | None, str]:
346 """Build and solve the Clarabel SOC problem for this model.
348 Raises:
349 ValueError: If the weights dimension does not match the model
350 capacity ``assets``.
352 """
353 n = weights.n
354 if n != self.assets:
355 msg = f"weights has dimension {n} but the model capacity is assets={self.assets}"
356 raise ValueError(msg)
357 k = self.k
359 chol = self.parameter["chol"].value
360 exposure = self.parameter["exposure"].value
361 idio = self.parameter["idiosyncratic_risk"].value
363 lb_w, ub_w = self.bounds_assets.get_bounds()
364 lb_y, ub_y = self.bounds_factors.get_bounds()
366 # Variables: x = [t, w, y] with t bounding the total volatility
367 # and y the factor exposures.
368 w_cols = slice(1, 1 + n)
369 y_cols = slice(1 + n, 1 + n + k)
370 builder = ConeProgramBuilder(n_vars=1 + n + k)
372 # SOC: || [chol @ exposure @ (w - base); idio * (w - base)] ||_2 <= t
373 # encoded via y = exposure @ w, so the systematic term is chol @ (y - exposure @ base).
374 # Built directly in sparse form: the block has only O(n + k^2) nonzeros.
375 soc_size = 1 + k + n
376 a_soc = sparse.bmat(
377 [
378 [sparse.csr_matrix(np.array([[-1.0]])), None, None],
379 [None, None, sparse.csr_matrix(-chol)],
380 [None, sparse.diags(-idio), None],
381 ],
382 format="csr",
383 )
384 b_soc = np.zeros(soc_size)
385 b_soc[1 : 1 + k] = -chol @ (exposure @ base)
386 b_soc[1 + k :] = -idio * base
387 builder.add(a_soc, b_soc, clarabel.SecondOrderConeT(soc_size))
389 builder.add_sum_constraint(w_cols)
391 # Equality: y = exposure @ w
392 a_exp = builder.block(k)
393 a_exp[:, w_cols] = -exposure
394 a_exp[:, y_cols] = np.eye(k)
395 builder.add(a_exp, np.zeros(k), clarabel.ZeroConeT(k))
397 builder.add_variable_bounds(w_cols, lb_w, ub_w)
398 builder.add_variable_bounds(y_cols, lb_y, ub_y)
399 builder.add_linear_constraints(extra_constraints, w_cols)
401 q = np.zeros(builder.n_vars)
402 q[0] = 1.0
403 return self._solve_and_unpack(
404 builder,
405 q,
406 weights,
407 w_cols,
408 lambda sol: (float(sol.obj_val), float(sol.x[0])),
409 y_var=y_var,
410 y_cols=y_cols,
411 )