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

30 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-09 03:39 +0000

1"""Conditional Value at Risk (CVaR) risk model implementation. 

2 

3This module provides the CVar class, which implements the Conditional Value at Risk 

4(also known as Expected Shortfall) risk measure for portfolio optimization. 

5 

6CVaR measures the expected loss in the tail of the portfolio's return distribution, 

7making it a popular choice for risk-averse portfolio optimization. 

8 

9Example: 

10 Create a CVaR model and compute the tail risk: 

11 

12 >>> import cvxpy as cp 

13 >>> import numpy as np 

14 >>> from cvx.risk.cvar import CVar 

15 >>> # Create CVaR model with 95% confidence level 

16 >>> model = CVar(alpha=0.95, n=100, m=5) 

17 >>> # Generate sample returns 

18 >>> np.random.seed(42) 

19 >>> returns = np.random.randn(100, 5) 

20 >>> # Update model with returns data 

21 >>> model.update( 

22 ... returns=returns, 

23 ... lower_assets=np.zeros(5), 

24 ... upper_assets=np.ones(5) 

25 ... ) 

26 >>> # The model is ready for optimization 

27 >>> weights = cp.Variable(5) 

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

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

30 True 

31 

32""" 

33 

34# Copyright 2023 Stanford University Convex Optimization Group 

35# 

36# Licensed under the Apache License, Version 2.0 (the "License"); 

37# you may not use this file except in compliance with the License. 

38# You may obtain a copy of the License at 

39# 

40# http://www.apache.org/licenses/LICENSE-2.0 

41# 

42# Unless required by applicable law or agreed to in writing, software 

43# distributed under the License is distributed on an "AS IS" BASIS, 

44# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 

45# See the License for the specific language governing permissions and 

46# limitations under the License. 

47from __future__ import annotations 

48 

49from dataclasses import dataclass 

50from typing import Any, cast 

51 

52import cvxpy as cvx 

53import numpy as np 

54 

55from cvx.risk.bounds import Bounds 

56from cvx.risk.model import Model 

57 

58 

59@dataclass 

60class CVar(Model): 

61 """Conditional Value at Risk (CVaR) risk model. 

62 

63 CVaR, also known as Expected Shortfall, measures the expected loss in the 

64 worst (1-alpha) fraction of scenarios. For example, with alpha=0.95, CVaR 

65 is the average of the worst 5% of returns. 

66 

67 This implementation uses historical returns to estimate CVaR, which is 

68 computed as the negative average of the k smallest portfolio returns, 

69 where k = n * (1 - alpha). 

70 

71 Attributes: 

72 alpha: Confidence level, typically 0.95 or 0.99. Higher alpha means 

73 focusing on more extreme tail events. 

74 n: Number of historical return observations (scenarios). 

75 m: Maximum number of assets in the portfolio. 

76 

77 Example: 

78 Basic CVaR model setup and optimization: 

79 

80 >>> import cvxpy as cp 

81 >>> import numpy as np 

82 >>> from cvx.risk.cvar import CVar 

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

84 >>> # Create model for 95% CVaR with 50 scenarios and 3 assets 

85 >>> model = CVar(alpha=0.95, n=50, m=3) 

86 >>> # Number of tail samples: k = 50 * (1 - 0.95) = 2.5 -> 2 

87 >>> model.k 

88 2 

89 >>> # Generate sample returns 

90 >>> np.random.seed(42) 

91 >>> returns = np.random.randn(50, 3) 

92 >>> model.update( 

93 ... returns=returns, 

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

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

96 ... ) 

97 >>> # Create and solve optimization 

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

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

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

101 

102 Mathematical verification of CVaR calculation: 

103 

104 >>> model = CVar(alpha=0.95, n=20, m=2) 

105 >>> # Simple returns: asset 1 always returns 0.05, asset 2 returns vary 

106 >>> returns = np.zeros((20, 2)) 

107 >>> returns[:, 0] = 0.05 # Asset 1 constant return 

108 >>> returns[:, 1] = np.linspace(-0.20, 0.18, 20) # Asset 2 varying 

109 >>> model.update( 

110 ... returns=returns, 

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

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

113 ... ) 

114 >>> # k = 20 * (1 - 0.95) = 1, so we take the single worst return 

115 >>> model.k 

116 1 

117 >>> # For 100% in asset 2, worst return is -0.20 

118 >>> w = np.array([0.0, 1.0]) 

119 >>> cvar = model.estimate(w).value 

120 >>> expected_cvar = 0.20 # negative of worst return 

121 >>> bool(np.isclose(cvar, expected_cvar, rtol=1e-6)) 

122 True 

123 

124 Different alpha values affect the tail focus: 

125 

126 >>> # Higher alpha = focus on more extreme events 

127 >>> model_95 = CVar(alpha=0.95, n=100, m=2) 

128 >>> model_95.k # Only 5 worst scenarios 

129 5 

130 >>> model_75 = CVar(alpha=0.75, n=100, m=2) 

131 >>> model_75.k # 25 worst scenarios 

132 25 

133 

134 """ 

