Coverage for cvxrisk/factor/factor.py: 95%

41 statements  

« prev     ^ index     » next       coverage.py v7.8.2, created at 2025-06-18 11:11 +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 cvx 

21import numpy as np 

22 

23from ..bounds import Bounds 

24from ..linalg import cholesky 

25from ..model import Model 

26 

27 

28@dataclass 

29class FactorModel(Model): 

30 """Factor risk model.""" 

31 

32 assets: int = 0 

33 """Maximal number of assets""" 

34 

35 k: int = 0 

36 """Maximal number of factors""" 

37 

38 def __post_init__(self): 

39 """Initialize the parameters after the class is instantiated. 

40 

41 Creates parameters for factor exposure, idiosyncratic risk, and the Cholesky 

42 decomposition of the factor covariance matrix. Also initializes bounds for 

43 both assets and factors. 

44 """ 

45 self.parameter["exposure"] = cvx.Parameter( 

46 shape=(self.k, self.assets), 

47 name="exposure", 

48 value=np.zeros((self.k, self.assets)), 

49 ) 

50 

51 self.parameter["idiosyncratic_risk"] = cvx.Parameter( 

52 shape=self.assets, name="idiosyncratic risk", value=np.zeros(self.assets) 

53 ) 

54 

55 self.parameter["chol"] = cvx.Parameter( 

56 shape=(self.k, self.k), 

57 name="cholesky of covariance", 

58 value=np.zeros((self.k, self.k)), 

59 ) 

60 

61 self.bounds_assets = Bounds(m=self.assets, name="assets") 

62 self.bounds_factors = Bounds(m=self.k, name="factors") 

63 

64 def estimate(self, weights: cvx.Variable, **kwargs) -> cvx.Expression: 

65 """Compute the total portfolio risk using the factor model. 

66 

67 Combines systematic risk (from factor exposures) and idiosyncratic risk 

68 to calculate the total portfolio risk. 

69 

70 Args: 

71 weights: CVXPY variable representing portfolio weights 

72 

73 **kwargs: Additional keyword arguments, may include: 

74 

75 - y: Factor exposures (if not provided, calculated as exposure @ weights) 

76 

77 Returns: 

78 CVXPY expression: The total portfolio risk 

79 

80 """ 

81 var_residual = cvx.norm2(cvx.multiply(self.parameter["idiosyncratic_risk"], weights)) 

82 

83 y = kwargs.get("y", self.parameter["exposure"] @ weights) 

84 

85 return cvx.norm2(cvx.vstack([cvx.norm2(self.parameter["chol"] @ y), var_residual])) 

86 

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

88 """Update the factor model parameters. 

89 

90 Args: 

91 **kwargs: Keyword arguments containing: 

92 

93 - exposure: Factor exposure matrix 

94 

95 - idiosyncratic_risk: Vector of idiosyncratic risks 

96 

97 - cov: Factor covariance matrix 

98 

99 - Other parameters passed to bounds_assets.update() and bounds_factors.update() 

100 

101 """ 

102 self.parameter["exposure"].value = np.zeros((self.k, self.assets)) 

103 self.parameter["chol"].value = np.zeros((self.k, self.k)) 

104 self.parameter["idiosyncratic_risk"].value = np.zeros(self.assets) 

105 

106 # get the exposure 

107 exposure = kwargs["exposure"] 

108 

109 # extract dimensions 

110 k, assets = exposure.shape 

111 if k > self.k: 

112 raise ValueError("Number of factors exceeds maximal number of factors") 

113 if assets > self.assets: 

114 raise ValueError("Number of assets exceeds maximal number of assets") 

115 

116 self.parameter["exposure"].value[:k, :assets] = kwargs["exposure"] 

117 self.parameter["idiosyncratic_risk"].value[:assets] = kwargs["idiosyncratic_risk"] 

118 self.parameter["chol"].value[:k, :k] = cholesky(kwargs["cov"]) 

119 self.bounds_assets.update(**kwargs) 

120 self.bounds_factors.update(**kwargs) 

121 

122 def constraints(self, weights: cvx.Variable, **kwargs) -> list[cvx.Constraint]: 

123 """Return constraints for the factor model. 

124 

125 Args: 

126 weights: CVXPY variable representing portfolio weights 

127 

128 **kwargs: Additional keyword arguments, may include: 

129 

130 - y: Factor exposures (if not provided, calculated as exposure @ weights) 

131 

132 Returns: 

133 List of CVXPY constraints including asset bounds, factor bounds, 

134 and the constraint that y equals exposure @ weights 

135 

136 """ 

137 y = kwargs.get("y", self.parameter["exposure"] @ weights) 

138 

139 return ( 

140 self.bounds_assets.constraints(weights) 

141 + self.bounds_factors.constraints(y) 

142 + [y == self.parameter["exposure"] @ weights] 

143 )