mirror of
https://github.com/robertmartin8/PyPortfolioOpt.git
synced 2022-11-27 18:02:41 +03:00
per-asset bounds
This commit is contained in:
@@ -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::
|
||||
|
||||
|
||||
@@ -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 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"
|
||||
)
|
||||
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
|
||||
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(
|
||||
"Lower bound is too high. Impossible to construct valid portfolio"
|
||||
)
|
||||
|
||||
return bounds
|
||||
|
||||
|
||||
def portfolio_performance(
|
||||
|
||||
@@ -67,6 +67,12 @@ 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)
|
||||
|
||||
# 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:
|
||||
if isinstance(weight_bounds[0], numbers.Real):
|
||||
self.lB = np.ones(self.mean.shape) * weight_bounds[0]
|
||||
else:
|
||||
@@ -75,6 +81,7 @@ class CLA(base_optimizer.BaseOptimizer):
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user