Coverage for src/cvxmarkowitz/models/bounds.py: 100%

25 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-15 05:21 +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.""" 

15 

16from __future__ import annotations 

17 

18from dataclasses import dataclass 

19 

20import cvxpy as cp 

21import numpy as np 

22 

23from cvxmarkowitz.model import Model 

24from cvxmarkowitz.types import Constraints, Dimensions, Matrix, Variables 

25from cvxmarkowitz.utils.fill import fill_vector 

26 

27 

28@dataclass(frozen=True) 

29class Bounds(Model): 

30 """Lower/upper bound model applied to a variable vector. 

31 

32 Attributes: 

33 name: Suffix used to distinguish multiple bounds (e.g., "assets"). 

34 acting_on: Key in the variables dict this bound constrains (e.g., D.WEIGHTS). 

35 """ 

36 

37 name: str = "" 

38 acting_on: str = "weights" 

39 

40 def estimate(self, variables: Variables) -> cp.Expression: 

41 """No estimation for bounds. 

42 

43 Bounds only contribute constraints; they do not produce an objective term. 

44 """ 

45 raise NotImplementedError("No estimation for bounds") 

46 

47 def _f(self, string: str) -> str: 

48 """Return ``string`` suffixed with ``self.name`` (e.g. ``"lower_assets"``).""" 

49 return f"{string}_{self.name}" 

50 

51 def __post_init__(self) -> None: 

52 """Create lower/upper bound parameters with default values. 

53 

54 Initializes two parameters named with the bound type and `name` suffix, 

55 both sized to `assets`. Defaults are zeros for lower and ones for upper. 

56 """ 

57 self.data[self._f("lower")] = cp.Parameter( 

58 shape=self.assets, 

59 name=self._f("lower"), 

60 value=np.zeros(self.assets), 

61 ) 

62 self.data[self._f("upper")] = cp.Parameter( 

63 shape=self.assets, 

64 name=self._f("upper"), 

65 value=np.ones(self.assets), 

66 ) 

67 

68 def dimensions(self, **kwargs: Matrix) -> Dimensions: 

69 """Return the size both bound vectors imply for the variable bounded. 

70 

71 Both, not just the lower one: a payload giving a lower bound for two 

72 assets and an upper bound for four would otherwise pad the lower bound 

73 with zeros and leave the tail free between 0 and the real upper bound, 

74 which is the same silent-tail failure `Problem.update` exists to catch. 

75 """ 

76 return ( 

77 (self.acting_on, len(kwargs[self._f("lower")])), 

78 (self.acting_on, len(kwargs[self._f("upper")])), 

79 ) 

80 

81 def update(self, **kwargs: Matrix) -> None: 

82 """Assign lower/upper vectors, zero-padding them to the compiled length.""" 

83 self.data[self._f("lower")].value = fill_vector(num=self.assets, x=kwargs[self._f("lower")]) 

84 self.data[self._f("upper")].value = fill_vector(num=self.assets, x=kwargs[self._f("upper")]) 

85 

86 def constraints(self, variables: Variables) -> Constraints: 

87 """Return lower/upper inequality constraints for `acting_on` variable. 

88 

89 Raises KeyError if `acting_on` is not present in `variables`. 

90 """ 

91 return { 

92 f"lower bound {self.name}": variables[self.acting_on] >= self.data[self._f("lower")], 

93 f"upper bound {self.name}": variables[self.acting_on] <= self.data[self._f("upper")], 

94 }