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

103 statements  

« prev     ^ index     » next       coverage.py v7.14.0, created at 2026-05-13 06:46 +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 numpy as np 

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

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

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

16 >>> # Generate sample returns 

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

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

19 >>> # Update model with returns data 

20 >>> model.update( 

21 ... returns=returns, 

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

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

24 ... ) 

25 >>> # The model is ready for use 

26 >>> w = np.ones(5) / 5 

27 >>> risk = model.estimate(w) 

28 >>> isinstance(risk, float) 

29 True 

30 

31""" 

32 

33# Copyright 2023 Stanford University Convex Optimization Group 

34# 

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

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

37# You may obtain a copy of the License at 

38# 

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

40# 

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

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

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

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

45# limitations under the License. 

46from __future__ import annotations 

47 

48from dataclasses import dataclass 

49from typing import Any 

50 

51import clarabel 

52import numpy as np 

53from scipy import sparse 

54 

55from cvx.core import Bounds, Model, Parameter, Variable 

56 

57 

58@dataclass 

59class CVar(Model): 

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

61 

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

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

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

65 

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

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

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

69 

70 Attributes: 

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

72 focusing on more extreme tail events. 

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

74 m: Maximum number of assets in the portfolio. 

75 

76 Example: 

77 Basic CVaR model setup: 

78 

79 >>> import numpy as np 

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

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

82 >>> from cvx.core.variable import Variable 

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

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

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

86 >>> model.k 

87 2 

88 >>> # Generate sample returns 

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

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

91 >>> model.update( 

92 ... returns=returns, 

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

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

95 ... ) 

96 >>> # Create and solve optimization 

97 >>> weights = Variable(3) 

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

99 >>> problem.solve() 

100 

101 Mathematical verification of CVaR calculation: 

102 

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

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

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

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

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

108 >>> model.update( 

109 ... returns=returns, 

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

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

112 ... ) 

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

114 >>> model.k 

115 1 

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

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

118 >>> cvar = model.estimate(w) 

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

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

121 True 

122 

123 Different alpha values affect the tail focus: 

124 

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

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

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

128 5 

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

130 >>> model_75.k # 25 worst scenarios 

131 25 

132 

133 """ 

134 

135 alpha: float = 0.95 

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

137 

138 n: int = 0 

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

140 

141 m: int = 0 

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

143 

144 def __post_init__(self) -> None: 

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

146 

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

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

149 

150 Example: 

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

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

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

154 >>> model.k 

155 5 

156 >>> # Returns parameter is created 

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

158 (100, 5) 

159 

160 """ 

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

162 self.parameter["R"] = Parameter(shape=(self.n, self.m), name="returns") 

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

164 

165 def estimate(self, weights: np.ndarray, **kwargs: Any) -> float: 

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

167 

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

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

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

171 

172 Args: 

173 weights: Numpy array representing portfolio weights. 

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

175 

176 Returns: 

177 Float representing the CVaR (expected tail loss). 

178 

179 Example: 

180 >>> import numpy as np 

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

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

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

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

185 >>> model.update( 

186 ... returns=returns, 

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

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

189 ... ) 

190 >>> w = np.array([1/3, 1/3, 1/3]) 

191 >>> cvar = model.estimate(w) 

192 >>> isinstance(cvar, float) 

193 True 

194 

195 """ 

196 portfolio_returns = self.parameter["R"].value @ np.asarray(weights) 

197 sorted_returns = np.sort(portfolio_returns) 

198 # Take the k smallest (worst) returns and average them 

199 return float(-np.mean(sorted_returns[: self.k])) 

200 

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

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

203 

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

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

206 first columns are updated. 

207 

208 Args: 

209 **kwargs: Keyword arguments containing: 

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

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

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

213 

214 Example: 

215 >>> import numpy as np 

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

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

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

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

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

221 >>> model.update( 

222 ... returns=returns, 

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

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

225 ... ) 

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

227 (50, 3) 

228 

