Coverage for src / cvx / risk / portfolio / min_risk.py: 100%

36 statements  

« prev     ^ index     » next       coverage.py v7.14.0, created at 2026-05-13 06:46 +0000

1"""Minimum risk portfolio optimization. 

2 

3This module provides functions for creating and solving minimum risk portfolio 

4optimization problems using various risk models. Problems are solved directly 

5with the Clarabel conic solver, without using cvxpy. 

6 

7Example: 

8 Create and solve a minimum risk portfolio problem: 

9 

10 >>> import numpy as np 

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

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

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

14 >>> # Create risk model 

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

16 >>> model.update( 

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

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

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

20 ... ) 

21 >>> # Create optimization problem 

22 >>> weights = Variable(3) 

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

24 >>> # Solve the problem 

25 >>> problem.solve() 

26 >>> # Optimal weights sum to 1 

27 >>> bool(np.isclose(np.sum(weights.value), 1.0)) 

28 True 

29 

30""" 

31 

32# Copyright 2023 Stanford University Convex Optimization Group 

33# 

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

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

36# You may obtain a copy of the License at 

37# 

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

39# 

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

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

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

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

44# limitations under the License. 

45from __future__ import annotations 

46 

47from dataclasses import dataclass, field 

48from typing import Any 

49 

50import numpy as np 

51 

52from cvx.core import Model, Variable 

53 

54# Type alias for user-supplied linear constraints: (a, lb, ub) 

55# meaning lb <= a @ w <= ub. Use None for one-sided bounds. 

56LinearConstraint = tuple[np.ndarray, float | None, float | None] 

57 

58 

59@dataclass 

60class MinRiskProblem: 

61 """A minimum-risk portfolio optimization problem solved with Clarabel. 

62 

63 This class stores the problem structure and allows the problem to be 

64 solved (and re-solved after parameter updates) via the :meth:`solve` method. 

65 After solving, the optimal weights are available via the ``weights`` variable's 

66 ``value`` attribute, and the optimal risk value is available via ``value``. 

67 

68 Attributes: 

69 riskmodel: The risk model defining portfolio risk. 

70 weights: Variable that will hold the optimal weights after solving. 

71 base: Base portfolio (numpy array or 0.0). The problem minimizes the 

72 risk of ``weights - base``. 

73 value: Optimal objective value after solving (None before solving). 

74 status: Solver status string after solving (None before solving). 

75 

76 Example: 

77 >>> import numpy as np 

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

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

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

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

82 >>> model.update( 

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

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

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

86 ... ) 

87 >>> weights = Variable(2) 

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

89 >>> problem.solve() 

90 >>> problem.status 

91 'Solved' 

92 >>> bool(np.isclose(np.sum(weights.value), 1.0)) 

93 True 

94 

95 """ 

96 

97 riskmodel: Model 

98 weights: Variable 

99 base: Any = 0.0 

100 _extra_constraints: list[LinearConstraint] = field(default_factory=list) 

101 _kwargs: dict[str, Any] = field(default_factory=dict) 

102 

103 value: float | None = field(default=None, init=False) 

104 status: str | None = field(default=None, init=False) 

105 _y_var: Variable | None = field(default=None, init=False) 

106 

107 def __post_init__(self) -> None: 

108 """Extract and store the optional y Variable from kwargs.""" 

109 y = self._kwargs.get("y") 

110 if isinstance(y, Variable): 

111 self._y_var = y 

112 

113 def _get_base_array(self) -> np.ndarray: 

114 """Return the base portfolio as a numpy array of length weights.n.""" 

115 n = self.weights.n 

116 if isinstance(self.base, (int, float)) and self.base == 0: 

117 return np.zeros(n) 

118 base = np.asarray(self.base) 

119 result = np.zeros(n) 

120 m = min(len(base), n) 

121 result[:m] = base[:m] 

122 return result 

123 

124 def solve(self) -> None: 

