Coverage for src/cvxmarkowitz/model.py: 100%

21 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"""Abstract cp model.""" 

15 

16from __future__ import annotations 

17 

18from abc import ABC, abstractmethod 

19from dataclasses import dataclass, field 

20 

21import cvxpy as cp 

22 

23from cvxmarkowitz.types import Constraints, Dimensions, Matrix, Parameter, Variables 

24 

25 

26@dataclass(frozen=True) 

27class Model(ABC): 

28 """Abstract base for every component a `Builder` assembles. 

29 

30 Risk models are only part of it: `Bounds`, `ExpectedReturns`, `TradingCosts` 

31 and `HoldingCosts` derive from this too. A component owns the cvxpy 

32 Parameters it is built from and contributes an objective term (`estimate`), 

33 constraints (`constraints`), or -- as `Bounds` does -- only the latter. 

34 

35 Attributes: 

36 assets: Number of entries the component's parameters are sized for. 

37 parameter: cvxpy Parameters the component holds that `data` does not 

38 back. Some are set once at construction (`TradingCosts` fixes the 

39 cost exponent this way); others are written by `update` from a 

40 keyword, which is the case that requires overriding `keywords`. 

41 data: cvxpy Parameters `update` fills from its keyword arguments, and 

42 the default source of `keywords`. 

43 """ 

44 

45 assets: int 

46 parameter: Parameter = field(default_factory=dict) 

47 data: Parameter = field(default_factory=dict) 

48 

49 @property 

50 def keywords(self) -> tuple[str, ...]: 

51 """Return the keyword names this model's `update` consumes. 

52 

53 `Problem.update` checks these against the keywords it was handed before 

54 any value is written, so that a missing one is reported as a 

55 `CvxDataError` rather than escaping as whatever the model's own 

56 `kwargs[...]` lookup happens to raise. 

57 

58 The default is the keys of `data`, which is where a model registers the 

59 cvxpy Parameters it fills from keyword arguments. **Override it whenever 

60 `update` reads a keyword that `data` does not back** -- otherwise the 

61 check cannot see that keyword and a caller who omits it gets a bare 

62 `KeyError`, which is outside the `CvxError` tree the package promises. 

63 `ExpectedReturns` is the one such model today. 

64 

65 Returns a tuple rather than a set so the key named in the error message 

66 follows the insertion order of `data` instead of set iteration order. 

67 """ 

68 return tuple(self.data) 

69 

70 @abstractmethod 

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

72 """Return this component's objective contribution, given the variables. 

73 

74 What the expression means is the component's own business: a risk model 

75 returns a risk measure (`FactorModel` and `SampleCovariance` a norm, not 

76 a variance; `CVar` a conditional value-at-risk), `ExpectedReturns` a 

77 robust expected return, the cost models a cost. A component that 

78 contributes no objective term at all raises `NotImplementedError` here 

79 -- see `Bounds`, which is pure constraints. 

80 """ 

81 

82 @abstractmethod 

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

84 """Return the size every input in `kwargs` implies for a problem variable. 

85 

86 One `(variable name, size)` pair per input this model consumes, the 

87 variable named by `DataNames` -- `WEIGHTS` for anything sized by the 

88 asset universe, `FACTOR_WEIGHTS` for anything sized by the factors. 

89 Several pairs may name the same variable; that is the point. 

90 

91 `Problem.update` collects these across every model and rejects a payload 

92 whose claims disagree. It has to, because `update` pads short inputs up 

93 to the compiled size (see `cvxmarkowitz.utils.fill`): a payload that 

94 hands the risk model two assets and the bounds four does not fail on its 

95 own -- it leaves the padded tail both zero-risk and unbounded, and the 

96 solver puts the whole portfolio there. Each model already checks its own 

97 inputs against each other; this is what checks them across models. 

98 

99 Abstract, rather than a default a model may quietly not override, for 

100 the reason `keywords` documents: that shape of contract has already been 

101 got wrong once here. A model with nothing to declare returns `()`, but it 

102 forfeits the cross-check for every keyword it consumes. 

103 """ 

104 

105 @abstractmethod 

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

107 """Write fresh values into this component's parameters, in place. 

108 

109 Each implementation documents the keywords it consumes; `keywords` is 

110 what `Problem.update` checks those against before any value is written. 

111 """ 

112 

113 def constraints(self, variables: Variables) -> Constraints: # noqa: ARG002 # base default ignores `variables`; name kept to match overrides (LSP) 

114 """Return this component's named constraints; none by default.""" 

115 return {}