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
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-20 05:22 +0000
1"""Minimum risk portfolio optimization.
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.
7Example:
8 Create and solve a minimum risk portfolio problem:
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
30"""
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
38from dataclasses import dataclass, field
39from typing import Any, Protocol
41import numpy as np
43from cvx.core import Variable
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]
50class _SolvableModel(Protocol):
51 """Protocol for risk models that support direct Clarabel solving."""
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 ...
64@dataclass
65class MinRiskProblem:
66 """A minimum-risk portfolio optimization problem solved with Clarabel.
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``.
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).
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
101 """
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)
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)
113 def __post_init__(self) -> None:
114 """Validate the constraints and kwargs, and store the optional y Variable.
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``.
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
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)
141 def _get_base_array(self) -> np.ndarray:
142 """Return the base portfolio as a numpy array of length weights.n.
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.
148 Raises:
149 ValueError: If an array base is longer than ``weights.n``.
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
163 def solve(self) -> None:
164 """Build the Clarabel problem from current parameter values and solve it.
166 Updates the ``value`` and ``status`` attributes, and populates
167 ``weights.value`` (and ``y.value`` for FactorModel) with the solution.
169 After calling ``solve()``, you can update the model parameters and call
170 ``solve()`` again without reconstructing the problem structure.
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.
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
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
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.
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.
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.
236 Returns:
237 A :class:`MinRiskProblem` object. Call :meth:`MinRiskProblem.solve` to
238 solve it and populate ``weights.value``.
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``.
246 Example:
247 Basic minimum risk portfolio:
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
266 With base portfolio (tracking error minimization):
268 >>> benchmark = np.array([0.5, 0.5])
269 >>> problem = minrisk_problem(model, weights, base=benchmark)
270 >>> problem.solve()
272 With custom constraints (at least 30% in first asset):
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
280 """
281 return MinRiskProblem(
282 riskmodel=riskmodel,
283 weights=weights,
284 base=base,
285 _extra_constraints=constraints or [],
286 _kwargs=kwargs,
287 )