mirror of
https://github.com/robertmartin8/PyPortfolioOpt.git
synced 2022-11-27 18:02:41 +03:00
migrated efficient frontier #77
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
The ``base_optimizer`` module houses the parent classes ``BaseOptimizer`` and
|
||||
``BaseConvexOptimizer``, from which all optimisers will inherit. The later is for
|
||||
optimisers that use the scipy solver.
|
||||
The ``base_optimizer`` module houses the parent classes ``BaseOptimizer`` from which all
|
||||
optimisers will inherit. ``BaseConvexOptimizer`` is thebase class for all ``cvxpy`` (and ``scipy``)
|
||||
optimisation.
|
||||
|
||||
Additionally, we define a general utility function ``portfolio_performance`` to
|
||||
evaluate return and risk for a given set of portfolio weights.
|
||||
@@ -11,7 +11,9 @@ import json
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import cvxpy as cp
|
||||
import scipy.optimize as sco
|
||||
from . import objective_functions
|
||||
from . import exceptions
|
||||
|
||||
|
||||
class BaseOptimizer:
|
||||
@@ -100,16 +102,24 @@ class BaseOptimizer:
|
||||
class BaseConvexOptimizer(BaseOptimizer):
|
||||
|
||||
"""
|
||||
The BaseConvexOptimizer contains many private variables for use by
|
||||
``cvxpy``. For example, the immutable optimisation variable for weights
|
||||
is stored as self._w. Interacting directly with these variables is highly
|
||||
discouraged.
|
||||
|
||||
Instance variables:
|
||||
|
||||
- ``n_assets`` - int
|
||||
- ``tickers`` - str list
|
||||
- ``weights`` - np.ndarray
|
||||
- ``bounds`` - float tuple OR (float tuple) list
|
||||
- ``constraints`` - dict list
|
||||
|
||||
Public methods:
|
||||
|
||||
- ``add_objective()`` adds a (convex) objective to the optimisation problem
|
||||
- ``add_constraint()`` adds a (linear) constraint to the optimisation problem
|
||||
- ``convex_objective()`` solves for a generic convex objective with linear constraints
|
||||
- ``nonconvex_objective()`` solves for a generic nonconvex objective using the scipy backend.
|
||||
This is prone to getting stuck in local minima and is generally *not* recommended.
|
||||
- ``set_weights()`` creates self.weights (np.ndarray) from a weights dict
|
||||
- ``clean_weights()`` rounds the weights and clips near-zeros.
|
||||
- ``save_weights_to_file()`` saves the weights to csv, json, or txt.
|
||||
@@ -174,12 +184,164 @@ class BaseConvexOptimizer(BaseOptimizer):
|
||||
self._constraints.append(self._w >= self._lower_bounds)
|
||||
self._constraints.append(self._w <= self._upper_bounds)
|
||||
|
||||
@staticmethod
|
||||
def _make_scipy_bounds():
|
||||
def _solve_cvxpy_opt_problem(self):
|
||||
"""
|
||||
Convert the current cvxpy bounds to scipy bounds
|
||||
Helper method to solve the cvxpy problem and check output,
|
||||
once objectives and constraints have been defined
|
||||
|
||||
:raises exceptions.OptimizationError: if problem is not solvable by cvxpy
|
||||
"""
|
||||
raise NotImplementedError
|
||||
try:
|
||||
opt = cp.Problem(cp.Minimize(self._objective), self._constraints)
|
||||
opt.solve()
|
||||
except (TypeError, cp.DCPError):
|
||||
raise exceptions.OptimizationError
|
||||
if opt.status != "optimal":
|
||||
raise exceptions.OptimizationError
|
||||
self.weights = self._w.value.round(16) + 0.0 # +0.0 removes signed zero
|
||||
|
||||
def add_objective(self, new_objective, **kwargs):
|
||||
"""
|
||||
Add a new term into the objective function. This term must be convex,
|
||||
and built from cvxpy atomic functions.
|
||||
|
||||
Example:
|
||||
|
||||
def L1_norm(w, k=1):
|
||||
return k * cp.norm(w, 1)
|
||||
|
||||
ef.add_objective(L1_norm, k=2)
|
||||
|
||||
:param new_objective: the objective to be added
|
||||
:type new_objective: cp.Expression (i.e function of cp.Variable)
|
||||
"""
|
||||
self._additional_objectives.append(new_objective(self._w, **kwargs))
|
||||
|
||||
def add_constraint(self, new_constraint):
|
||||
"""
|
||||
Add a new constraint to the optimisation problem. This constraint must be linear and
|
||||
must be either an equality or simple inequality.
|
||||
|
||||
Examples:
|
||||
|
||||
ef.add_constraint(lambda x : x[0] == 0.02)
|
||||
ef.add_constraint(lambda x : x >= 0.01)
|
||||
ef.add_constraint(lambda x: x <= np.array([0.01, 0.08, ..., 0.5]))
|
||||
|
||||
:param new_constraint: the constraint to be added
|
||||
:type constraintfunc: lambda function
|
||||
"""
|
||||
if not callable(new_constraint):
|
||||
raise TypeError("New constraint must be provided as a lambda function")
|
||||
|
||||
# Save raw constraint (needed for e.g max_sharpe)
|
||||
self._additional_constraints_raw.append(new_constraint)
|
||||
# Add constraint
|
||||
self._constraints.append(new_constraint(self._w))
|
||||
|
||||
def convex_objective(self, custom_objective, weights_sum_to_one=True, **kwargs):
|
||||
"""
|
||||
Optimise a custom convex objective function. Constraints should be added with
|
||||
``ef.add_constraint()``. Optimiser arguments *must* be passed as keyword-args. Example:
|
||||
|
||||
# Could define as a lambda function instead
|
||||
def logarithmic_barrier(w, cov_matrix, k=0.1):
|
||||
# 60 Years of Portfolio Optimisation, Kolm et al (2014)
|
||||
return cp.quad_form(w, cov_matrix) - k * cp.sum(cp.log(w))
|
||||
|
||||
w = ef.convex_objective(logarithmic_barrier, cov_matrix=ef.cov_matrix)
|
||||
|
||||
:param custom_objective: an objective function to be MINIMISED. This should be written using
|
||||
cvxpy atoms Should map (w, **kwargs) -> float.
|
||||
:type custom_objective: function with signature (cp.Variable, **kwargs) -> cp.Expression
|
||||
:param weights_sum_to_one: whether to add the default objective, defaults to True
|
||||
:type weights_sum_to_one: bool, optional
|
||||
:raises OptimizationError: if the objective is nonconvex or constraints nonlinear.
|
||||
:return: asset weights for the efficient risk portfolio
|
||||
:rtype: dict
|
||||
"""
|
||||
# custom_objective must have the right signature (w, **kwargs)
|
||||
self._objective = custom_objective(self._w, **kwargs)
|
||||
|
||||
for obj in self._additional_objectives:
|
||||
self._objective += obj
|
||||
|
||||
if weights_sum_to_one:
|
||||
self._constraints.append(cp.sum(self._w) == 1)
|
||||
|
||||
self._solve_cvxpy_opt_problem()
|
||||
return dict(zip(self.tickers, self.weights))
|
||||
|
||||
def nonconvex_objective(
|
||||
self,
|
||||
custom_objective,
|
||||
objective_args=None,
|
||||
weights_sum_to_one=True,
|
||||
constraints=None,
|
||||
solver="SLSQP",
|
||||
):
|
||||
"""
|
||||
Optimise some objective function using the scipy backend. This can
|
||||
support nonconvex objectives and nonlinear constraints, but often gets stuck
|
||||
at local minima. This method is not recommended – caveat emptor. Example:
|
||||
|
||||
# Market-neutral efficient risk
|
||||
constraints = [
|
||||
{"type": "eq", "fun": lambda w: np.sum(w)}, # weights sum to zero
|
||||
{
|
||||
"type": "eq",
|
||||
"fun": lambda w: target_risk ** 2 - np.dot(w.T, np.dot(ef.cov_matrix, w)),
|
||||
}, # risk = target_risk
|
||||
]
|
||||
ef.nonconvex_objective(
|
||||
lambda w, mu: -w.T.dot(mu), # min negative return (i.e maximise return)
|
||||
objective_args=(ef.expected_returns,),
|
||||
weights_sum_to_one=False,
|
||||
constraints=constraints,
|
||||
)
|
||||
|
||||
:param objective_function: an objective function to be MINIMISED. This function
|
||||
should map (weight, args) -> cost
|
||||
:type objective_function: function with signature (np.ndarray, args) -> float
|
||||
:param objective_args: arguments for the objective function (excluding weight)
|
||||
:type objective_args: tuple of np.ndarrays
|
||||
:param weights_sum_to_one: whether to add the default objective, defaults to True
|
||||
:type weights_sum_to_one: bool, optional
|
||||
:param constraints: list of constraints in the scipy format (i.e dicts)
|
||||
:type constraints: dict list
|
||||
:param solver: which SCIPY solver to use, e.g "SLSQP", "COBYLA", "BFGS".
|
||||
User beware: different optimisers require different inputs.
|
||||
:type solver: string
|
||||
:return: asset weights that optimise the custom objective
|
||||
:rtype: dict
|
||||
"""
|
||||
# Sanitise inputs
|
||||
if not isinstance(objective_args, tuple):
|
||||
objective_args = (objective_args,)
|
||||
|
||||
# Make scipy bounds
|
||||
bound_array = np.vstack((self._lower_bounds, self._upper_bounds)).T
|
||||
bounds = list(map(tuple, bound_array))
|
||||
|
||||
initial_guess = np.array([1 / self.n_assets] * self.n_assets)
|
||||
|
||||
# Construct constraints
|
||||
final_constraints = []
|
||||
if weights_sum_to_one:
|
||||
final_constraints.append({"type": "eq", "fun": lambda x: np.sum(x) - 1})
|
||||
if constraints is not None:
|
||||
final_constraints += constraints
|
||||
|
||||
result = sco.minimize(
|
||||
custom_objective,
|
||||
x0=initial_guess,
|
||||
args=objective_args,
|
||||
method=solver,
|
||||
bounds=bounds,
|
||||
constraints=final_constraints,
|
||||
)
|
||||
self.weights = result["x"]
|
||||
return dict(zip(self.tickers, self.weights))
|
||||
|
||||
|
||||
def portfolio_performance(
|
||||
|
||||
@@ -148,7 +148,8 @@ class DiscreteAllocation:
|
||||
# Construct long-only discrete allocations for each
|
||||
short_val = self.total_portfolio_value * self.short_ratio
|
||||
|
||||
print("\nAllocating long sub-portfolio:")
|
||||
if verbose:
|
||||
print("\nAllocating long sub-portfolio...")
|
||||
da1 = DiscreteAllocation(
|
||||
longs,
|
||||
self.latest_prices[longs.keys()],
|
||||
@@ -156,7 +157,8 @@ class DiscreteAllocation:
|
||||
)
|
||||
long_alloc, long_leftover = da1.greedy_portfolio()
|
||||
|
||||
print("\nAllocating short sub-portfolio:")
|
||||
if verbose:
|
||||
print("\nAllocating short sub-portfolio...")
|
||||
da2 = DiscreteAllocation(
|
||||
shorts,
|
||||
self.latest_prices[shorts.keys()],
|
||||
@@ -263,7 +265,8 @@ class DiscreteAllocation:
|
||||
# Construct long-only discrete allocations for each
|
||||
short_val = self.total_portfolio_value * self.short_ratio
|
||||
|
||||
print("\nAllocating long sub-portfolio:")
|
||||
if verbose:
|
||||
print("\nAllocating long sub-portfolio:")
|
||||
da1 = DiscreteAllocation(
|
||||
longs,
|
||||
self.latest_prices[longs.keys()],
|
||||
@@ -271,7 +274,8 @@ class DiscreteAllocation:
|
||||
)
|
||||
long_alloc, long_leftover = da1.lp_portfolio()
|
||||
|
||||
print("\nAllocating short sub-portfolio:")
|
||||
if verbose:
|
||||
print("\nAllocating short sub-portfolio:")
|
||||
da2 = DiscreteAllocation(
|
||||
shorts,
|
||||
self.latest_prices[shorts.keys()],
|
||||
|
||||
@@ -7,8 +7,7 @@ import warnings
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import cvxpy as cp
|
||||
import scipy.optimize as sco
|
||||
from . import exceptions
|
||||
|
||||
from . import objective_functions, base_optimizer
|
||||
|
||||
|
||||
@@ -30,11 +29,6 @@ class EfficientFrontier(base_optimizer.BaseConvexOptimizer):
|
||||
- ``cov_matrix`` - np.ndarray
|
||||
- ``expected_returns`` - np.ndarray
|
||||
|
||||
- Optimisation parameters:
|
||||
|
||||
- ``initial_guess`` - np.ndarray
|
||||
- ``constraints`` - dict list
|
||||
- ``opt_method`` - the optimisation algorithm to use. Defaults to SLSQP.
|
||||
|
||||
- Output: ``weights`` - np.ndarray
|
||||
|
||||
@@ -42,7 +36,8 @@ class EfficientFrontier(base_optimizer.BaseConvexOptimizer):
|
||||
|
||||
- ``max_sharpe()`` optimises for maximal Sharpe ratio (a.k.a the tangency portfolio)
|
||||
- ``min_volatility()`` optimises for minimum volatility
|
||||
- ``custom_objective()`` optimises for some custom objective function
|
||||
|
||||
- ``max_quadratic_utility()`` maximises the quadratic utility, giiven some risk aversion.
|
||||
- ``efficient_risk()`` maximises Sharpe for a given target risk
|
||||
- ``efficient_return()`` minimises risk for a given target return
|
||||
- ``portfolio_performance()`` calculates the expected return, volatility and Sharpe ratio for
|
||||
@@ -128,97 +123,6 @@ class EfficientFrontier(base_optimizer.BaseConvexOptimizer):
|
||||
del self._constraints[0]
|
||||
del self._constraints[0]
|
||||
|
||||
def _solve_cvxpy_opt_problem(self):
|
||||
"""
|
||||
Helper method to solve the cvxpy problem and check output,
|
||||
once objectives and constraints have been defined
|
||||
|
||||
:raises exceptions.OptimizationError: if problem is not solvable by cvxpy
|
||||
"""
|
||||
try:
|
||||
opt = cp.Problem(cp.Minimize(self._objective), self._constraints)
|
||||
opt.solve()
|
||||
except (TypeError, cp.DCPError):
|
||||
raise exceptions.OptimizationError
|
||||
if opt.status != "optimal":
|
||||
raise exceptions.OptimizationError
|
||||
self.weights = self._w.value.round(16) + 0.0 # +0.0 removes signed zero
|
||||
|
||||
def add_objective(self, new_objective, **kwargs):
|
||||
"""
|
||||
Add a new term into the objective function. This term must be convex,
|
||||
and built from cvxpy atomic functions.
|
||||
|
||||
Example:
|
||||
|
||||
def L1_norm(w, k=1):
|
||||
return k * cp.norm(w, 1)
|
||||
|
||||
ef.add_objective(L1_norm, k=2)
|
||||
|
||||
:param new_objective: the objective to be added
|
||||
:type new_objective: cp.Expression (i.e function of cp.Variable)
|
||||
"""
|
||||
self._additional_objectives.append(new_objective(self._w, **kwargs))
|
||||
|
||||
def add_constraint(self, new_constraint):
|
||||
"""
|
||||
Add a new constraint to the optimisation problem. This constraint must be linear and
|
||||
must be either an equality or simple inequality.
|
||||
|
||||
Examples:
|
||||
|
||||
ef.add_constraint(lambda x : x[0] == 0.02)
|
||||
ef.add_constraint(lambda x : x >= 0.01)
|
||||
ef.add_constraint(lambda x: x <= np.array([0.01, 0.08, ..., 0.5]))
|
||||
|
||||
:param new_constraint: the constraint to be added
|
||||
:type constraintfunc: lambda function
|
||||
"""
|
||||
if not callable(new_constraint):
|
||||
raise TypeError("New constraint must be provided as a lambda function")
|
||||
|
||||
# Save raw constraint (needed for e.g max_sharpe)
|
||||
self._additional_constraints_raw.append(new_constraint)
|
||||
# Add constraint
|
||||
self._constraints.append(new_constraint(self._w))
|
||||
|
||||
def convex_optimize(self, custom_objective, constraints):
|
||||
# TODO: fix
|
||||
# genera convex optimistion
|
||||
pass
|
||||
|
||||
def nonconvex_optimize(self, custom_objective=None, constraints=None):
|
||||
# TODO: fix
|
||||
# opt using scipy
|
||||
args = (self.cov_matrix,)
|
||||
|
||||
initial_guess = np.array([1 / self.n_assets] * self.n_assets)
|
||||
|
||||
result = sco.minimize(
|
||||
objective_functions.volatility,
|
||||
x0=initial_guess,
|
||||
args=args,
|
||||
method="SLSQP",
|
||||
bounds=[(0, 1)] * 20,
|
||||
constraints=[{"type": "eq", "fun": lambda x: np.sum(x) - 1}],
|
||||
)
|
||||
self.weights = result["x"]
|
||||
|
||||
# max sharpe
|
||||
# args = (self.expected_returns, self.cov_matrix, self.gamma, risk_free_rate)
|
||||
# result = sco.minimize(
|
||||
# objective_functions.negative_sharpe,
|
||||
# x0=self.initial_guess,
|
||||
# args=args,
|
||||
# method=self.opt_method,
|
||||
# bounds=self.bounds,
|
||||
# constraints=self.constraints,
|
||||
# )
|
||||
# self.weights = result["x"]
|
||||
|
||||
return dict(zip(self.tickers, self.weights))
|
||||
|
||||
def min_volatility(self):
|
||||
"""
|
||||
Minimise volatility.
|
||||
@@ -325,29 +229,6 @@ class EfficientFrontier(base_optimizer.BaseConvexOptimizer):
|
||||
self._solve_cvxpy_opt_problem()
|
||||
return dict(zip(self.tickers, self.weights))
|
||||
|
||||
# TODO: roll custom_objective into nonconvex_optimizer
|
||||
def custom_objective(self, objective_function, *args):
|
||||
"""
|
||||
Optimise some objective function. While an implicit requirement is that the function
|
||||
can be optimised via a quadratic optimiser, this is not enforced. Thus there is a
|
||||
decent chance of silent failure.
|
||||
|
||||
:param objective_function: function which maps (weight, args) -> cost
|
||||
:type objective_function: function with signature (np.ndarray, args) -> float
|
||||
:return: asset weights that optimise the custom objective
|
||||
:rtype: dict
|
||||
"""
|
||||
result = sco.minimize(
|
||||
objective_function,
|
||||
x0=self.initial_guess,
|
||||
args=args,
|
||||
method=self.opt_method,
|
||||
bounds=self.bounds,
|
||||
constraints=self.constraints,
|
||||
)
|
||||
self.weights = result["x"]
|
||||
return dict(zip(self.tickers, self.weights))
|
||||
|
||||
def efficient_risk(self, target_volatility, market_neutral=False):
|
||||
"""
|
||||
Maximise return for a target risk.
|
||||
|
||||
@@ -9,14 +9,14 @@ from tests.utilities_for_tests import get_data, setup_efficient_frontier
|
||||
|
||||
def test_custom_bounds():
|
||||
ef = EfficientFrontier(
|
||||
*setup_efficient_frontier(data_only=True), weight_bounds=(0.02, 0.10)
|
||||
*setup_efficient_frontier(data_only=True), weight_bounds=(0.02, 0.13)
|
||||
)
|
||||
ef.min_volatility()
|
||||
np.testing.assert_allclose(ef._lower_bounds, np.array([0.02] * ef.n_assets))
|
||||
np.testing.assert_allclose(ef._lower_bounds, np.array([0.10] * ef.n_assets))
|
||||
np.testing.assert_allclose(ef._upper_bounds, np.array([0.13] * ef.n_assets))
|
||||
|
||||
assert ef.weights.min() >= 0.02
|
||||
assert ef.weights.max() <= 0.10
|
||||
assert ef.weights.max() <= 0.13
|
||||
np.testing.assert_almost_equal(ef.weights.sum(), 1)
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ def test_none_bounds():
|
||||
w2 = ef.weights
|
||||
np.testing.assert_array_almost_equal(w1, w2)
|
||||
|
||||
|
||||
def test_bound_input_types():
|
||||
bounds = [0.01, 0.13]
|
||||
ef = EfficientFrontier(
|
||||
|
||||
@@ -1,45 +1,116 @@
|
||||
import numpy as np
|
||||
from tests.utilities_for_tests import setup_efficient_frontier
|
||||
import cvxpy as cp
|
||||
import pytest
|
||||
from pypfopt import EfficientFrontier
|
||||
from pypfopt import objective_functions
|
||||
from pypfopt import exceptions
|
||||
from tests.utilities_for_tests import setup_efficient_frontier
|
||||
|
||||
|
||||
def test_custom_objective_equal_weights():
|
||||
def test_custom_convex_equal_weights():
|
||||
ef = setup_efficient_frontier()
|
||||
|
||||
def new_objective(weights):
|
||||
return (weights ** 2).sum()
|
||||
def new_objective(w):
|
||||
return cp.sum(w ** 2)
|
||||
|
||||
ef.custom_objective(new_objective)
|
||||
ef.convex_objective(new_objective)
|
||||
np.testing.assert_allclose(ef.weights, np.array([1 / 20] * 20))
|
||||
|
||||
|
||||
def test_custom_objective_min_var():
|
||||
def test_custom_convex_min_var():
|
||||
ef = setup_efficient_frontier()
|
||||
ef.min_volatility()
|
||||
built_in = ef.weights
|
||||
|
||||
# With custom objective
|
||||
ef = setup_efficient_frontier()
|
||||
ef.custom_objective(objective_functions.volatility, ef.cov_matrix, 0)
|
||||
ef.convex_objective(
|
||||
objective_functions.portfolio_variance, cov_matrix=ef.cov_matrix
|
||||
)
|
||||
custom = ef.weights
|
||||
np.testing.assert_allclose(built_in, custom, atol=1e-7)
|
||||
|
||||
|
||||
def test_custom_objective_sharpe_L2():
|
||||
ef = setup_efficient_frontier()
|
||||
ef.gamma = 2
|
||||
ef.max_sharpe()
|
||||
def test_custom_convex_objective_market_neutral_efficient_risk():
|
||||
target_risk = 0.19
|
||||
ef = EfficientFrontier(
|
||||
*setup_efficient_frontier(data_only=True), weight_bounds=(-1, 1)
|
||||
)
|
||||
ef.efficient_risk(target_risk, market_neutral=True)
|
||||
built_in = ef.weights
|
||||
|
||||
# Recreate the market-neutral efficient_risk optimiser using this API
|
||||
ef = EfficientFrontier(
|
||||
*setup_efficient_frontier(data_only=True), weight_bounds=(-1, 1)
|
||||
)
|
||||
ef.add_constraint(lambda x: cp.sum(x) == 0)
|
||||
ef.add_constraint(lambda x: cp.quad_form(x, ef.cov_matrix) <= target_risk ** 2)
|
||||
ef.convex_objective(lambda x: -x @ ef.expected_returns, weights_sum_to_one=False)
|
||||
custom = ef.weights
|
||||
np.testing.assert_allclose(built_in, custom, atol=1e-7)
|
||||
|
||||
|
||||
def test_convex_sharpe_raises_error():
|
||||
# With custom objective
|
||||
with pytest.raises(exceptions.OptimizationError):
|
||||
ef = setup_efficient_frontier()
|
||||
ef.convex_objective(
|
||||
objective_functions.sharpe_ratio,
|
||||
expected_returns=ef.expected_returns,
|
||||
cov_matrix=ef.cov_matrix,
|
||||
)
|
||||
|
||||
|
||||
def test_custom_convex_logarithmic_barrier():
|
||||
# 60 Years of Portfolio Optimisation, Kolm et al (2014)
|
||||
ef = setup_efficient_frontier()
|
||||
|
||||
def logarithmic_barrier(w, cov_matrix, k=0.1):
|
||||
log_sum = cp.sum(cp.log(w))
|
||||
var = cp.quad_form(w, cov_matrix)
|
||||
return var - k * log_sum
|
||||
|
||||
w = ef.convex_objective(logarithmic_barrier, cov_matrix=ef.cov_matrix)
|
||||
assert isinstance(w, dict)
|
||||
assert set(w.keys()) == set(ef.tickers)
|
||||
np.testing.assert_almost_equal(ef.weights.sum(), 1)
|
||||
|
||||
np.testing.assert_allclose(
|
||||
ef.portfolio_performance(),
|
||||
(0.23978400459553223, 0.21100848889958182, 1.041588448605623),
|
||||
)
|
||||
|
||||
|
||||
def test_custom_convex_deviation_risk_parity_error():
|
||||
# 60 Years of Portfolio Optimisation, Kolm et al (2014)
|
||||
ef = setup_efficient_frontier()
|
||||
|
||||
def deviation_risk_parity(w, cov_matrix):
|
||||
n = cov_matrix.shape[0]
|
||||
rp = (w * (cov_matrix @ w)) / cp.quad_form(w, cov_matrix)
|
||||
return cp.sum_squares(rp - 1 / n)
|
||||
|
||||
with pytest.raises(exceptions.OptimizationError):
|
||||
ef.convex_objective(deviation_risk_parity, cov_matrix=ef.cov_matrix)
|
||||
|
||||
|
||||
def test_custom_nonconvex_min_var():
|
||||
ef = setup_efficient_frontier()
|
||||
ef.min_volatility()
|
||||
original_vol = ef.portfolio_performance()[1]
|
||||
|
||||
# With custom objective
|
||||
ef = setup_efficient_frontier()
|
||||
ef.custom_objective(objective_functions.negative_sharpe,
|
||||
ef.expected_returns, ef.cov_matrix, 2)
|
||||
custom = ef.weights
|
||||
np.testing.assert_allclose(built_in, custom, atol=1e-7)
|
||||
ef.nonconvex_objective(
|
||||
objective_functions.portfolio_variance, objective_args=ef.cov_matrix
|
||||
)
|
||||
custom_vol = ef.portfolio_performance()[1]
|
||||
# Scipy should be close but not as good for this simple objective
|
||||
np.testing.assert_almost_equal(custom_vol, original_vol, decimal=5)
|
||||
assert original_vol < custom_vol
|
||||
|
||||
|
||||
def test_custom_logarithmic_barrier():
|
||||
def test_custom_nonconvex_logarithmic_barrier():
|
||||
# 60 Years of Portfolio Optimisation, Kolm et al (2014)
|
||||
ef = setup_efficient_frontier()
|
||||
|
||||
@@ -48,43 +119,85 @@ def test_custom_logarithmic_barrier():
|
||||
portfolio_volatility = np.dot(weights.T, np.dot(cov_matrix, weights))
|
||||
return portfolio_volatility - k * log_sum
|
||||
|
||||
w = ef.custom_objective(logarithmic_barrier, ef.cov_matrix, 0.1)
|
||||
w = ef.nonconvex_objective(logarithmic_barrier, objective_args=(ef.cov_matrix, 0.2))
|
||||
assert isinstance(w, dict)
|
||||
assert set(w.keys()) == set(ef.tickers)
|
||||
assert set(w.keys()) == set(ef.expected_returns.index)
|
||||
np.testing.assert_almost_equal(ef.weights.sum(), 1)
|
||||
|
||||
|
||||
def test_custom_deviation_risk_parity():
|
||||
# 60 Years of Portfolio Optimisation, Kolm et al (2014)
|
||||
def test_custom_nonconvex_deviation_risk_parity_1():
|
||||
# 60 Years of Portfolio Optimisation, Kolm et al (2014) - first definition
|
||||
ef = setup_efficient_frontier()
|
||||
|
||||
def deviation_risk_parity(w, cov_matrix):
|
||||
diff = w * np.dot(cov_matrix, w) - \
|
||||
(w * np.dot(cov_matrix, w)).reshape(-1, 1)
|
||||
diff = w * np.dot(cov_matrix, w) - (w * np.dot(cov_matrix, w)).reshape(-1, 1)
|
||||
return (diff ** 2).sum().sum()
|
||||
|
||||
w = ef.custom_objective(deviation_risk_parity, ef.cov_matrix)
|
||||
w = ef.nonconvex_objective(deviation_risk_parity, ef.cov_matrix)
|
||||
assert isinstance(w, dict)
|
||||
assert set(w.keys()) == set(ef.tickers)
|
||||
assert set(w.keys()) == set(ef.expected_returns.index)
|
||||
np.testing.assert_almost_equal(ef.weights.sum(), 1)
|
||||
|
||||
|
||||
def test_custom_utility_objective():
|
||||
def test_custom_nonconvex_deviation_risk_parity_2():
|
||||
# 60 Years of Portfolio Optimisation, Kolm et al (2014) - second definition
|
||||
ef = setup_efficient_frontier()
|
||||
|
||||
def deviation_risk_parity(w, cov_matrix):
|
||||
n = cov_matrix.shape[0]
|
||||
rp = (w * (cov_matrix @ w)) / cp.quad_form(w, cov_matrix)
|
||||
return cp.sum_squares(rp - 1 / n).value
|
||||
|
||||
w = ef.nonconvex_objective(deviation_risk_parity, ef.cov_matrix)
|
||||
assert isinstance(w, dict)
|
||||
assert set(w.keys()) == set(ef.tickers)
|
||||
np.testing.assert_almost_equal(ef.weights.sum(), 1)
|
||||
|
||||
|
||||
def test_custom_nonconvex_utility_objective():
|
||||
ef = setup_efficient_frontier()
|
||||
|
||||
def utility_obj(weights, mu, cov_matrix, k=1):
|
||||
return -weights.dot(mu) + k * np.dot(weights.T, np.dot(cov_matrix, weights))
|
||||
|
||||
w = ef.custom_objective(utility_obj, ef.expected_returns, ef.cov_matrix, 1)
|
||||
w = ef.nonconvex_objective(
|
||||
utility_obj, objective_args=(ef.expected_returns, ef.cov_matrix, 1)
|
||||
)
|
||||
assert isinstance(w, dict)
|
||||
assert set(w.keys()) == set(ef.tickers)
|
||||
assert set(w.keys()) == set(ef.expected_returns.index)
|
||||
np.testing.assert_almost_equal(ef.weights.sum(), 1)
|
||||
vol1 = ef.portfolio_performance()[1]
|
||||
|
||||
# If we increase k, volatility should decrease
|
||||
ef.custom_objective(utility_obj, ef.expected_returns, ef.cov_matrix, 2)
|
||||
w = ef.nonconvex_objective(
|
||||
utility_obj, objective_args=(ef.expected_returns, ef.cov_matrix, 3)
|
||||
)
|
||||
vol2 = ef.portfolio_performance()[1]
|
||||
assert vol2 < vol1
|
||||
|
||||
|
||||
def test_custom_nonconvex_objective_market_neutral_efficient_risk():
|
||||
# Recreate the market-neutral efficient_risk optimiser using this API
|
||||
target_risk = 0.19
|
||||
ef = EfficientFrontier(
|
||||
*setup_efficient_frontier(data_only=True), weight_bounds=(-1, 1)
|
||||
)
|
||||
|
||||
weight_constr = {"type": "eq", "fun": lambda w: np.sum(w)}
|
||||
risk_constr = {
|
||||
"type": "eq",
|
||||
"fun": lambda w: target_risk ** 2 - np.dot(w.T, np.dot(ef.cov_matrix, w)),
|
||||
}
|
||||
constraints = [weight_constr, risk_constr]
|
||||
|
||||
ef.nonconvex_objective(
|
||||
lambda w, mu: -w.T.dot(mu),
|
||||
objective_args=(ef.expected_returns),
|
||||
weights_sum_to_one=False,
|
||||
constraints=constraints,
|
||||
)
|
||||
np.testing.assert_allclose(
|
||||
ef.portfolio_performance(),
|
||||
(0.2309497754562942, target_risk, 1.1102600451243954),
|
||||
atol=1e-6,
|
||||
)
|
||||
|
||||
@@ -40,7 +40,7 @@ def test_greedy_portfolio_allocation():
|
||||
da = DiscreteAllocation(w, latest_prices)
|
||||
allocation, leftover = da.greedy_portfolio()
|
||||
|
||||
assert allocation == {
|
||||
assert {
|
||||
"MA": 14,
|
||||
"FB": 12,
|
||||
"PFE": 51,
|
||||
@@ -49,7 +49,6 @@ def test_greedy_portfolio_allocation():
|
||||
"BBY": 9,
|
||||
"SBUX": 6,
|
||||
"GOOG": 1,
|
||||
"AMD": 1,
|
||||
}
|
||||
|
||||
total = 0
|
||||
@@ -68,7 +67,7 @@ def test_greedy_allocation_rmse_error():
|
||||
latest_prices = get_latest_prices(df)
|
||||
da = DiscreteAllocation(w, latest_prices)
|
||||
da.greedy_portfolio()
|
||||
np.testing.assert_almost_equal(da._allocation_rmse_error(), 0.0257368)
|
||||
np.testing.assert_almost_equal(da._allocation_rmse_error(), 0.025762032436733803)
|
||||
|
||||
|
||||
def test_greedy_portfolio_allocation_short():
|
||||
@@ -124,7 +123,7 @@ def test_greedy_allocation_rmse_error_short():
|
||||
latest_prices = get_latest_prices(df)
|
||||
da = DiscreteAllocation(w, latest_prices)
|
||||
da.greedy_portfolio()
|
||||
np.testing.assert_almost_equal(da._allocation_rmse_error(), 0.03306318)
|
||||
np.testing.assert_almost_equal(da._allocation_rmse_error(), 0.033070015016740284)
|
||||
|
||||
|
||||
def test_greedy_portfolio_allocation_short_different_params():
|
||||
@@ -154,13 +153,13 @@ def test_greedy_portfolio_allocation_short_different_params():
|
||||
"XOM": 11,
|
||||
"BAC": -271,
|
||||
"GM": -133,
|
||||
"GE": -355,
|
||||
"SHLD": -923,
|
||||
"AMD": -284,
|
||||
"JPM": -6,
|
||||
"T": -13,
|
||||
"UAA": -7,
|
||||
"RRC": -2,
|
||||
"GE": -356,
|
||||
"SHLD": -922,
|
||||
"AMD": -285,
|
||||
"JPM": -5,
|
||||
"T": -14,
|
||||
"UAA": -8,
|
||||
"RRC": -3,
|
||||
}
|
||||
long_total = 0
|
||||
short_total = 0
|
||||
@@ -184,14 +183,14 @@ def test_lp_portfolio_allocation():
|
||||
allocation, leftover = da.lp_portfolio()
|
||||
|
||||
assert da.allocation == {
|
||||
"AAPL": 5.0,
|
||||
"FB": 11.0,
|
||||
"BABA": 5.0,
|
||||
"AMZN": 1.0,
|
||||
"BBY": 7.0,
|
||||
"MA": 14.0,
|
||||
"PFE": 50.0,
|
||||
"SBUX": 5.0,
|
||||
"AAPL": 5,
|
||||
"FB": 11,
|
||||
"BABA": 5,
|
||||
"AMZN": 1,
|
||||
"BBY": 7,
|
||||
"MA": 14,
|
||||
"PFE": 50,
|
||||
"SBUX": 5,
|
||||
}
|
||||
total = 0
|
||||
for ticker, num in allocation.items():
|
||||
@@ -209,7 +208,7 @@ def test_lp_allocation_rmse_error():
|
||||
latest_prices = get_latest_prices(df)
|
||||
da = DiscreteAllocation(w, latest_prices)
|
||||
da.lp_portfolio()
|
||||
np.testing.assert_almost_equal(da._allocation_rmse_error(verbose=False), 0.0170634)
|
||||
np.testing.assert_almost_equal(da._allocation_rmse_error(verbose=False), 0.017070218149194846)
|
||||
|
||||
|
||||
def test_lp_portfolio_allocation_short():
|
||||
@@ -224,24 +223,24 @@ def test_lp_portfolio_allocation_short():
|
||||
allocation, leftover = da.lp_portfolio()
|
||||
|
||||
assert da.allocation == {
|
||||
"GOOG": 1.0,
|
||||
"AAPL": 5.0,
|
||||
"FB": 8.0,
|
||||
"BABA": 5.0,
|
||||
"WMT": 2.0,
|
||||
"XOM": 2.0,
|
||||
"BBY": 9.0,
|
||||
"MA": 16.0,
|
||||
"PFE": 46.0,
|
||||
"SBUX": 9.0,
|
||||
"GE": -43.0,
|
||||
"AMD": -34.0,
|
||||
"BAC": -32.0,
|
||||
"GM": -16.0,
|
||||
"T": -1.0,
|
||||
"UAA": -1.0,
|
||||
"SHLD": -110.0,
|
||||
"JPM": -1.0,
|
||||
"GOOG": 1,
|
||||
"AAPL": 5,
|
||||
"FB": 8,
|
||||
"BABA": 5,
|
||||
"WMT": 2,
|
||||
"XOM": 2,
|
||||
"BBY": 9,
|
||||
"MA": 16,
|
||||
"PFE": 46,
|
||||
"SBUX": 9,
|
||||
"GE": -43,
|
||||
"AMD": -34,
|
||||
"BAC": -32,
|
||||
"GM": -16,
|
||||
"T": -1,
|
||||
"UAA": -1,
|
||||
"SHLD": -110,
|
||||
"JPM": -1,
|
||||
}
|
||||
long_total = 0
|
||||
short_total = 0
|
||||
@@ -265,7 +264,7 @@ def test_lp_allocation_rmse_error_short():
|
||||
latest_prices = get_latest_prices(df)
|
||||
da = DiscreteAllocation(w, latest_prices)
|
||||
da.lp_portfolio()
|
||||
np.testing.assert_almost_equal(da._allocation_rmse_error(), 0.02699558)
|
||||
np.testing.assert_almost_equal(da._allocation_rmse_error(), 0.027018566693989568)
|
||||
|
||||
|
||||
def test_lp_portfolio_allocation_different_params():
|
||||
@@ -282,17 +281,17 @@ def test_lp_portfolio_allocation_different_params():
|
||||
allocation, leftover = da.lp_portfolio()
|
||||
|
||||
assert da.allocation == {
|
||||
"GOOG": 1.0,
|
||||
"AAPL": 43.0,
|
||||
"FB": 95.0,
|
||||
"BABA": 44.0,
|
||||
"AMZN": 4.0,
|
||||
"AMD": 1.0,
|
||||
"SHLD": 3.0,
|
||||
"BBY": 69.0,
|
||||
"MA": 114.0,
|
||||
"PFE": 412.0,
|
||||
"SBUX": 51.0,
|
||||
"GOOG": 1,
|
||||
"AAPL": 43,
|
||||
"FB": 95,
|
||||
"BABA": 44,
|
||||
"AMZN": 4,
|
||||
"AMD": 1,
|
||||
"SHLD": 3,
|
||||
"BBY": 69,
|
||||
"MA": 114,
|
||||
"PFE": 412,
|
||||
"SBUX": 51,
|
||||
}
|
||||
total = 0
|
||||
for ticker, num in allocation.items():
|
||||
|
||||
Reference in New Issue
Block a user