Coverage for src/cvxmarkowitz/risk/factor/factor.py: 100%
50 statements
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-11 10:51 +0000
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-11 10:51 +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 CvxError
24from cvxmarkowitz.model import Model
25from cvxmarkowitz.names import DataNames as D
26from cvxmarkowitz.types import Constraints, Expressions, Matrix, Parameter, Variables # noqa: F401
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 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 the L2 norm of idiosyncratic and its uncertainty contributions."""
79 return cp.norm2(
80 cp.hstack(
81 [
82 cp.multiply(self.data[D.IDIOSYNCRATIC_VOLA], variables[D.WEIGHTS]),
83 cp.multiply(
84 self.data[D.IDIOSYNCRATIC_VOLA_UNCERTAINTY],
85 variables[D.WEIGHTS],
86 ),
87 ]
88 )
89 )
91 def _systematic_risk(self, variables: Variables) -> cp.Expression:
92 """Return the L2 norm of systematic and its uncertainty contributions."""
93 return cp.norm2(
94 cp.hstack(
95 [
96 self.data[D.CHOLESKY] @ variables[D.FACTOR_WEIGHTS],
97 self.data[D.SYSTEMATIC_VOLA_UNCERTAINTY] @ variables[D._ABS],
98 ]
99 )
100 )
102 def update(self, **kwargs: Matrix) -> None:
103 """Validate and assign all factor-model inputs.
105 Expected keyword arguments:
106 exposure: Factor exposure matrix (factors x assets).
107 idiosyncratic_vola: Asset-specific volatility vector.
108 chol: Cholesky factor of factor covariance (factors x factors).
109 systematic_vola_uncertainty: Nonnegative vector for systematic risk uncertainty.
110 idiosyncratic_vola_uncertainty: Nonnegative vector for residual risk uncertainty.
111 """
112 self._validate(**kwargs)
114 self.data[D.EXPOSURE].value = fill_matrix(rows=self.factors, cols=self.assets, x=kwargs["exposure"])
115 self.data[D.IDIOSYNCRATIC_VOLA].value = fill_vector(num=self.assets, x=kwargs[D.IDIOSYNCRATIC_VOLA])
116 self.data[D.CHOLESKY].value = fill_matrix(rows=self.factors, cols=self.factors, x=kwargs[D.CHOLESKY])
118 # Robust risk
119 self.data[D.SYSTEMATIC_VOLA_UNCERTAINTY].value = fill_vector(
120 num=self.factors, x=kwargs[D.SYSTEMATIC_VOLA_UNCERTAINTY]
121 )
122 self.data[D.IDIOSYNCRATIC_VOLA_UNCERTAINTY].value = fill_vector(
123 num=self.assets, x=kwargs[D.IDIOSYNCRATIC_VOLA_UNCERTAINTY]
124 )
126 def _validate(self, **kwargs: Matrix) -> None:
127 """Check that all required inputs are present and shape-consistent."""
128 for key in self.data:
129 if key not in kwargs:
130 raise CvxError(f"Missing keyword {key}") # noqa: TRY003
132 self._check_shapes(**kwargs)
134 def _check_shapes(self, **kwargs: Matrix) -> None:
135 """Validate that the input dimensions are mutually consistent."""
136 k, assets = kwargs[D.EXPOSURE].shape
138 if kwargs[D.IDIOSYNCRATIC_VOLA].shape[0] != kwargs[D.IDIOSYNCRATIC_VOLA_UNCERTAINTY].shape[0]:
139 raise CvxError("Mismatch in length for idiosyncratic_vola and idiosyncratic_vola_uncertainty") # noqa: TRY003
141 if kwargs[D.IDIOSYNCRATIC_VOLA].shape[0] != assets:
142 raise CvxError("Mismatch in length for idiosyncratic_vola and exposure") # noqa: TRY003
144 if kwargs[D.SYSTEMATIC_VOLA_UNCERTAINTY].shape[0] != k:
145 raise CvxError("Mismatch in length of systematic_vola_uncertainty and exposure") # noqa: TRY003
147 if kwargs[D.CHOLESKY].shape[0] != k:
148 raise CvxError("Mismatch in size of chol and exposure") # noqa: TRY003
150 def constraints(self, variables: Variables) -> Constraints:
151 """Return factor-model linking and robust-risk constraints."""
152 return {
153 "factors": variables[D.FACTOR_WEIGHTS] == self.data[D.EXPOSURE] @ variables[D.WEIGHTS],
154 "_abs": variables[D._ABS] >= cp.abs(variables[D.FACTOR_WEIGHTS]), # Robust risk dummy variable
155 }