diff --git a/docs/EfficientFrontier.rst b/docs/EfficientFrontier.rst index 3d1607b..904a9a7 100644 --- a/docs/EfficientFrontier.rst +++ b/docs/EfficientFrontier.rst @@ -43,8 +43,8 @@ magnitude, I will definitely consider switching. .. note:: - As a rule of thumb, any parameters that can apply to all optimisers - are instance variables (passed when you are initialising the object). + As of v0.5.0, you can pass a collection (list or tuple) of (min, max) pairs + representing different bounds for different assets. .. caution:: diff --git a/pypfopt/base_optimizer.py b/pypfopt/base_optimizer.py index c58907c..31a9465 100644 --- a/pypfopt/base_optimizer.py +++ b/pypfopt/base_optimizer.py @@ -80,16 +80,17 @@ class BaseScipyOptimizer(BaseOptimizer): - ``n_assets`` - int - ``tickers`` - str list - ``weights`` - np.ndarray - - ``bounds`` - (float tuple) list + - ``bounds`` - float tuple OR (float tuple) list - ``initial_guess`` - nnp.ndarray - ``constraints`` - dict list """ def __init__(self, n_assets, tickers=None, weight_bounds=(0, 1)): """ - :param weight_bounds: minimum and maximum weight of an asset, defaults to (0, 1). - Must be changed to (-1, 1) for portfolios with shorting. - :type weight_bounds: tuple, optional + :param weight_bounds: minimum and maximum weight of each asset OR single min/max pair + if all identical, defaults to (0, 1). Must be changed to (-1, 1) + for portfolios with shorting. + :type weight_bounds: tuple OR tuple list, optional """ super().__init__(n_assets, tickers) self.bounds = self._make_valid_bounds(weight_bounds) @@ -102,21 +103,36 @@ class BaseScipyOptimizer(BaseOptimizer): Private method: process input bounds into a form acceptable by scipy.optimize, and check the validity of said bounds. - :param test_bounds: minimum and maximum weight of an asset - :type test_bounds: tuple - :raises ValueError: if ``test_bounds`` is not a tuple of length two. + :param test_bounds: minimum and maximum weight of each asset OR single min/max pair + if all identical, defaults to (0, 1). + :type test_bounds: tuple OR list/tuple of tuples. + :raises ValueError: if ``test_bounds`` is not a tuple of length two OR a collection + of pairs. :raises ValueError: if the lower bound is too high :return: a tuple of bounds, e.g ((0, 1), (0, 1), (0, 1) ...) :rtype: tuple of tuples """ - if len(test_bounds) != 2 or not isinstance(test_bounds, tuple): + # If it is a collection with the right length, assume they are all bounds. + print(test_bounds) + if len(test_bounds) == self.n_assets and not isinstance( + test_bounds[0], (float, int) + ): + bounds = test_bounds + else: + if len(test_bounds) != 2 or not isinstance(test_bounds, tuple): + raise ValueError( + "test_bounds must be a tuple of (lower bound, upper bound) " + "OR collection of bounds for each asset" + ) + bounds = (test_bounds,) * self.n_assets + + # Ensure lower bound is not too high + if sum((0 if b[0] is None else b[0]) for b in bounds) > 1: raise ValueError( - "test_bounds must be a tuple of (lower bound, upper bound)" + "Lower bound is too high. Impossible to construct valid portfolio" ) - if test_bounds[0] is not None: - if test_bounds[0] * self.n_assets > 1: - raise ValueError("Lower bound is too high") - return (test_bounds,) * self.n_assets + + return bounds def portfolio_performance( diff --git a/pypfopt/cla.py b/pypfopt/cla.py index b1fb1fe..69d9cb7 100644 --- a/pypfopt/cla.py +++ b/pypfopt/cla.py @@ -67,14 +67,21 @@ class CLA(base_optimizer.BaseOptimizer): self.mean[-1, 0] += 1e-5 self.expected_returns = self.mean.reshape((len(self.mean),)) self.cov_matrix = np.asarray(cov_matrix) - if isinstance(weight_bounds[0], numbers.Real): - self.lB = np.ones(self.mean.shape) * weight_bounds[0] + + # Bounds + if len(weight_bounds) == len(self.mean): + self.lB = np.array([b[0] for b in weight_bounds]).reshape(-1, 1) + self.uB = np.array([b[1] for b in weight_bounds]).reshape(-1, 1) else: - self.lB = np.array(weight_bounds[0]).reshape(self.mean.shape) - if isinstance(weight_bounds[0], numbers.Real): - self.uB = np.ones(self.mean.shape) * weight_bounds[1] - else: - self.uB = np.array(weight_bounds[1]).reshape(self.mean.shape) + if isinstance(weight_bounds[0], numbers.Real): + self.lB = np.ones(self.mean.shape) * weight_bounds[0] + else: + self.lB = np.array(weight_bounds[0]).reshape(self.mean.shape) + if isinstance(weight_bounds[0], numbers.Real): + self.uB = np.ones(self.mean.shape) * weight_bounds[1] + else: + self.uB = np.array(weight_bounds[1]).reshape(self.mean.shape) + self.w = [] # solution self.ls = [] # lambdas self.g = [] # gammas diff --git a/pypfopt/efficient_frontier.py b/pypfopt/efficient_frontier.py index 1a1f23d..95c49cc 100644 --- a/pypfopt/efficient_frontier.py +++ b/pypfopt/efficient_frontier.py @@ -23,7 +23,7 @@ class EfficientFrontier(base_optimizer.BaseScipyOptimizer): - ``n_assets`` - int - ``tickers`` - str list - - ``bounds`` - (float tuple) list + - ``bounds`` - float tuple OR (float tuple) list - ``cov_matrix`` - pd.DataFrame - ``expected_returns`` - pd.Series @@ -52,9 +52,10 @@ class EfficientFrontier(base_optimizer.BaseScipyOptimizer): :type expected_returns: pd.Series, list, np.ndarray :param cov_matrix: covariance of returns for each asset :type cov_matrix: pd.DataFrame or np.array - :param weight_bounds: minimum and maximum weight of an asset, defaults to (0, 1). - Must be changed to (-1, 1) for portfolios with shorting. - :type weight_bounds: tuple, optional + :param weight_bounds: minimum and maximum weight of each asset OR single min/max pair + if all identical, defaults to (0, 1). Must be changed to (-1, 1) + for portfolios with shorting. + :type weight_bounds: tuple OR tuple list, optional :param gamma: L2 regularisation parameter, defaults to 0. Increase if you want more non-negligible weights :type gamma: float, optional diff --git a/pypfopt/value_at_risk.py b/pypfopt/value_at_risk.py index e65daad..faff884 100644 --- a/pypfopt/value_at_risk.py +++ b/pypfopt/value_at_risk.py @@ -21,7 +21,7 @@ class CVAROpt(base_optimizer.BaseScipyOptimizer): - ``tickers`` - str list - ``returns`` - pd.DataFrame - - ``bounds`` - (double tuple) list + - ``bounds`` - float tuple OR (float tuple) list - Optimisation parameters: @@ -40,10 +40,10 @@ class CVAROpt(base_optimizer.BaseScipyOptimizer): """ :param returns: asset historical returns :type returns: pd.DataFrame - :param weight_bounds: minimum and maximum weight of an asset, defaults to (0, 1). - Must be changed to (-1, 1) for portfolios with shorting. - For CVaR opt, this is not a hard boundary. - :type weight_bounds: tuple, optional + :param weight_bounds: minimum and maximum weight of each asset OR single min/max pair + if all identical, defaults to (0, 1). Must be changed to (-1, 1) + for portfolios with shorting. + :type weight_bounds: tuple OR tuple list, optional :raises TypeError: if ``returns`` is not a dataframe """ if not isinstance(returns, pd.DataFrame): diff --git a/tests/test_base_optimizer.py b/tests/test_base_optimizer.py index 167c021..3ce3ec5 100644 --- a/tests/test_base_optimizer.py +++ b/tests/test_base_optimizer.py @@ -23,7 +23,7 @@ def test_custom_lower_bound(): np.testing.assert_almost_equal(ef.weights.sum(), 1) -def test_custom_bounds(): +def test_custom_bounds_same(): ef = EfficientFrontier( *setup_efficient_frontier(data_only=True), weight_bounds=(0.03, 0.13) ) @@ -33,11 +33,28 @@ def test_custom_bounds(): np.testing.assert_almost_equal(ef.weights.sum(), 1) +def test_custom_bounds_different(): + bounds = [(0.01, 0.13), (0.02, 0.11)] * 10 + ef = EfficientFrontier( + *setup_efficient_frontier(data_only=True), weight_bounds=bounds + ) + ef.max_sharpe() + assert (0.01 <= ef.weights[::2]).all() and (ef.weights[::2] <= 0.13).all() + assert (0.02 <= ef.weights[1::2]).all() and (ef.weights[1::2] <= 0.11).all() + np.testing.assert_almost_equal(ef.weights.sum(), 1) + + bounds = ((0.01, 0.13), (0.02, 0.11)) * 10 + assert EfficientFrontier( + *setup_efficient_frontier(data_only=True), weight_bounds=bounds + ) + + def test_bounds_errors(): with pytest.raises(ValueError): EfficientFrontier( *setup_efficient_frontier(data_only=True), weight_bounds=(0.06, 1) ) + assert EfficientFrontier( *setup_efficient_frontier(data_only=True), weight_bounds=(0, 1) ) @@ -47,6 +64,12 @@ def test_bounds_errors(): *setup_efficient_frontier(data_only=True), weight_bounds=(0.06, 1, 3) ) + with pytest.raises(ValueError): + bounds = [(0.01, 0.13), (0.02, 0.11)] * 5 + EfficientFrontier( + *setup_efficient_frontier(data_only=True), weight_bounds=bounds + ) + def test_clean_weights(): ef = setup_efficient_frontier() @@ -92,7 +115,9 @@ def test_clean_weights_no_rounding(): # in previous commits, this call would raise a ValueError cleaned = ef.clean_weights(rounding=None, cutoff=0) assert cleaned - np.testing.assert_array_almost_equal(np.sort(ef.weights), np.sort(list(cleaned.values()))) + np.testing.assert_array_almost_equal( + np.sort(ef.weights), np.sort(list(cleaned.values())) + ) def test_efficient_frontier_init_errors(): diff --git a/tests/test_cla.py b/tests/test_cla.py index eee2086..7167789 100644 --- a/tests/test_cla.py +++ b/tests/test_cla.py @@ -51,6 +51,19 @@ def test_cla_max_sharpe_short(): assert sharpe > long_only_sharpe +def test_cla_custom_bounds(): + bounds = [(0.01, 0.13), (0.02, 0.11)] * 10 + cla = CLA(*setup_cla(data_only=True), weight_bounds=bounds) + df = get_data() + cla.cov_matrix = risk_models.exp_cov(df).values + w = cla.min_volatility() + assert isinstance(w, dict) + assert set(w.keys()) == set(cla.tickers) + np.testing.assert_almost_equal(cla.weights.sum(), 1) + assert (0.01 <= cla.weights[::2]).all() and (cla.weights[::2] <= 0.13).all() + assert (0.02 <= cla.weights[1::2]).all() and (cla.weights[1::2] <= 0.11).all() + + def test_cla_min_volatility(): cla = setup_cla() w = cla.min_volatility()