per-asset bounds

This commit is contained in:
robertmartin8
2019-12-10 21:58:25 +00:00
parent 4c80d3948e
commit 6db226b2d5
7 changed files with 95 additions and 33 deletions

View File

@@ -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::

View File

@@ -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(

View File

@@ -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

View File

@@ -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

View File

@@ -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):

View File

@@ -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():

View File

@@ -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()