125 """Build the Clarabel problem from current parameter values and solve it. 

126 

127 Updates the ``value`` and ``status`` attributes, and populates 

128 ``weights.value`` (and ``y.value`` for FactorModel) with the solution. 

129 

130 After calling ``solve()``, you can update the model parameters and call 

131 ``solve()`` again without reconstructing the problem structure. 

132 

133 Example: 

134 >>> import numpy as np 

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

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

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

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

139 >>> weights = Variable(2) 

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

141 >>> model.update( 

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

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

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

145 ... ) 

146 >>> problem.solve() 

147 >>> bool('Solved' in problem.status) 

148 True 

149 

150 """ 

151 base = self._get_base_array() 

152 obj, _risk, status = self.riskmodel.solve_minrisk(self.weights, base, self._extra_constraints, self._y_var) 

153 self.value = obj 

154 self.status = status 

155 

156 

157def minrisk_problem( 

158 riskmodel: Model, 

159 weights: Variable, 

160 base: Any = 0.0, 

161 constraints: list[LinearConstraint] | None = None, 

162 **kwargs: Any, 

163) -> MinRiskProblem: 

164 """Create a minimum-risk portfolio optimization problem. 

165 

166 This function creates a :class:`MinRiskProblem` that minimizes portfolio 

167 risk subject to standard constraints (weights sum to 1, weight bounds from 

168 the model) plus any user-supplied linear constraints. The problem is solved 

169 directly with Clarabel. 

170 

171 Args: 

172 riskmodel: A risk model implementing the :class:`~cvx.core.model.Model` 

173 interface. Supported types: :class:`~cvx.risk.sample.SampleCovariance`, 

174 :class:`~cvx.risk.factor.FactorModel`, 

175 :class:`~cvx.risk.cvar.CVar`. 

176 weights: :class:`~cvx.risk.variable.Variable` that will hold the optimal 

177 weights after calling :meth:`MinRiskProblem.solve`. 

178 base: Base portfolio for tracking-error minimization. Can be a numpy array 

179 of length ``weights.n`` or a scalar (default 0.0 means no base). 

180 constraints: Optional list of linear constraints on portfolio weights. 

181 Each constraint is a tuple ``(a, lb, ub)`` specifying 

182 ``lb <= a @ w <= ub``. Use ``None`` for one-sided bounds. 

183 For an equality constraint use ``lb == ub``. 

184 **kwargs: Additional keyword arguments. For :class:`~cvx.risk.factor.FactorModel`, 

185 pass ``y=Variable(k)`` to expose the factor-exposure solution. 

186 

187 Returns: 

188 A :class:`MinRiskProblem` object. Call :meth:`MinRiskProblem.solve` to 

189 solve it and populate ``weights.value``. 

190 

191 Example: 

192 Basic minimum risk portfolio: 

193 

194 >>> import numpy as np 

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

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

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

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

199 >>> model.update( 

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

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

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

203 ... ) 

204 >>> weights = Variable(2) 

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

206 >>> problem.solve() 

207 >>> # Lower variance asset gets higher weight 

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

209 True 

210 

211 With base portfolio (tracking error minimization): 

212 

213 >>> benchmark = np.array([0.5, 0.5]) 

214 >>> problem = minrisk_problem(model, weights, base=benchmark) 

215 >>> problem.solve() 

216 

217 With custom constraints (at least 30% in first asset): 

218 

219 >>> custom_constraints = [(np.array([1, 0]), 0.3, None)] 

220 >>> problem = minrisk_problem(model, weights, constraints=custom_constraints) 

221 >>> problem.solve() 

222 >>> bool(weights.value[0] >= 0.3 - 1e-6) 

223 True 

224 

225 """ 

226 return MinRiskProblem( 

227 riskmodel=riskmodel, 

228 weights=weights, 

229 base=base, 

230 _extra_constraints=constraints or [], 

231 _kwargs=kwargs, 

232 )