Coverage for src / cvx / markowitz / risk / factor / factor.py: 100%
47 statements
« prev ^ index » next coverage.py v7.12.0, created at 2025-12-08 13:49 +0000
« prev ^ index » next coverage.py v7.12.0, created at 2025-12-08 13:49 +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 cvx.markowitz.cvxerror import CvxError
24from cvx.markowitz.model import Model
25from cvx.markowitz.names import DataNames as D
26from cvx.markowitz.types import Expressions, Matrix, Parameter, Variables # noqa: F401
27from cvx.markowitz.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 variance."""
72 var_residual = self._residual_risk(variables)
73 var_systematic = self._systematic_risk(variables)
75 return cp.norm2(cp.vstack([var_systematic, var_residual]))
77 def _residual_risk(self, variables: Variables) -> cp.Expression:
78 return cp.norm2(
79 cp.hstack(
80 [
81 cp.multiply(self.data[D.IDIOSYNCRATIC_VOLA], variables[D.WEIGHTS]),
82 cp.multiply(
83 self.data[D.IDIOSYNCRATIC_VOLA_UNCERTAINTY],
84 variables[D.WEIGHTS],
85 ),
86 ]
87 )
88 )
90 def _systematic_risk(self, variables: Variables) -> cp.Expression:
91 return cp.norm2(
92 cp.hstack(
93 [
94 self.data[D.CHOLESKY] @ variables[D.FACTOR_WEIGHTS],
95 self.data[D.SYSTEMATIC_VOLA_UNCERTAINTY] @ variables[D._ABS],
96 ]
97 )
98 )
100 def update(self, **kwargs: Matrix) -> None:
101 """Validate and assign all factor-model inputs.
103 Expected keyword arguments:
104 exposure: Factor exposure matrix (factors x assets).
105 idiosyncratic_vola: Asset-specific volatility vector.
106 chol: Cholesky factor of factor covariance (factors x factors).
107 systematic_vola_uncertainty: Nonnegative vector for systematic risk uncertainty.
108 idiosyncratic_vola_uncertainty: Nonnegative vector for residual risk uncertainty.
109 """
110 # check the keywords
111 for key in self.data.keys():
112 if key not in kwargs.keys():
113 raise CvxError(f"Missing keyword {key}")
115 if not kwargs[D.IDIOSYNCRATIC_VOLA].shape[0] == kwargs[D.IDIOSYNCRATIC_VOLA_UNCERTAINTY].shape[0]:
116 raise CvxError("Mismatch in length for idiosyncratic_vola and idiosyncratic_vola_uncertainty")
118 exposure = kwargs[D.EXPOSURE]
119 k, assets = exposure.shape
121 if not kwargs[D.IDIOSYNCRATIC_VOLA].shape[0] == assets:
122 raise CvxError("Mismatch in length for idiosyncratic_vola and exposure")
124 if not kwargs[D.SYSTEMATIC_VOLA_UNCERTAINTY].shape[0] == k:
125 raise CvxError("Mismatch in length of systematic_vola_uncertainty and exposure")
127 if not kwargs[D.CHOLESKY].shape[0] == k:
128 raise CvxError("Mismatch in size of chol and exposure")
130 self.data[D.EXPOSURE].value = fill_matrix(rows=self.factors, cols=self.assets, x=kwargs["exposure"])
131 self.data[D.IDIOSYNCRATIC_VOLA].value = fill_vector(num=self.assets, x=kwargs[D.IDIOSYNCRATIC_VOLA])
132 self.data[D.CHOLESKY].value = fill_matrix(rows=self.factors, cols=self.factors, x=kwargs[D.CHOLESKY])
134 # Robust risk
135 self.data[D.SYSTEMATIC_VOLA_UNCERTAINTY].value = fill_vector(
136 num=self.factors, x=kwargs[D.SYSTEMATIC_VOLA_UNCERTAINTY]
137 )
138 self.data[D.IDIOSYNCRATIC_VOLA_UNCERTAINTY].value = fill_vector(
139 num=self.assets, x=kwargs[D.IDIOSYNCRATIC_VOLA_UNCERTAINTY]
140 )
142 def constraints(self, variables: Variables) -> Expressions:
143 """Return factor-model linking and robust-risk constraints."""
144 return {
145 "factors": variables[D.FACTOR_WEIGHTS] == self.data[D.EXPOSURE] @ variables[D.WEIGHTS],
146 "_abs": variables[D._ABS] >= cp.abs(variables[D.FACTOR_WEIGHTS]), # Robust risk dummy variable
147 }