Coverage for src / cvx / risk / bounds.py: 100%
30 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-09 03:39 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-09 03:39 +0000
1# Copyright 2023 Stanford University Convex Optimization Group
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14"""Bounds for portfolio optimization.
16This module provides the Bounds class for defining and enforcing lower and upper
17bounds on portfolio weights or other variables in optimization problems.
19Example:
20 Create bounds for a portfolio and use them as constraints:
22 >>> import cvxpy as cp
23 >>> import numpy as np
24 >>> from cvx.risk.bounds import Bounds
25 >>> # Create bounds for 3 assets
26 >>> bounds = Bounds(m=3, name="assets")
27 >>> # Update bounds with actual values
28 >>> bounds.update(
29 ... lower_assets=np.array([0.0, 0.1, 0.0]),
30 ... upper_assets=np.array([0.5, 0.4, 0.3])
31 ... )
32 >>> # Create constraints
33 >>> weights = cp.Variable(3)
34 >>> constraints = bounds.constraints(weights)
35 >>> len(constraints)
36 2
38"""
40from __future__ import annotations
42from dataclasses import dataclass
43from typing import Any
45import cvxpy as cp
46import numpy as np
48from .model import Model
51@dataclass
52class Bounds(Model):
53 """Representation of bounds for a model, defining constraints and parameters.
55 This dataclass provides functionality to establish and manage bounds for a model.
56 It includes methods to handle bound parameters, update them dynamically, and
57 generate constraints that can be used in optimization models.
59 The Bounds class creates CVXPY Parameter objects for lower and upper bounds,
60 which can be updated without reconstructing the optimization problem.
62 Attributes:
63 m: Maximum number of bounds (e.g., number of assets or factors).
64 name: Name for the bounds used in parameter naming (e.g., "assets" or "factors").
66 Example:
67 Create and use bounds for portfolio weights:
69 >>> import cvxpy as cp
70 >>> import numpy as np
71 >>> from cvx.risk.bounds import Bounds
72 >>> # Create bounds with capacity for 5 assets
73 >>> bounds = Bounds(m=5, name="assets")
74 >>> # Initialize with actual bounds (can be smaller than m)
75 >>> bounds.update(
76 ... lower_assets=np.array([0.0, 0.0, 0.1]),
77 ... upper_assets=np.array([0.5, 0.5, 0.4])
78 ... )
79 >>> # Check parameter values
80 >>> bounds.parameter["lower_assets"].value[:3]
81 array([0. , 0. , 0.1])
82 >>> bounds.parameter["upper_assets"].value[:3]
83 array([0.5, 0.5, 0.4])
85 Bounds can be used with different variable types (factors, sectors, etc.):
87 >>> factor_bounds = Bounds(m=3, name="factors")
88 >>> factor_bounds.update(
89 ... lower_factors=np.array([-0.1, -0.2, -0.15]),
90 ... upper_factors=np.array([0.1, 0.2, 0.15])
91 ... )
92 >>> # Factor exposure variable
93 >>> y = cp.Variable(3)
94 >>> factor_constraints = factor_bounds.constraints(y)
95 >>> len(factor_constraints)
96 2
98 Verify bounds are enforced correctly in optimization:
100 >>> weights = cp.Variable(5)
101 >>> bounds.update(
102 ... lower_assets=np.array([0.3, 0.0, 0.0, 0.0, 0.0]),
103 ... upper_assets=np.array([0.5, 0.2, 0.2, 0.2, 0.2])
104 ... )
105 >>> prob = cp.Problem(
106 ... cp.Minimize(weights[0]), # Minimize first weight
107 ... bounds.constraints(weights) + [cp.sum(weights) == 1.0]
108 ... )
109 >>> _ = prob.solve(solver="CLARABEL")
110 >>> # First weight should be at lower bound (0.3)
111 >>> bool(np.isclose(weights.value[0], 0.3, atol=1e-4))
112 True
114 """
116 m: int = 0
117 """Maximum number of bounds (e.g., number of assets)."""
119 name: str = ""
120 """Name for the bounds, used in parameter naming (e.g., 'assets' or 'factors')."""
122 def estimate(self, weights: cp.Variable, **kwargs: Any) -> cp.Expression:
123 """No estimation for bounds.
125 Bounds do not provide a risk estimate; they only provide constraints.
126 This method raises NotImplementedError.
128 Args:
129 weights: CVXPY variable representing portfolio weights.
130 **kwargs: Additional keyword arguments.
132 Raises:
133 NotImplementedError: Always raised as bounds do not provide risk estimates.
135 Example:
136 >>> import cvxpy as cp
137 >>> from cvx.risk.bounds import Bounds
138 >>> bounds = Bounds(m=3, name="assets")
139 >>> weights = cp.Variable(3)
140 >>> try:
141 ... bounds.estimate(weights)
142 ... except NotImplementedError:
143 ... print("estimate not implemented for Bounds")
144 estimate not implemented for Bounds
146 """
147 raise NotImplementedError("No estimation for bounds")
149 def _f(self, str_prefix: str) -> str:
150 """Create a parameter name by appending the name attribute.
152 This internal method creates consistent parameter names by combining
153 a prefix with the bounds name (e.g., "lower_assets" or "upper_factors").
155 Args:
156 str_prefix: Base string for the parameter name (e.g., "lower" or "upper").
158 Returns:
159 Combined parameter name in the format "{str_prefix}_{self.name}".
161 Example:
162 >>> from cvx.risk.bounds import Bounds
163 >>> bounds = Bounds(m=3, name="assets")
164 >>> bounds._f("lower")
165 'lower_assets'
166 >>> bounds._f("upper")
167 'upper_assets'
169 """
170 return f"{str_prefix}_{self.name}"
172 def __post_init__(self) -> None:
173 """Initialize the parameters after the class is instantiated.
175 Creates lower and upper bound CVXPY Parameter objects with appropriate
176 shapes and default values. Lower bounds default to zeros, upper bounds
177 default to ones.
179 Example:
180 >>> from cvx.risk.bounds import Bounds
181 >>> bounds = Bounds(m=3, name="assets")
182 >>> # Parameters are automatically created
183 >>> bounds.parameter["lower_assets"].shape
184 (3,)
185 >>> bounds.parameter["upper_assets"].shape
186 (3,)
188 """
189 self.parameter[self._f("lower")] = cp.Parameter(
190 shape=self.m,
191 name="lower bound",
192 value=np.zeros(self.m),
193 )
194 self.parameter[self._f("upper")] = cp.Parameter(
195 shape=self.m,
196 name="upper bound",
197 value=np.ones(self.m),
198 )
200 def update(self, **kwargs: Any) -> None:
201 """Update the lower and upper bound parameters.
203 This method updates the bound parameters with new values. The input
204 arrays can be shorter than m, in which case remaining values are set
205 to zero.
207 Args:
208 **kwargs: Keyword arguments containing lower and upper bounds
209 with keys formatted as "{lower/upper}_{self.name}".
211 Example:
212 >>> import numpy as np
213 >>> from cvx.risk.bounds import Bounds
214 >>> bounds = Bounds(m=5, name="assets")
215 >>> # Update with bounds for only 3 assets
216 >>> bounds.update(
217 ... lower_assets=np.array([0.0, 0.1, 0.2]),
218 ... upper_assets=np.array([0.5, 0.4, 0.3])
219 ... )
220 >>> bounds.parameter["lower_assets"].value[:3]
221 array([0. , 0.1, 0.2])
223 """
224 lower = kwargs[self._f("lower")]
225 lower_arr = np.zeros(self.m)
226 lower_arr[: len(lower)] = lower
227 self.parameter[self._f("lower")].value = lower_arr
229 upper = kwargs[self._f("upper")]
230 upper_arr = np.zeros(self.m)
231 upper_arr[: len(upper)] = upper
232 self.parameter[self._f("upper")].value = upper_arr
234 def constraints(self, weights: cp.Variable, **kwargs: Any) -> list[cp.Constraint]:
235 """Return constraints that enforce the bounds on weights.
237 Creates CVXPY constraints that enforce the lower and upper bounds
238 on the weights variable.
240 Args:
241 weights: CVXPY variable representing portfolio weights.
242 **kwargs: Additional keyword arguments (not used).
244 Returns:
245 List of two CVXPY constraints: lower bound and upper bound.
247 Example:
248 >>> import cvxpy as cp
249 >>> import numpy as np
250 >>> from cvx.risk.bounds import Bounds
251 >>> bounds = Bounds(m=2, name="assets")
252 >>> bounds.update(
253 ... lower_assets=np.array([0.1, 0.2]),
254 ... upper_assets=np.array([0.6, 0.7])
255 ... )
256 >>> weights = cp.Variable(2)
257 >>> constraints = bounds.constraints(weights)
258 >>> len(constraints)
259 2
261 """
262 return [
263 weights >= self.parameter[self._f("lower")],
264 weights <= self.parameter[self._f("upper")],
265 ]