From adad9c94e3dc002f860e4955259b24ecdeff65b7 Mon Sep 17 00:00:00 2001 From: Enzo Busseti Date: Fri, 29 Dec 2023 15:03:30 +0400 Subject: [PATCH] Added constraint on min/max benchmark deviation --- cvxportfolio/constraints.py | 73 ++++++++++++++++++++++++++ cvxportfolio/tests/test_constraints.py | 67 +++++++++++++++++++++++ docs/constraints.rst | 4 ++ 3 files changed, 144 insertions(+) diff --git a/cvxportfolio/constraints.py b/cvxportfolio/constraints.py index e732c7a1e..83de00372 100644 --- a/cvxportfolio/constraints.py +++ b/cvxportfolio/constraints.py @@ -56,6 +56,8 @@ "ParticipationRateLimit", "MaxWeights", "MinWeights", + "MaxBenchmarkDeviation", + "MinBenchmarkDeviation", "NoTrade", "NoCash", "FactorMaxLimit", @@ -617,6 +619,77 @@ def _rhs(self): """Compile right hand side of the constraint expression.""" return -self.limit.parameter +class MaxBenchmarkDeviation(MaxWeights): + r"""A max limit on post-trade weights minus the benchmark weights. + + In our notation, this is + + .. math:: + + {(w_t + z_t - w^\text{bm}_t)}_{1:n} \leq w^\text{max} + + where the limit :math:`w^\text{max}` is either a scalar or a vector, see + below. + + .. versionadded:: 1.1.0 + Added in version 1.1.0 + + :param limit: A series or number giving the weights limit. See the + :ref:`passing-data` manual page for details on how to provide this + data. For example, you pass a float if you want a constant limit + for all assets at all times, a Pandas series indexed by time if you + want a limit constant for all assets but varying in time, a Pandas + series indexed by the assets' names if you have limits constant in time + but different for each asset, and a Pandas dataframe indexed by time + and with assets as columns if you have a different limit for each point + in time and each asset. If the value changes for each asset, you should + provide a value for each name that ever appear in a back-test; the + data will be sliced according to the current trading universe during a + back-test. It is fine to have missing values at certain times on assets + that are not traded then. + :type limit: float, pandas.Series, pandas.DataFrame + """ + + def _compile_constr_to_cvxpy(self, w_plus, z, w_plus_minus_w_bm): + """Compile left hand side of the constraint expression.""" + return w_plus_minus_w_bm[:-1] + + +class MinBenchmarkDeviation(MinWeights): + r"""A min limit on post-trade weights minus the benchmark weights. + + In our notation, this is + + .. math:: + + {(w_t + z_t - w^\text{bm}_t)}_{1:n} \geq w^\text{min} + + where the limit :math:`w^\text{min}` is either a scalar or a vector, see + below. + + .. versionadded:: 1.1.0 + Added in version 1.1.0 + + :param limit: A series or number giving the weights limit. See the + :ref:`passing-data` manual page for details on how to provide this + data. For example, you pass a float if you want a constant limit + for all assets at all times, a Pandas series indexed by time if you + want a limit constant for all assets but varying in time, a Pandas + series indexed by the assets' names if you have limits constant in time + but different for each asset, and a Pandas dataframe indexed by time + and with assets as columns if you have a different limit for each point + in time and each asset. If the value changes for each asset, you should + provide a value for each name that ever appear in a back-test; the + data will be sliced according to the current trading universe during a + back-test. It is fine to have missing values at certain times on assets + that are not traded then. + :type limit: float, pandas.Series, pandas.DataFrame + """ + + def _compile_constr_to_cvxpy(self, w_plus, z, w_plus_minus_w_bm): + """Compile left hand side of the constraint expression.""" + return -w_plus_minus_w_bm[:-1] + class MinMaxWeightsAtTimes(Estimator): """This class abstracts functionalities used by the two below. diff --git a/cvxportfolio/tests/test_constraints.py b/cvxportfolio/tests/test_constraints.py index 214cb609f..b6544ddaf 100644 --- a/cvxportfolio/tests/test_constraints.py +++ b/cvxportfolio/tests/test_constraints.py @@ -193,6 +193,73 @@ def test_min_weights(self): model.values_in_time_recursive(t=self.returns.index[2]) self.assertFalse(cons.value()) + def test_max_bm_dev(self): + """Test max benchmark deviation constraint.""" + model = cvx.MaxBenchmarkDeviation(2) + cons = self._build_constraint(model) + self.w_plus_minus_w_bm.value = np.ones(self.N) / self.N + self.assertTrue(cons.value()) + tmp = np.zeros(self.N) + tmp[0] = 4 + tmp[-1] = -3 + self.w_plus_minus_w_bm.value = tmp + self.assertFalse(cons.value()) + + model = cvx.MaxBenchmarkDeviation(7) + cons = self._build_constraint(model) + + tmp = np.zeros(self.N) + tmp[0] = 4 + tmp[-1] = -3 + self.w_plus_minus_w_bm.value = tmp + self.assertTrue(cons.value()) + + limits = pd.Series(index=self.returns.index, data=2) + limits.iloc[1] = 7 + + model = cvx.MaxBenchmarkDeviation(limits) + cons = self._build_constraint(model, t=self.returns.index[1]) + + tmp = np.zeros(self.N) + tmp[0] = 4 + tmp[-1] = -3 + self.w_plus_minus_w_bm.value = tmp + self.assertTrue(cons.value()) + model.values_in_time_recursive(t=self.returns.index[2]) + self.assertFalse(cons.value()) + + def test_min_bm_dev(self): + """Test min benchmark deviation constraint.""" + model = cvx.MinBenchmarkDeviation(2) + cons = self._build_constraint(model, self.returns.index[1]) + + self.w_plus_minus_w_bm.value = np.ones(self.N) / self.N + self.assertFalse(cons.value()) + tmp = np.zeros(self.N) + tmp[0] = 4 + tmp[-1] = -3 + self.w_plus_minus_w_bm.value = tmp + self.assertFalse(cons.value()) + model = cvx.MinBenchmarkDeviation(-3) + cons = self._build_constraint(model, self.returns.index[1]) + tmp = np.zeros(self.N) + tmp[0] = 4 + tmp[-1] = -3 + self.w_plus_minus_w_bm.value = tmp + self.assertTrue(cons.value()) + + limits = pd.Series(index=self.returns.index, data=2) + limits.iloc[1] = -3 + model = cvx.MinBenchmarkDeviation(limits) + cons = self._build_constraint(model, t=self.returns.index[1]) + tmp = np.zeros(self.N) + tmp[0] = 4 + tmp[-1] = -3 + self.w_plus_minus_w_bm.value = tmp + self.assertTrue(cons.value()) + model.values_in_time_recursive(t=self.returns.index[2]) + self.assertFalse(cons.value()) + def test_factor_max_limit(self): """Test factor max limit constraint.""" diff --git a/docs/constraints.rst b/docs/constraints.rst index 845711127..1a279d957 100644 --- a/docs/constraints.rst +++ b/docs/constraints.rst @@ -28,6 +28,10 @@ Constraints .. autoclass:: MinWeights +.. autoclass:: MaxBenchmarkDeviation + +.. autoclass:: MinBenchmarkDeviation + .. autoclass:: ParticipationRateLimit .. autoclass:: TurnoverLimit