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

53 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-20 05:22 +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 (c) 2025 Jebel Quant Research 

33# 

34# Licensed under the MIT License. See the LICENSE file in the project root 

35# for the full license text. 

36from __future__ import annotations 

37 

38from dataclasses import dataclass, field 

39from typing import Any, Protocol 

40 

41import numpy as np 

42 

43from cvx.core import Variable 

44 

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

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

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

48 

49 

50class _SolvableModel(Protocol): 

51 """Protocol for risk models that support direct Clarabel solving.""" 

52 

53 def solve_minrisk( 

54 self, 

55 weights: Variable, 

56 base: np.ndarray, 

57 extra_constraints: list[LinearConstraint], 

58 y_var: Variable | None = None, 

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

60 """Solve the minimum-risk problem and return (objective, risk, status).""" 

61 ... 

62 

63 

64@dataclass 

65class MinRiskProblem: 

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

67 

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

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

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

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

72 

73 Attributes: 

74 riskmodel: The risk model defining portfolio risk. 

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

76 base: Base portfolio (numpy array or scalar). The problem minimizes the 

77 risk of ``weights - base``. A scalar is broadcast to every asset; 

78 a shorter array is zero-padded, a longer one is rejected. 

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

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

81 

82 Example: 

83 >>> import numpy as np 

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

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

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

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

88 >>> model.update( 

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

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

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

92 ... ) 

93 >>> weights = Variable(2) 

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

95 >>> problem.solve() 

96 >>> problem.status 

97 'Solved' 

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

99 True 

100 

101 """ 

102 

103 riskmodel: _SolvableModel 

104 weights: Variable 

105 base: Any = 0.0 

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

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

108 

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

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

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

112 

113 def __post_init__(self) -> None: 

114 """Validate the constraints and kwargs, and store the optional y Variable. 

115 

116 Raises: 

117 TypeError: If kwargs holds any key other than ``y``, or ``y`` is not 

118 a :class:`~cvx.core.variable.Variable`. 

119 ValueError: If a constraint's coefficient vector does not have 

120 length ``weights.n``. 

121 

122 """ 

123 unexpected = sorted(set(self._kwargs) - {"y"}) 

124 if unexpected: 

125 msg = f"minrisk_problem() got unexpected keyword argument(s): {', '.join(map(repr, unexpected))}" 

126 raise TypeError(msg) 

127 if "y" in self._kwargs: 

128 y = self._kwargs["y"] 

129 if not isinstance(y, Variable): 

130 msg = f"y must be a Variable, got {type(y).__name__}" 

131 raise TypeError(msg) 

132 self._y_var = y 

133 

134 n = self.weights.n 

135 for index, (coeffs, _, _) in enumerate(self._extra_constraints): 

136 length = np.asarray(coeffs).size 

137 if length != n: 

138 msg = f"constraint {index} has {length} coefficients but weights has dimension {n}" 

139 raise ValueError(msg) 

140 

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

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

143 

144 A scalar base is broadcast to every asset. An array base shorter than 

145 ``weights.n`` is zero-padded on purpose: the risk models accept fewer 

146 active assets than their capacity, and the padded slots stay zero. 

147 

148 Raises: 

149 ValueError: If an array base is longer than ``weights.n``. 

150 

151 """ 

152 n = self.weights.n 

153 base = np.asarray(self.base, dtype=float) 

154 if base.ndim == 0: 

155 return np.full(n, float(base)) 

156 if len(base) > n: 

157 msg = f"base has length {len(base)} but weights has dimension {n}" 

158 raise ValueError(msg) 

159 result = np.zeros(n) 

160 result[: len(base)] = base 

161 return result 

162 

163 def solve(self) -> None: 

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

165 

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

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

168 

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

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

171 

172 Failure contract: ``solve()`` does not raise when the problem cannot be 

173 solved (e.g. it is infeasible or unbounded). Instead, ``status`` is set 

174 to the solver status, ``value`` stays ``None``, and ``weights.value`` 

175 is left untouched. Always check ``status`` (or ``value is not None``) 

176 before using the weights. 

177 

178 Example: 

179 >>> import numpy as np 

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

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

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

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

184 >>> weights = Variable(2) 

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

186 >>> model.update( 

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

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

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

190 ... ) 

191 >>> problem.solve() 

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

193 True 

194 

195 """ 

196 base = self._get_base_array() 

197 obj, _, status = self.riskmodel.solve_minrisk(self.weights, base, self._extra_constraints, self._y_var) 

198 self.value = obj 

199 self.status = status 

200 

201 

202def minrisk_problem( 

203 riskmodel: _SolvableModel, 

204 weights: Variable, 

205 base: Any = 0.0, 

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

207 **kwargs: Any, 

208) -> MinRiskProblem: 

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

210 

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

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

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

214 directly with Clarabel. 

215 

216 Args: 

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

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

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

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

221 weights: :class:`~cvx.core.variable.Variable` that will hold the optimal 

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

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

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

225 A scalar is broadcast to every asset; an array shorter than 

226 ``weights.n`` is zero-padded, and a longer one raises ``ValueError`` 

227 when the problem is solved. 

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

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

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

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

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

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

234 Any other keyword is rejected. 

235 

236 Returns: 

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

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

239 

240 Raises: 

241 TypeError: If an unexpected keyword is passed, or ``y`` is not a 

242 :class:`~cvx.core.variable.Variable`. 

243 ValueError: If a constraint's coefficient vector does not have length 

244 ``weights.n``. 

245 

246 Example: 

247 Basic minimum risk portfolio: 

248 

249 >>> import numpy as np 

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

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

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

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

254 >>> model.update( 

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

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

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

258 ... ) 

259 >>> weights = Variable(2) 

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

261 >>> problem.solve() 

262 >>> # Lower variance asset gets higher weight 

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

264 True 

265 

266 With base portfolio (tracking error minimization): 

267 

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

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

270 >>> problem.solve() 

271 

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

273 

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

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

276 >>> problem.solve() 

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

278 True 

279 

280 """ 

281 return MinRiskProblem( 

282 riskmodel=riskmodel, 

283 weights=weights, 

284 base=base, 

285 _extra_constraints=constraints or [], 

286 _kwargs=kwargs, 

287 )