Coverage for src / cvx / risk / sample / sample.py: 100%

26 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-09 03:39 +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"""Risk models based on the sample covariance matrix. 

15 

16This module provides the SampleCovariance class, which implements a risk model 

17based on the Cholesky decomposition of the sample covariance matrix. This is 

18one of the most common approaches to portfolio risk estimation. 

19 

20Example: 

21 Create and use a sample covariance risk model: 

22 

23 >>> import cvxpy as cp 

24 >>> import numpy as np 

25 >>> from cvx.risk.sample import SampleCovariance 

26 >>> # Create risk model for up to 3 assets 

27 >>> model = SampleCovariance(num=3) 

28 >>> # Update with a covariance matrix 

29 >>> cov = np.array([[1.0, 0.5, 0.0], [0.5, 1.0, 0.5], [0.0, 0.5, 1.0]]) 

30 >>> model.update( 

31 ... cov=cov, 

32 ... lower_assets=np.zeros(3), 

33 ... upper_assets=np.ones(3) 

34 ... ) 

35 >>> # Estimate risk for a given portfolio 

36 >>> weights = np.array([0.4, 0.3, 0.3]) 

37 >>> risk = model.estimate(weights).value 

38 >>> isinstance(risk, float) 

39 True 

40 

41""" 

42 

43from __future__ import annotations 

44 

45from dataclasses import dataclass 

46from typing import Any, cast 

47 

48import cvxpy as cvx 

49import numpy as np 

50 

51from cvx.risk.bounds import Bounds 

52from cvx.risk.linalg import cholesky 

53from cvx.risk.model import Model 

54 

55 

56@dataclass 

57class SampleCovariance(Model): 

58 """Risk model based on the Cholesky decomposition of the sample covariance matrix. 

59 

60 This model computes portfolio risk as the L2 norm of the product of the 

61 Cholesky factor and the weights vector. Mathematically, if R is the upper 

62 triangular Cholesky factor of the covariance matrix (R^T @ R = cov), then: 

63 

64 risk = ||R @ w||_2 = sqrt(w^T @ cov @ w) 

65 

66 This represents the portfolio standard deviation (volatility). 

67 

68 Attributes: 

69 num: Maximum number of assets the model can handle. The model can be 

70 updated with fewer assets, but not more. 

71 

72 Example: 

73 Basic usage: 

74 

75 >>> import cvxpy as cp 

76 >>> import numpy as np 

77 >>> from cvx.risk.sample import SampleCovariance 

78 >>> model = SampleCovariance(num=2) 

79 >>> model.update( 

80 ... cov=np.array([[1.0, 0.5], [0.5, 2.0]]), 

81 ... lower_assets=np.zeros(2), 

82 ... upper_assets=np.ones(2) 

83 ... ) 

84 >>> # Equal weight portfolio 

85 >>> weights = np.array([0.5, 0.5]) 

86 >>> risk = model.estimate(weights).value 

87 >>> # Risk should be sqrt(0.5^2 * 1 + 0.5^2 * 2 + 2 * 0.5 * 0.5 * 0.5) 

88 >>> bool(np.isclose(risk, 1.0)) 

89 True 

90 

91 Using in optimization: 

92 

93 >>> from cvx.risk.portfolio import minrisk_problem 

94 >>> weights = cp.Variable(2) 

95 >>> problem = minrisk_problem(model, weights) 

96 >>> _ = problem.solve(solver="CLARABEL") 

97 >>> # Lower variance asset gets higher weight 

98 >>> bool(weights.value[0] > weights.value[1]) 

99 True 

100 

101 Mathematical verification - the risk estimate equals sqrt(w^T @ cov @ w): 

102 

103 >>> model = SampleCovariance(num=3) 

104 >>> cov = np.array([[0.04, 0.01, 0.02], 

105 ... [0.01, 0.09, 0.01], 

106 ... [0.02, 0.01, 0.16]]) 

107 >>> model.update( 

108 ... cov=cov, 

109 ... lower_assets=np.zeros(3), 

110 ... upper_assets=np.ones(3) 

111 ... ) 

112 >>> w = np.array([0.4, 0.35, 0.25]) 

113 >>> # Model estimate 

114 >>> model_risk = model.estimate(w).value 

115 >>> # Manual calculation: sqrt(w^T @ cov @ w) 

116 >>> manual_risk = np.sqrt(w @ cov @ w) 

117 >>> bool(np.isclose(model_risk, manual_risk, rtol=1e-6)) 

118 True 

119 

120 Using with correlation matrix and volatilities: 

121 

122 >>> # Construct covariance from correlation and volatilities 

123 >>> vols = np.array([0.15, 0.20, 0.25]) # 15%, 20%, 25% annual vol 

124 >>> corr = np.array([[1.0, 0.3, 0.1], 

125 ... [0.3, 1.0, 0.4], 

126 ... [0.1, 0.4, 1.0]]) 

127 >>> cov = np.outer(vols, vols) * corr 

128 >>> model.update( 

129 ... cov=cov, 

130 ... lower_assets=np.zeros(3), 

131 ... upper_assets=np.ones(3) 

132 ... ) 

133 >>> equal_weight = np.array([1/3, 1/3, 1/3]) 

134 >>> portfolio_vol = model.estimate(equal_weight).value 

135 >>> # Portfolio vol should be less than weighted average vol (diversification) 

136 >>> bool(portfolio_vol < np.mean(vols)) 

137 True 

138 

139 """ 

