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
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-13 06:46 +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 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
47from dataclasses import dataclass, field
48from typing import Any
50import numpy as np
52from cvx.core import Model, Variable
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]
59@dataclass
60class MinRiskProblem:
61 """A minimum-risk portfolio optimization problem solved with Clarabel.
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``.
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).
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
95 """
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)
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)
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
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
124 def solve(self) -> None:
125 """Build the Clarabel problem from current parameter values and solve it.
127 Updates the ``value`` and ``status`` attributes, and populates
128 ``weights.value`` (and ``y.value`` for FactorModel) with the solution.
130 After calling ``solve()``, you can update the model parameters and call
131 ``solve()`` again without reconstructing the problem structure.
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
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
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.
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.
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.
187 Returns:
188 A :class:`MinRiskProblem` object. Call :meth:`MinRiskProblem.solve` to
189 solve it and populate ``weights.value``.
191 Example:
192 Basic minimum risk portfolio:
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
211 With base portfolio (tracking error minimization):
213 >>> benchmark = np.array([0.5, 0.5])
214 >>> problem = minrisk_problem(model, weights, base=benchmark)
215 >>> problem.solve()
217 With custom constraints (at least 30% in first asset):
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
225 """
226 return MinRiskProblem(
227 riskmodel=riskmodel,
228 weights=weights,
229 base=base,
230 _extra_constraints=constraints or [],
231 _kwargs=kwargs,
232 )