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

38 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-07-22 10:34 +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 0.0). The problem minimizes the 

77 risk of ``weights - base``. 

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

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

80 

81 Example: 

82 >>> import numpy as np 

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

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

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

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

87 >>> model.update( 

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

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

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

91 ... ) 

92 >>> weights = Variable(2) 

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

94 >>> problem.solve() 

95 >>> problem.status 

96 'Solved' 

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

98 True 

99 

100 """ 

101 

102 riskmodel: _SolvableModel 

103 weights: Variable 

104 base: Any = 0.0 

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

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

107 

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

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

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

111 

112 def __post_init__(self) -> None: 

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

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

115 if isinstance(y, Variable): 

116 self._y_var = y 

117 

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

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

120 n = self.weights.n 

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

122 return np.zeros(n) 

123 base = np.asarray(self.base) 

124 result = np.zeros(n) 

125 prefix_length = min(len(base), n) 

126 result[:prefix_length] = base[:prefix_length] 

127 return result 

128 

129 def solve(self) -> None: 

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

131 

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

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

134 

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

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

137 

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

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

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

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

142 before using the weights. 

143 

144 Example: 

145 >>> import numpy as np 

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

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

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

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

150 >>> weights = Variable(2) 

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

152 >>> model.update( 

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

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

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

156 ... ) 

157 >>> problem.solve() 

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

159 True 

160 

161 """ 

162 base = self._get_base_array() 

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

164 self.value = obj 

165 self.status = status 

166 

167 

168def minrisk_problem( 

169 riskmodel: _SolvableModel, 

170 weights: Variable, 

171 base: Any = 0.0, 

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

173 **kwargs: Any, 

174) -> MinRiskProblem: 

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

176 

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

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

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

180 directly with Clarabel. 

181 

182 Args: 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

197 

198 Returns: 

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

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

201 

202 Example: 

203 Basic minimum risk portfolio: 

204 

205 >>> import numpy as np 

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

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

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

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

210 >>> model.update( 

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

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

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

214 ... ) 

215 >>> weights = Variable(2) 

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

217 >>> problem.solve() 

218 >>> # Lower variance asset gets higher weight 

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

220 True 

221 

222 With base portfolio (tracking error minimization): 

223 

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

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

226 >>> problem.solve() 

227 

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

229 

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

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

232 >>> problem.solve() 

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

234 True 

235 

236 """ 

237 return MinRiskProblem( 

238 riskmodel=riskmodel, 

239 weights=weights, 

240 base=base, 

241 _extra_constraints=constraints or [], 

242 _kwargs=kwargs, 

243 )