140 

141 num: int = 0 

142 """Maximum number of assets the model can handle.""" 

143 

144 def __post_init__(self) -> None: 

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

146 

147 Creates the Cholesky decomposition parameter and initializes the bounds. 

148 The Cholesky parameter is a square matrix of size (num, num), and bounds 

149 are created for asset weights. 

150 

151 Example: 

152 >>> from cvx.risk.sample import SampleCovariance 

153 >>> model = SampleCovariance(num=5) 

154 >>> # Parameters are automatically created 

155 >>> model.parameter["chol"].shape 

156 (5, 5) 

157 

158 """ 

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

160 shape=(self.num, self.num), 

161 name="cholesky of covariance", 

162 value=np.zeros((self.num, self.num)), 

163 ) 

164 self.bounds = Bounds(m=self.num, name="assets") 

165 

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

167 """Estimate the portfolio risk using the Cholesky decomposition. 

168 

169 Computes the L2 norm of the product of the Cholesky factor and the 

170 weights vector. This is equivalent to the square root of the portfolio 

171 variance (i.e., portfolio volatility). 

172 

173 Args: 

174 weights: CVXPY variable or numpy array representing portfolio weights. 

175 **kwargs: Additional keyword arguments (not used). 

176 

177 Returns: 

178 CVXPY expression representing the portfolio risk (standard deviation). 

179 

180 Example: 

181 >>> import cvxpy as cp 

182 >>> import numpy as np 

183 >>> from cvx.risk.sample import SampleCovariance 

184 >>> model = SampleCovariance(num=2) 

185 >>> # Identity covariance (uncorrelated assets with unit variance) 

186 >>> model.update( 

187 ... cov=np.eye(2), 

188 ... lower_assets=np.zeros(2), 

189 ... upper_assets=np.ones(2) 

190 ... ) 

191 >>> weights = cp.Variable(2) 

192 >>> risk = model.estimate(weights) 

193 >>> isinstance(risk, cp.Expression) 

194 True 

195 

196 """ 

197 return cast( 

198 cvx.Expression, 

199 cvx.norm2(self.parameter["chol"] @ weights), 

200 ) 

201 

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

203 """Update the Cholesky decomposition parameter and bounds. 

204 

205 Computes the Cholesky decomposition of the provided covariance matrix 

206 and updates the model parameters. The covariance matrix can be smaller 

207 than num x num. 

208 

209 Args: 

210 **kwargs: Keyword arguments containing: 

211 - cov: Covariance matrix (numpy.ndarray). Must be positive definite. 

212 - lower_assets: Array of lower bounds for asset weights. 

213 - upper_assets: Array of upper bounds for asset weights. 

214 

215 Example: 

216 >>> import numpy as np 

217 >>> from cvx.risk.sample import SampleCovariance 

218 >>> model = SampleCovariance(num=5) 

219 >>> # Update with a 3x3 covariance (smaller than max) 

220 >>> cov = np.array([[1.0, 0.3, 0.1], 

221 ... [0.3, 1.0, 0.2], 

222 ... [0.1, 0.2, 1.0]]) 

223 >>> model.update( 

224 ... cov=cov, 

225 ... lower_assets=np.zeros(3), 

226 ... upper_assets=np.ones(3) 

227 ... ) 

228 >>> # Cholesky factor is updated 

229 >>> model.parameter["chol"].value[:3, :3].shape 

230 (3, 3) 

231 

232 """ 

233 cov = kwargs["cov"] 

234 n = cov.shape[0] 

235 

236 chol = np.zeros((self.num, self.num)) 

237 chol[:n, :n] = cholesky(cov) 

238 self.parameter["chol"].value = chol 

239 self.bounds.update(**kwargs) 

240 

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

242 """Return constraints for the sample covariance model. 

243 

244 Returns the asset bounds constraints from the internal bounds object. 

245 

246 Args: 

247 weights: CVXPY variable representing portfolio weights. 

248 **kwargs: Additional keyword arguments (not used). 

249 

250 Returns: 

251 List of CVXPY constraints from the bounds object (lower and upper bounds). 

252 

253 Example: 

254 >>> import cvxpy as cp 

255 >>> import numpy as np 

256 >>> from cvx.risk.sample import SampleCovariance 

257 >>> model = SampleCovariance(num=3) 

258 >>> model.update( 

259 ... cov=np.eye(3), 

260 ... lower_assets=np.array([0.1, 0.0, 0.0]), 

261 ... upper_assets=np.array([0.5, 0.6, 0.4]) 

262 ... ) 

263 >>> weights = cp.Variable(3) 

264 >>> constraints = model.constraints(weights) 

265 >>> len(constraints) 

266 2 

267 

268 """ 

269 return self.bounds.constraints(weights)