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

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.""" 

15 

16from __future__ import annotations 

17 

18from dataclasses import dataclass 

19 

20import cvxpy as cp 

21import numpy as np 

22 

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 

28 

29 

30@dataclass(frozen=True) 

31class FactorModel(Model): 

32 """Factor risk model.""" 

33 

34 factors: int = 0 

35 

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 ) 

43 

44 self.data[D.IDIOSYNCRATIC_VOLA] = cp.Parameter( 

45 shape=self.assets, 

46 name=D.IDIOSYNCRATIC_VOLA, 

47 value=np.zeros(self.assets), 

48 ) 

49 

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 ) 

55 

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 ) 

62 

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 ) 

69 

70 def estimate(self, variables: Variables) -> cp.Expression: 

71 """Compute the total risk as the norm of its systematic and residual parts. 

72 

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) 

78 

79 return cp.norm2(cp.vstack([var_systematic, var_residual])) 

80 

81 def residual_risk(self, variables: Variables) -> cp.Expression: 

82 """Return the asset-specific (idiosyncratic) part of the risk. 

83 

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 ) 

100 

101 def systematic_risk(self, variables: Variables) -> cp.Expression: 

102 """Return the factor-driven (systematic) part of the risk. 

103 

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 ) 

115 

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]) 

120 

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 ) 

130 

131 def update(self, **kwargs: Matrix) -> None: 

132 """Validate and assign all factor-model inputs. 

133 

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) 

142 

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]) 

146 

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 ) 

154 

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 

160 

161 self._check_shapes(**kwargs) 

162 

163 def _check_shapes(self, **kwargs: Matrix) -> None: 

164 """Validate that the input dimensions are mutually consistent.""" 

165 k, assets = kwargs[D.EXPOSURE].shape 

166 

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 

169 

170 if kwargs[D.IDIOSYNCRATIC_VOLA].shape[0] != assets: 

171 raise CvxDataError("Mismatch in length for idiosyncratic_vola and exposure") # noqa: TRY003 

172 

173 if kwargs[D.SYSTEMATIC_VOLA_UNCERTAINTY].shape[0] != k: 

174 raise CvxDataError("Mismatch in length of systematic_vola_uncertainty and exposure") # noqa: TRY003 

175 

176 if kwargs[D.CHOLESKY].shape[0] != k: 

177 raise CvxDataError("Mismatch in size of chol and exposure") # noqa: TRY003 

178 

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 }