Coverage for src/cvx/core/bounds.py: 100%

34 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-07-22 10:34 +0000

1# Copyright (c) 2025 Jebel Quant Research 

2# 

3# Licensed under the MIT License. See the LICENSE file in the project root 

4# for the full license text. 

5"""Box constraints for optimization variables. 

6 

7This module provides the :class:`Bounds` class, which tracks lower and upper 

8bound constraints for a named group of variables. It works for any bounded 

9quantity — portfolio weights, factor exposures, sector allocations, etc. 

10 

11Example: 

12 >>> import numpy as np 

13 >>> from cvx.core.bounds import Bounds 

14 >>> bounds = Bounds(m=3, name="assets") 

15 >>> bounds.update( 

16 ... lower_assets=np.array([0.0, 0.1, 0.0]), 

17 ... upper_assets=np.array([0.5, 0.4, 0.3]) 

18 ... ) 

19 >>> lb, ub = bounds.get_bounds() 

20 >>> lb 

21 array([0. , 0.1, 0. ]) 

22 >>> ub 

23 array([0.5, 0.4, 0.3]) 

24 

25""" 

26 

27from __future__ import annotations 

28 

29from dataclasses import dataclass 

30from typing import Any 

31 

32import numpy as np 

33 

34from cvx.core.model import Model 

35from cvx.core.parameter import Parameter 

36 

37 

38@dataclass 

39class Bounds(Model): 

40 """Box constraints for a named group of optimization variables. 

41 

42 Stores lower and upper bounds as :class:`~cvx.core.parameter.Parameter` 

43 objects so they can be updated between solves without rebuilding the 

44 problem structure. The ``name`` attribute identifies the variable group 

45 (e.g. ``"assets"``, ``"factors"``); bound keys are derived as 

46 ``lower_{name}`` / ``upper_{name}``. 

47 

48 Attributes: 

49 m: Capacity — maximum number of variables in the group. 

50 name: Label for the variable group, used to form parameter key names. 

51 

52 Example: 

53 >>> import numpy as np 

54 >>> from cvx.core.bounds import Bounds 

55 >>> bounds = Bounds(m=5, name="assets") 

56 >>> bounds.update( 

57 ... lower_assets=np.array([0.0, 0.0, 0.1]), 

58 ... upper_assets=np.array([0.5, 0.5, 0.4]) 

59 ... ) 

60 >>> bounds.parameter["lower_assets"].value[:3] 

61 array([0. , 0. , 0.1]) 

62 >>> bounds.parameter["upper_assets"].value[:3] 

63 array([0.5, 0.5, 0.4]) 

64 

65 Any variable group name works: 

66 

67 >>> factor_bounds = Bounds(m=3, name="factors") 

68 >>> factor_bounds.update( 

69 ... lower_factors=np.array([-0.1, -0.2, -0.15]), 

70 ... upper_factors=np.array([0.1, 0.2, 0.15]) 

71 ... ) 

72 >>> lb, ub = factor_bounds.get_bounds() 

73 >>> lb 

74 array([-0.1 , -0.2 , -0.15]) 

75 >>> ub 

76 array([0.1 , 0.2 , 0.15]) 

77 

78 """ 

79 

80 m: int = 0 

81 """Capacity — maximum number of variables.""" 

82 

83 name: str = "" 

84 """Label for the variable group.""" 

85 

86 def estimate(self, weights: np.ndarray, **kwargs: Any) -> float: 

87 """Not implemented — ``Bounds`` only provides constraint data. 

88 

89 Args: 

90 weights: Ignored. 

91 **kwargs: Ignored. 

92 

93 Raises: 

94 NotImplementedError: Always. 

95 

96 Example: 

97 >>> import numpy as np 

98 >>> from cvx.core.bounds import Bounds 

99 >>> bounds = Bounds(m=3, name="assets") 

100 >>> try: 

101 ... bounds.estimate(np.zeros(3)) 

102 ... except NotImplementedError: 

103 ... print("estimate not implemented for Bounds") 

104 estimate not implemented for Bounds 

105 

106 """ 

