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
« prev ^ index » next coverage.py v7.14.1, created at 2026-07-22 10:34 +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 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).
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
100 """
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)
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)
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
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
129 def solve(self) -> None:
130 """Build the Clarabel problem from current parameter values and solve it.
132 Updates the ``value`` and ``status`` attributes, and populates
133 ``weights.value`` (and ``y.value`` for FactorModel) with the solution.
135 After calling ``solve()``, you can update the model parameters and call
136 ``solve()`` again without reconstructing the problem structure.
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.
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
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
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.
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.
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.
198 Returns:
199 A :class:`MinRiskProblem` object. Call :meth:`MinRiskProblem.solve` to
200 solve it and populate ``weights.value``.
202 Example:
203 Basic minimum risk portfolio:
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
222 With base portfolio (tracking error minimization):
224 >>> benchmark = np.array([0.5, 0.5])
225 >>> problem = minrisk_problem(model, weights, base=benchmark)
226 >>> problem.solve()
228 With custom constraints (at least 30% in first asset):
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
236 """
237 return MinRiskProblem(
238 riskmodel=riskmodel,
239 weights=weights,
240 base=base,
241 _extra_constraints=constraints or [],
242 _kwargs=kwargs,
243 )