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::
|
.. note::
|
||||||
|
|
||||||
As a rule of thumb, any parameters that can apply to all optimisers
|
As of v0.5.0, you can pass a collection (list or tuple) of (min, max) pairs
|
||||||
are instance variables (passed when you are initialising the object).
|
representing different bounds for different assets.
|
||||||
|
|
||||||
.. caution::
|
.. caution::
|
||||||
|
|
||||||
|
|||||||
@@ -80,16 +80,17 @@ class BaseScipyOptimizer(BaseOptimizer):
|
|||||||
- ``n_assets`` - int
|
- ``n_assets`` - int
|
||||||
- ``tickers`` - str list
|
- ``tickers`` - str list
|
||||||
- ``weights`` - np.ndarray
|
- ``weights`` - np.ndarray
|
||||||
- ``bounds`` - (float tuple) list
|
- ``bounds`` - float tuple OR (float tuple) list
|
||||||
- ``initial_guess`` - nnp.ndarray
|
- ``initial_guess`` - nnp.ndarray
|
||||||
- ``constraints`` - dict list
|
- ``constraints`` - dict list
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, n_assets, tickers=None, weight_bounds=(0, 1)):
|
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).
|
:param weight_bounds: minimum and maximum weight of each asset OR single min/max pair
|
||||||
Must be changed to (-1, 1) for portfolios with shorting.
|
if all identical, defaults to (0, 1). Must be changed to (-1, 1)
|
||||||
:type weight_bounds: tuple, optional
|
for portfolios with shorting.
|
||||||
|
:type weight_bounds: tuple OR tuple list, optional
|
||||||
"""
|
"""
|
||||||
super().__init__(n_assets, tickers)
|
super().__init__(n_assets, tickers)
|
||||||
self.bounds = self._make_valid_bounds(weight_bounds)
|
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,
|
Private method: process input bounds into a form acceptable by scipy.optimize,
|
||||||
and check the validity of said bounds.
|
and check the validity of said bounds.
|
||||||
|
|
||||||
:param test_bounds: minimum and maximum weight of an asset
|
:param test_bounds: minimum and maximum weight of each asset OR single min/max pair
|
||||||
:type test_bounds: tuple
|
if all identical, defaults to (0, 1).
|
||||||
:raises ValueError: if ``test_bounds`` is not a tuple of length two.
|
: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
|
:raises ValueError: if the lower bound is too high
|
||||||
:return: a tuple of bounds, e.g ((0, 1), (0, 1), (0, 1) ...)
|
:return: a tuple of bounds, e.g ((0, 1), (0, 1), (0, 1) ...)
|
||||||
:rtype: tuple of tuples
|
: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(
|
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:
|
return bounds
|
||||||
raise ValueError("Lower bound is too high")
|
|
||||||
return (test_bounds,) * self.n_assets
|
|
||||||
|
|
||||||
|
|
||||||
def portfolio_performance(
|
def portfolio_performance(
|
||||||
|
|||||||
@@ -67,14 +67,21 @@ class CLA(base_optimizer.BaseOptimizer):
|
|||||||
self.mean[-1, 0] += 1e-5
|
self.mean[-1, 0] += 1e-5
|
||||||
self.expected_returns = self.mean.reshape((len(self.mean),))
|
self.expected_returns = self.mean.reshape((len(self.mean),))
|
||||||
self.cov_matrix = np.asarray(cov_matrix)
|
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:
|
else:
|
||||||
self.lB = np.array(weight_bounds[0]).reshape(self.mean.shape)
|
if isinstance(weight_bounds[0], numbers.Real):
|
||||||
if isinstance(weight_bounds[0], numbers.Real):
|
self.lB = np.ones(self.mean.shape) * weight_bounds[0]
|
||||||
self.uB = np.ones(self.mean.shape) * weight_bounds[1]
|
else:
|
||||||
else:
|
self.lB = np.array(weight_bounds[0]).reshape(self.mean.shape)
|
||||||
self.uB = np.array(weight_bounds[1]).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.w = [] # solution
|
||||||
self.ls = [] # lambdas
|
self.ls = [] # lambdas
|
||||||
self.g = [] # gammas
|
self.g = [] # gammas
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ class EfficientFrontier(base_optimizer.BaseScipyOptimizer):
|
|||||||
|
|
||||||
- ``n_assets`` - int
|
- ``n_assets`` - int
|
||||||
- ``tickers`` - str list
|
- ``tickers`` - str list
|
||||||
- ``bounds`` - (float tuple) list
|
- ``bounds`` - float tuple OR (float tuple) list
|
||||||
- ``cov_matrix`` - pd.DataFrame
|
- ``cov_matrix`` - pd.DataFrame
|
||||||
- ``expected_returns`` - pd.Series
|
- ``expected_returns`` - pd.Series
|
||||||
|
|
||||||
@@ -52,9 +52,10 @@ class EfficientFrontier(base_optimizer.BaseScipyOptimizer):
|
|||||||
:type expected_returns: pd.Series, list, np.ndarray
|
:type expected_returns: pd.Series, list, np.ndarray
|
||||||
:param cov_matrix: covariance of returns for each asset
|
:param cov_matrix: covariance of returns for each asset
|
||||||
:type cov_matrix: pd.DataFrame or np.array
|
:type cov_matrix: pd.DataFrame or np.array
|
||||||
:param weight_bounds: minimum and maximum weight of an asset, defaults to (0, 1).
|
:param weight_bounds: minimum and maximum weight of each asset OR single min/max pair
|
||||||
Must be changed to (-1, 1) for portfolios with shorting.
|
if all identical, defaults to (0, 1). Must be changed to (-1, 1)
|
||||||
:type weight_bounds: tuple, optional
|
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
|
:param gamma: L2 regularisation parameter, defaults to 0. Increase if you want more
|
||||||
non-negligible weights
|
non-negligible weights
|
||||||
:type gamma: float, optional
|
:type gamma: float, optional
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class CVAROpt(base_optimizer.BaseScipyOptimizer):
|
|||||||
|
|
||||||
- ``tickers`` - str list
|
- ``tickers`` - str list
|
||||||
- ``returns`` - pd.DataFrame
|
- ``returns`` - pd.DataFrame
|
||||||
- ``bounds`` - (double tuple) list
|
- ``bounds`` - float tuple OR (float tuple) list
|
||||||
|
|
||||||
- Optimisation parameters:
|
- Optimisation parameters:
|
||||||
|
|
||||||
@@ -40,10 +40,10 @@ class CVAROpt(base_optimizer.BaseScipyOptimizer):
|
|||||||
"""
|
"""
|
||||||
:param returns: asset historical returns
|
:param returns: asset historical returns
|
||||||
:type returns: pd.DataFrame
|
:type returns: pd.DataFrame
|
||||||
:param weight_bounds: minimum and maximum weight of an asset, defaults to (0, 1).
|
:param weight_bounds: minimum and maximum weight of each asset OR single min/max pair
|
||||||
Must be changed to (-1, 1) for portfolios with shorting.
|
if all identical, defaults to (0, 1). Must be changed to (-1, 1)
|
||||||
For CVaR opt, this is not a hard boundary.
|
for portfolios with shorting.
|
||||||
:type weight_bounds: tuple, optional
|
:type weight_bounds: tuple OR tuple list, optional
|
||||||
:raises TypeError: if ``returns`` is not a dataframe
|
:raises TypeError: if ``returns`` is not a dataframe
|
||||||
"""
|
"""
|
||||||
if not isinstance(returns, pd.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)
|
np.testing.assert_almost_equal(ef.weights.sum(), 1)
|
||||||
|
|
||||||
|
|
||||||
def test_custom_bounds():
|
def test_custom_bounds_same():
|
||||||
ef = EfficientFrontier(
|
ef = EfficientFrontier(
|
||||||
*setup_efficient_frontier(data_only=True), weight_bounds=(0.03, 0.13)
|
*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)
|
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():
|
def test_bounds_errors():
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
EfficientFrontier(
|
EfficientFrontier(
|
||||||
*setup_efficient_frontier(data_only=True), weight_bounds=(0.06, 1)
|
*setup_efficient_frontier(data_only=True), weight_bounds=(0.06, 1)
|
||||||
)
|
)
|
||||||
|
|
||||||
assert EfficientFrontier(
|
assert EfficientFrontier(
|
||||||
*setup_efficient_frontier(data_only=True), weight_bounds=(0, 1)
|
*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)
|
*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():
|
def test_clean_weights():
|
||||||
ef = setup_efficient_frontier()
|
ef = setup_efficient_frontier()
|
||||||
@@ -92,7 +115,9 @@ def test_clean_weights_no_rounding():
|
|||||||
# in previous commits, this call would raise a ValueError
|
# in previous commits, this call would raise a ValueError
|
||||||
cleaned = ef.clean_weights(rounding=None, cutoff=0)
|
cleaned = ef.clean_weights(rounding=None, cutoff=0)
|
||||||
assert cleaned
|
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():
|
def test_efficient_frontier_init_errors():
|
||||||
|
|||||||
@@ -51,6 +51,19 @@ def test_cla_max_sharpe_short():
|
|||||||
assert sharpe > long_only_sharpe
|
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():
|
def test_cla_min_volatility():
|
||||||
cla = setup_cla()
|
cla = setup_cla()
|
||||||
w = cla.min_volatility()
|
w = cla.min_volatility()
|
||||||
|
|||||||
Reference in New Issue
Block a user