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

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

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

90 

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 ) 

101 

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

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

104 

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) 

113 

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

117 

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 ) 

125 

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 

131 

132 self._check_shapes(**kwargs) 

133 

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

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

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

137 

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 

140 

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

142 raise CvxError("Mismatch in length for idiosyncratic_vola and exposure") # noqa: TRY003 

143 

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

145 raise CvxError("Mismatch in length of systematic_vola_uncertainty and exposure") # noqa: TRY003 

146 

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

148 raise CvxError("Mismatch in size of chol and exposure") # noqa: TRY003 

149 

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 }