Coverage for src/cvxmarkowitz/risk/factor/factor.py: 100%
54 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-15 05:21 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-15 05: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."""
16from __future__ import annotations
18from dataclasses import dataclass
20import cvxpy as cp
21import numpy as np
23from cvxmarkowitz.cvxerror import CvxDataError
24from cvxmarkowitz.model import Model
25from cvxmarkowitz.names import DataNames as D
26from cvxmarkowitz.types import Constraints, Dimensions, Matrix, Variables
27from cvxmarkowitz.utils.fill import fill_matrix, fill_vector
30@dataclass(frozen=True)
31class FactorModel(Model):
32 """Factor risk model."""
34 factors: int = 0
36 def __post_init__(self) -> None:
37 """Initialize parameters that define the factor risk model."""
38 self.data[D.EXPOSURE] = cp.Parameter(
39 shape=(self.factors, self.assets),
40 name=D.EXPOSURE,
41 value=np.zeros((self.factors, self.assets)),
42 )
44 self.data[D.IDIOSYNCRATIC_VOLA] = cp.Parameter(
45 shape=self.assets,
46 name=D.IDIOSYNCRATIC_VOLA,
47 value=np.zeros(self.assets),
48 )
50 self.data[D.CHOLESKY] = cp.Parameter(
51 shape=(self.factors, self.factors),
52 name=D.CHOLESKY,
53 value=np.zeros((self.factors, self.factors)),
54 )
56 self.data[D.SYSTEMATIC_VOLA_UNCERTAINTY] = cp.Parameter(
57 shape=self.factors,
58 name=D.SYSTEMATIC_VOLA_UNCERTAINTY,
59 value=np.zeros(self.factors),
60 nonneg=True,
61 )
63 self.data[D.IDIOSYNCRATIC_VOLA_UNCERTAINTY] = cp.Parameter(
64 shape=self.assets,
65 name=D.IDIOSYNCRATIC_VOLA_UNCERTAINTY,
66 value=np.zeros(self.assets),
67 nonneg=True,
68 )
70 def estimate(self, variables: Variables) -> cp.Expression:
71 """Compute the total risk as the norm of its systematic and residual parts.
73 The two parts are available on their own via `systematic_risk` and
74 `residual_risk`; this is their Euclidean combination.
75 """
76 var_residual = self.residual_risk(variables)
77 var_systematic = self.systematic_risk(variables)
79 return cp.norm2(cp.vstack([var_systematic, var_residual]))
81 def residual_risk(self, variables: Variables) -> cp.Expression:
82 """Return the asset-specific (idiosyncratic) part of the risk.
84 The L2 norm of the idiosyncratic volatility contribution and its
85 uncertainty contribution. Part of the model's public surface: the
86 systematic/residual split is what a factor model is for, and
87 `estimate` is the norm of this and `systematic_risk`.
88 """
89 return cp.norm2(
90 cp.hstack(
91 [
92 cp.multiply(self.data[D.IDIOSYNCRATIC_VOLA], variables[D.WEIGHTS]),
93 cp.multiply(
94 self.data[D.IDIOSYNCRATIC_VOLA_UNCERTAINTY],
95 variables[D.WEIGHTS],
96 ),
97 ]
98 )
99 )
101 def systematic_risk(self, variables: Variables) -> cp.Expression:
102 """Return the factor-driven (systematic) part of the risk.
104 The L2 norm of the systematic volatility contribution and its
105 uncertainty contribution. See `residual_risk` for the counterpart.
106 """
107 return cp.norm2(
108 cp.hstack(
109 [
110 self.data[D.CHOLESKY] @ variables[D.FACTOR_WEIGHTS],
111 self.data[D.SYSTEMATIC_VOLA_UNCERTAINTY] @ variables[D._ABS],
112 ]
113 )
114 )
116 def dimensions(self, **kwargs: Matrix) -> Dimensions:
117 """Return the asset and factor counts every factor-model input implies."""
118 factors, assets = np.shape(kwargs[D.EXPOSURE])
119 chol_rows, chol_cols = np.shape(kwargs[D.CHOLESKY])
121 return (
122 (D.WEIGHTS, assets),
123 (D.WEIGHTS, len(kwargs[D.IDIOSYNCRATIC_VOLA])),
124 (D.WEIGHTS, len(kwargs[D.IDIOSYNCRATIC_VOLA_UNCERTAINTY])),
125 (D.FACTOR_WEIGHTS, factors),
126 (D.FACTOR_WEIGHTS, chol_rows),
127 (D.FACTOR_WEIGHTS, chol_cols),
128 (D.FACTOR_WEIGHTS, len(kwargs[D.SYSTEMATIC_VOLA_UNCERTAINTY])),
129 )
131 def update(self, **kwargs: Matrix) -> None:
132 """Validate and assign all factor-model inputs.
134 Expected keyword arguments:
135 exposure: Factor exposure matrix (factors x assets).
136 idiosyncratic_vola: Asset-specific volatility vector.
137 chol: Cholesky factor of factor covariance (factors x factors).
138 systematic_vola_uncertainty: Nonnegative vector for systematic risk uncertainty.
139 idiosyncratic_vola_uncertainty: Nonnegative vector for residual risk uncertainty.
140 """
141 self._validate(**kwargs)
143 self.data[D.EXPOSURE].value = fill_matrix(rows=self.factors, cols=self.assets, x=kwargs[D.EXPOSURE])
144 self.data[D.IDIOSYNCRATIC_VOLA].value = fill_vector(num=self.assets, x=kwargs[D.IDIOSYNCRATIC_VOLA])
145 self.data[D.CHOLESKY].value = fill_matrix(rows=self.factors, cols=self.factors, x=kwargs[D.CHOLESKY])
147 # Robust risk
148 self.data[D.SYSTEMATIC_VOLA_UNCERTAINTY].value = fill_vector(
149 num=self.factors, x=kwargs[D.SYSTEMATIC_VOLA_UNCERTAINTY]
150 )
151 self.data[D.IDIOSYNCRATIC_VOLA_UNCERTAINTY].value = fill_vector(
152 num=self.assets, x=kwargs[D.IDIOSYNCRATIC_VOLA_UNCERTAINTY]
153 )
155 def _validate(self, **kwargs: Matrix) -> None:
156 """Check that all required inputs are present and shape-consistent."""
157 for key in self.data:
158 if key not in kwargs:
159 raise CvxDataError(f"Missing keyword {key}") # noqa: TRY003
161 self._check_shapes(**kwargs)
163 def _check_shapes(self, **kwargs: Matrix) -> None:
164 """Validate that the input dimensions are mutually consistent."""
165 k, assets = kwargs[D.EXPOSURE].shape
167 if kwargs[D.IDIOSYNCRATIC_VOLA].shape[0] != kwargs[D.IDIOSYNCRATIC_VOLA_UNCERTAINTY].shape[0]:
168 raise CvxDataError("Mismatch in length for idiosyncratic_vola and idiosyncratic_vola_uncertainty") # noqa: TRY003
170 if kwargs[D.IDIOSYNCRATIC_VOLA].shape[0] != assets:
171 raise CvxDataError("Mismatch in length for idiosyncratic_vola and exposure") # noqa: TRY003
173 if kwargs[D.SYSTEMATIC_VOLA_UNCERTAINTY].shape[0] != k:
174 raise CvxDataError("Mismatch in length of systematic_vola_uncertainty and exposure") # noqa: TRY003
176 if kwargs[D.CHOLESKY].shape[0] != k:
177 raise CvxDataError("Mismatch in size of chol and exposure") # noqa: TRY003
179 def constraints(self, variables: Variables) -> Constraints:
180 """Return factor-model linking and robust-risk constraints."""
181 return {
182 "factors": variables[D.FACTOR_WEIGHTS] == self.data[D.EXPOSURE] @ variables[D.WEIGHTS],
183 "_abs": variables[D._ABS] >= cp.abs(variables[D.FACTOR_WEIGHTS]), # Robust risk dummy variable
184 }