107 raise NotImplementedError("Bounds does not implement estimate") 

108 

109 def _f(self, str_prefix: str) -> str: 

110 """Return the parameter key ``{str_prefix}_{name}``. 

111 

112 Example: 

113 >>> from cvx.core.bounds import Bounds 

114 >>> bounds = Bounds(m=3, name="assets") 

115 >>> bounds._f("lower") 

116 'lower_assets' 

117 >>> bounds._f("upper") 

118 'upper_assets' 

119 

120 """ 

121 return f"{str_prefix}_{self.name}" 

122 

123 def __post_init__(self) -> None: 

124 """Create lower (zeros) and upper (ones) bound parameters. 

125 

126 Example: 

127 >>> from cvx.core.bounds import Bounds 

128 >>> bounds = Bounds(m=3, name="assets") 

129 >>> bounds.parameter["lower_assets"].shape 

130 3 

131 >>> bounds.parameter["upper_assets"].shape 

132 3 

133 

134 """ 

135 self.parameter[self._f("lower")] = Parameter( 

136 shape=self.m, 

137 name="lower bound", 

138 ) 

139 self.parameter[self._f("upper")] = Parameter( 

140 shape=self.m, 

141 name="upper bound", 

142 ) 

143 self.parameter[self._f("upper")].value = np.ones(self.m) 

144 

145 def update(self, **kwargs: Any) -> None: 

146 """Update bound parameters from keyword arguments. 

147 

148 Input arrays shorter than ``m`` are zero-padded on the right. 

149 

150 Args: 

151 **kwargs: Must contain ``lower_{name}`` and ``upper_{name}`` keys 

152 with numpy arrays of length ≤ ``m``. 

153 

154 Raises: 

155 ValueError: If a required key is missing or an array is longer 

156 than ``m``. 

157 

158 Example: 

159 >>> import numpy as np 

160 >>> from cvx.core.bounds import Bounds 

161 >>> bounds = Bounds(m=5, name="assets") 

162 >>> bounds.update( 

163 ... lower_assets=np.array([0.0, 0.1, 0.2]), 

164 ... upper_assets=np.array([0.5, 0.4, 0.3]) 

165 ... ) 

166 >>> bounds.parameter["lower_assets"].value[:3] 

167 array([0. , 0.1, 0.2]) 

168 

169 """ 

170 for key in (self._f("lower"), self._f("upper")): 

171 if key not in kwargs: 

172 msg = f"update() requires a '{key}' argument" 

173 raise ValueError(msg) 

174 values = kwargs[key] 

175 if len(values) > self.m: 

176 msg = f"'{key}' has length {len(values)} but the maximum is {self.m}" 

177 raise ValueError(msg) 

178 arr = np.zeros(self.m) 

179 arr[: len(values)] = values 

180 self.parameter[key].value = arr 

181 

182 def get_bounds(self) -> tuple[np.ndarray, np.ndarray]: 

183 """Return ``(lower, upper)`` bound arrays of length ``m``. 

184 

185 Example: 

186 >>> import numpy as np 

187 >>> from cvx.core.bounds import Bounds 

188 >>> bounds = Bounds(m=3, name="assets") 

189 >>> bounds.update( 

190 ... lower_assets=np.array([0.1, 0.2, 0.0]), 

191 ... upper_assets=np.array([0.6, 0.7, 0.5]) 

192 ... ) 

193 >>> lb, ub = bounds.get_bounds() 

194 >>> lb 

195 array([0.1, 0.2, 0. ]) 

196 >>> ub 

197 array([0.6, 0.7, 0.5]) 

198 

199 """ 

200 return ( 

201 self.parameter[self._f("lower")].value.copy(), 

202 self.parameter[self._f("upper")].value.copy(), 

203 )