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

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

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

72 var_residual = self._residual_risk(variables) 

73 var_systematic = self._systematic_risk(variables) 

74 

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

76 

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 ) 

89 

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 ) 

99 

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

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

102 

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}") 

114 

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

117 

118 exposure = kwargs[D.EXPOSURE] 

119 k, assets = exposure.shape 

120 

121 if not kwargs[D.IDIOSYNCRATIC_VOLA].shape[0] == assets: 

122 raise CvxError("Mismatch in length for idiosyncratic_vola and exposure") 

123 

124 if not kwargs[D.SYSTEMATIC_VOLA_UNCERTAINTY].shape[0] == k: 

125 raise CvxError("Mismatch in length of systematic_vola_uncertainty and exposure") 

126 

127 if not kwargs[D.CHOLESKY].shape[0] == k: 

128 raise CvxError("Mismatch in size of chol and exposure") 

129 

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

133 

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 ) 

141 

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 }