229 """ 

230 ret = kwargs["returns"] 

231 num_assets = ret.shape[1] 

232 

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

234 returns_arr[:, :num_assets] = ret 

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

236 self.bounds.update(**kwargs) 

237 

238 def solve_minrisk( 

239 self, 

240 weights: Variable, 

241 base: np.ndarray, 

242 extra_constraints: list[tuple[np.ndarray, float | None, float | None]], 

243 y_var: Variable | None = None, 

244 ) -> tuple[float | None, float | None, str]: 

245 """Build and solve the Clarabel LP for this model.""" 

246 n = weights.n 

247 T = self.n # noqa: N806 

248 k = self.k 

249 R = self.parameter["R"].value # noqa: N806 

250 lb_w, ub_w = self.bounds.get_bounds() 

251 

252 R_n = R[:, :n] # noqa: N806 

253 n_vars = n + 1 + T 

254 P = sparse.csc_matrix((n_vars, n_vars)) # noqa: N806 

255 q = np.zeros(n_vars) 

256 q[n] = 1.0 

257 q[n + 1 :] = 1.0 / k 

258 

259 A_rows: list[np.ndarray] = [] # noqa: N806 

260 b_rows: list[np.ndarray] = [] 

261 cones: list[Any] = [] 

262 

263 A_cvar = np.zeros((T, n_vars)) # noqa: N806 

264 A_cvar[:, :n] = -R_n 

265 A_cvar[:, n] = -1.0 

266 A_cvar[:, n + 1 :] = -np.eye(T) 

267 A_rows.append(A_cvar) 

268 b_rows.append(R_n @ base) 

269 cones.append(clarabel.NonnegativeConeT(T)) 

270 

271 A_u = np.zeros((T, n_vars)) # noqa: N806 

272 A_u[:, n + 1 :] = -np.eye(T) 

273 A_rows.append(A_u) 

274 b_rows.append(np.zeros(T)) 

275 cones.append(clarabel.NonnegativeConeT(T)) 

276 

277 A_eq = np.zeros((1, n_vars)) # noqa: N806 

278 A_eq[0, :n] = 1.0 

279 A_rows.append(A_eq) 

280 b_rows.append(np.array([1.0])) 

281 cones.append(clarabel.ZeroConeT(1)) 

282 

283 A_lb = np.zeros((n, n_vars)) # noqa: N806 

284 A_lb[:, :n] = -np.eye(n) 

285 A_rows.append(A_lb) 

286 b_rows.append(-lb_w[:n]) 

287 cones.append(clarabel.NonnegativeConeT(n)) 

288 

289 A_ub = np.zeros((n, n_vars)) # noqa: N806 

290 A_ub[:, :n] = np.eye(n) 

291 A_rows.append(A_ub) 

292 b_rows.append(ub_w[:n]) 

293 cones.append(clarabel.NonnegativeConeT(n)) 

294 

295 for a, lb_val, ub_val in extra_constraints: 

296 a = np.asarray(a) 

297 if lb_val is not None and ub_val is not None and lb_val == ub_val: 

298 A_extra = np.zeros((1, n_vars)) # noqa: N806 

299 A_extra[0, :n] = a 

300 A_rows.append(A_extra) 

301 b_rows.append(np.array([lb_val])) 

302 cones.append(clarabel.ZeroConeT(1)) 

303 else: 

304 if lb_val is not None: 

305 A_extra = np.zeros((1, n_vars)) # noqa: N806 

306 A_extra[0, :n] = -a 

307 A_rows.append(A_extra) 

308 b_rows.append(np.array([-lb_val])) 

309 cones.append(clarabel.NonnegativeConeT(1)) 

310 if ub_val is not None: 

311 A_extra = np.zeros((1, n_vars)) # noqa: N806 

312 A_extra[0, :n] = a 

313 A_rows.append(A_extra) 

314 b_rows.append(np.array([ub_val])) 

315 cones.append(clarabel.NonnegativeConeT(1)) 

316 

317 A = sparse.csc_matrix(np.vstack(A_rows)) # noqa: N806 

318 b = np.concatenate(b_rows) 

319 

320 settings = clarabel.DefaultSettings() 

321 settings.verbose = False 

322 sol = clarabel.DefaultSolver(P, q, A, b, cones, settings).solve() 

323 status = str(sol.status) 

324 

325 if "Solved" in status: 

326 weights.value = np.array(sol.x[:n]) 

327 cvar_val = float(q @ sol.x) 

328 return cvar_val, cvar_val, status 

329 return None, None, status