135 

136 alpha: float = 0.95 

137 """Confidence level for CVaR (e.g., 0.95 for 95% CVaR).""" 

138 

139 n: int = 0 

140 """Number of historical return observations (scenarios).""" 

141 

142 m: int = 0 

143 """Maximum number of assets in the portfolio.""" 

144 

145 def __post_init__(self) -> None: 

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

147 

148 Calculates the number of samples in the tail (k) based on alpha, 

149 creates the returns parameter matrix, and initializes the bounds. 

150 

151 Example: 

152 >>> from cvx.risk.cvar import CVar 

153 >>> model = CVar(alpha=0.95, n=100, m=5) 

154 >>> # k is the number of samples in the tail 

155 >>> model.k 

156 5 

157 >>> # Returns parameter is created 

158 >>> model.parameter["R"].shape 

159 (100, 5) 

160 

161 """ 

162 self.k = int(self.n * (1 - self.alpha)) 

163 self.parameter["R"] = cvx.Parameter(shape=(self.n, self.m), name="returns", value=np.zeros((self.n, self.m))) 

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

165 

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

167 """Estimate the Conditional Value at Risk (CVaR) for the given weights. 

168 

169 Computes the negative average of the k smallest returns in the portfolio, 

170 where k is determined by the alpha parameter. This represents the expected 

171 loss in the worst (1-alpha) fraction of scenarios. 

172 

173 Args: 

174 weights: CVXPY variable representing portfolio weights. 

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

176 

177 Returns: 

178 CVXPY expression representing the CVaR (expected tail loss). 

179 

180 Example: 

181 >>> import cvxpy as cp 

182 >>> import numpy as np 

183 >>> from cvx.risk.cvar import CVar 

184 >>> model = CVar(alpha=0.95, n=100, m=3) 

185 >>> np.random.seed(42) 

186 >>> returns = np.random.randn(100, 3) 

187 >>> model.update( 

188 ... returns=returns, 

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

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

191 ... ) 

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

193 >>> cvar = model.estimate(weights) 

194 >>> isinstance(cvar, cp.Expression) 

195 True 

196 

197 """ 

198 # R is a matrix of returns, n is the number of rows in R 

199 # k is the number of returns in the left tail 

200 # average value of the k elements in the left tail 

201 return cast( 

202 cvx.Expression, 

203 -cvx.sum_smallest(self.parameter["R"] @ weights, k=self.k) / self.k, 

204 ) 

205 

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

207 """Update the returns data and bounds parameters. 

208 

209 Updates the returns matrix and asset bounds. The returns matrix can 

210 have fewer columns than m (maximum assets), in which case only the 

211 first columns are updated. 

212 

213 Args: 

214 **kwargs: Keyword arguments containing: 

215 - returns: Matrix of returns with shape (n, num_assets). 

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

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

218 

219 Example: 

220 >>> import numpy as np 

221 >>> from cvx.risk.cvar import CVar 

222 >>> model = CVar(alpha=0.95, n=50, m=5) 

223 >>> # Update with 3 assets (less than maximum of 5) 

224 >>> np.random.seed(42) 

225 >>> returns = np.random.randn(50, 3) 

226 >>> model.update( 

227 ... returns=returns, 

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

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

230 ... ) 

231 >>> model.parameter["R"].value[:, :3].shape 

232 (50, 3) 

233 

234 """ 

235 ret = kwargs["returns"] 

236 num_assets = ret.shape[1] 

237 

238 returns_arr = np.zeros((self.n, self.m)) 

239 returns_arr[:, :num_assets] = ret 

240 self.parameter["R"].value = returns_arr 

241 self.bounds.update(**kwargs) 

242 

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

244 """Return constraints for the CVaR model. 

245 

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

247 

248 Args: 

249 weights: CVXPY variable representing portfolio weights. 

250 **kwargs: Additional keyword arguments passed to bounds.constraints(). 

251 

252 Returns: 

253 List of CVXPY constraints from the bounds object. 

254 

255 Example: 

256 >>> import cvxpy as cp 

257 >>> import numpy as np 

258 >>> from cvx.risk.cvar import CVar 

259 >>> model = CVar(alpha=0.95, n=50, m=3) 

260 >>> np.random.seed(42) 

261 >>> returns = np.random.randn(50, 3) 

262 >>> model.update( 

263 ... returns=returns, 

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

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

266 ... ) 

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

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

269 >>> len(constraints) 

270 2 

271 

272 """ 

273 return self.bounds.constraints(weights)