mirror of
https://github.com/robertmartin8/PyPortfolioOpt.git
synced 2022-11-27 18:02:41 +03:00
Merge branch 'v1.5.0' of https://github.com/robertmartin8/PyPortfolioOpt into v1.5.0
This commit is contained in:
@@ -9,10 +9,14 @@ evaluate return and risk for a given set of portfolio weights.
|
||||
import collections
|
||||
import json
|
||||
import warnings
|
||||
from collections.abc import Iterable
|
||||
from typing import List
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import cvxpy as cp
|
||||
import scipy.optimize as sco
|
||||
|
||||
from . import objective_functions
|
||||
from . import exceptions
|
||||
|
||||
@@ -174,12 +178,11 @@ class BaseConvexOptimizer(BaseOptimizer):
|
||||
self._constraints = []
|
||||
self._lower_bounds = None
|
||||
self._upper_bounds = None
|
||||
self._map_bounds_to_constraints(weight_bounds)
|
||||
|
||||
self._opt = None
|
||||
self._solver = solver
|
||||
self._verbose = verbose
|
||||
self._solver_options = solver_options if solver_options else {}
|
||||
self._map_bounds_to_constraints(weight_bounds)
|
||||
|
||||
def _map_bounds_to_constraints(self, test_bounds):
|
||||
"""
|
||||
@@ -218,8 +221,32 @@ class BaseConvexOptimizer(BaseOptimizer):
|
||||
self._lower_bounds = np.nan_to_num(lower, nan=-1)
|
||||
self._upper_bounds = np.nan_to_num(upper, nan=1)
|
||||
|
||||
self._constraints.append(self._w >= self._lower_bounds)
|
||||
self._constraints.append(self._w <= self._upper_bounds)
|
||||
self.add_constraint(lambda w: w >= self._lower_bounds)
|
||||
self.add_constraint(lambda w: w <= self._upper_bounds)
|
||||
|
||||
def is_parameter_defined(self, parameter_name: str) -> bool:
|
||||
is_defined = False
|
||||
objective_and_constraints = self._constraints + [self._objective] if self._objective is not None else self._constraints
|
||||
for expr in objective_and_constraints:
|
||||
params = [arg for arg in get_all_args(expr) if isinstance(arg, cp.Parameter)]
|
||||
for param in params:
|
||||
if param.name() == parameter_name and not is_defined:
|
||||
is_defined = True
|
||||
elif param.name() == parameter_name and is_defined:
|
||||
raise Exception('Parameter name defined multiple times')
|
||||
return is_defined
|
||||
|
||||
def update_parameter_value(self, parameter_name: str, new_value: float) -> None:
|
||||
assert self.is_parameter_defined(parameter_name)
|
||||
was_updated = False
|
||||
objective_and_constraints = self._constraints + [self._objective] if self._objective is not None else self._constraints
|
||||
for expr in objective_and_constraints:
|
||||
params = [arg for arg in get_all_args(expr) if isinstance(arg, cp.Parameter)]
|
||||
for param in params:
|
||||
if param.name() == parameter_name:
|
||||
param.value = new_value
|
||||
was_updated = True
|
||||
assert was_updated
|
||||
|
||||
def _solve_cvxpy_opt_problem(self):
|
||||
"""
|
||||
@@ -229,14 +256,19 @@ class BaseConvexOptimizer(BaseOptimizer):
|
||||
:raises exceptions.OptimizationError: if problem is not solvable by cvxpy
|
||||
"""
|
||||
try:
|
||||
if self._opt is None:
|
||||
self._opt = cp.Problem(cp.Minimize(self._objective), self._constraints)
|
||||
|
||||
if self._solver is not None:
|
||||
self._opt.solve(
|
||||
solver=self._solver, verbose=self._verbose, **self._solver_options
|
||||
)
|
||||
self._initial_objective = self._objective.id
|
||||
self._initial_constraint_ids = {const.id for const in self._constraints}
|
||||
else:
|
||||
self._opt.solve(verbose=self._verbose, **self._solver_options)
|
||||
assert self._objective.id == self._initial_objective, \
|
||||
"The objective function was changed after the initial optimization. " \
|
||||
"Please create a new instance instead."
|
||||
assert {const.id for const in self._constraints} == self._initial_constraint_ids, \
|
||||
"The constraints were changed after the initial optimization. " \
|
||||
"Please create a new instance instead."
|
||||
self._opt.solve(solver=self._solver, verbose=self._verbose, **self._solver_options)
|
||||
|
||||
except (TypeError, cp.DCPError) as e:
|
||||
raise exceptions.OptimizationError from e
|
||||
|
||||
@@ -262,6 +294,9 @@ class BaseConvexOptimizer(BaseOptimizer):
|
||||
:param new_objective: the objective to be added
|
||||
:type new_objective: cp.Expression (i.e function of cp.Variable)
|
||||
"""
|
||||
if self._opt is not None:
|
||||
raise Exception('Adding objectives to an already solved problem might have unintended consequences.'
|
||||
'A new instance should be created for the new set of objectives.')
|
||||
self._additional_objectives.append(new_objective(self._w, **kwargs))
|
||||
|
||||
def add_constraint(self, new_constraint):
|
||||
@@ -280,6 +315,9 @@ class BaseConvexOptimizer(BaseOptimizer):
|
||||
"""
|
||||
if not callable(new_constraint):
|
||||
raise TypeError("New constraint must be provided as a lambda function")
|
||||
if self._opt is not None:
|
||||
raise Exception('Adding constraints to an already solved problem might have unintended consequences.'
|
||||
'A new instance should be created for the new set of constraints.')
|
||||
self._constraints.append(new_constraint(self._w))
|
||||
|
||||
def add_sector_constraints(self, sector_mapper, sector_lower, sector_upper):
|
||||
@@ -316,10 +354,10 @@ class BaseConvexOptimizer(BaseOptimizer):
|
||||
)
|
||||
for sector in sector_upper:
|
||||
is_sector = [sector_mapper[t] == sector for t in self.tickers]
|
||||
self._constraints.append(cp.sum(self._w[is_sector]) <= sector_upper[sector])
|
||||
self.add_constraint(lambda w: cp.sum(w[is_sector]) <= sector_upper[sector])
|
||||
for sector in sector_lower:
|
||||
is_sector = [sector_mapper[t] == sector for t in self.tickers]
|
||||
self._constraints.append(cp.sum(self._w[is_sector]) >= sector_lower[sector])
|
||||
self.add_constraint(lambda w: cp.sum(w[is_sector]) >= sector_lower[sector])
|
||||
|
||||
def convex_objective(self, custom_objective, weights_sum_to_one=True, **kwargs):
|
||||
"""
|
||||
@@ -349,7 +387,7 @@ class BaseConvexOptimizer(BaseOptimizer):
|
||||
self._objective += obj
|
||||
|
||||
if weights_sum_to_one:
|
||||
self._constraints.append(cp.sum(self._w) == 1)
|
||||
self.add_constraint(lambda w: cp.sum(w) == 1)
|
||||
|
||||
return self._solve_cvxpy_opt_problem()
|
||||
|
||||
@@ -413,7 +451,7 @@ class BaseConvexOptimizer(BaseOptimizer):
|
||||
# Construct constraints
|
||||
final_constraints = []
|
||||
if weights_sum_to_one:
|
||||
final_constraints.append({"type": "eq", "fun": lambda x: np.sum(x) - 1})
|
||||
final_constraints.append({"type": "eq", "fun": lambda w: np.sum(w) - 1})
|
||||
if constraints is not None:
|
||||
final_constraints += constraints
|
||||
|
||||
@@ -493,3 +531,18 @@ def portfolio_performance(
|
||||
if verbose:
|
||||
print("Annual volatility: {:.1f}%".format(100 * sigma))
|
||||
return None, sigma, None
|
||||
|
||||
|
||||
def get_all_args(expression: cp.Expression) -> List[cp.Expression]:
|
||||
if expression.args == []:
|
||||
return [expression]
|
||||
else:
|
||||
return list(flatten([get_all_args(arg) for arg in expression.args]))
|
||||
|
||||
|
||||
def flatten(l: Iterable) -> Iterable:
|
||||
for el in l:
|
||||
if isinstance(el, Iterable) and not isinstance(el, (str, bytes)):
|
||||
yield from flatten(el)
|
||||
else:
|
||||
yield el
|
||||
@@ -125,14 +125,7 @@ class EfficientCDaR(EfficientFrontier):
|
||||
for obj in self._additional_objectives:
|
||||
self._objective += obj
|
||||
|
||||
self._constraints += [
|
||||
self._z >= self._u[1:] - self._alpha,
|
||||
self._u[1:] >= self._u[:-1] - self.returns.values @ self._w,
|
||||
self._u[0] == 0,
|
||||
self._z >= 0,
|
||||
self._u[1:] >= 0,
|
||||
]
|
||||
|
||||
self._add_cdar_constraints()
|
||||
self._make_weight_sum_constraint(market_neutral)
|
||||
return self._solve_cvxpy_opt_problem()
|
||||
|
||||
@@ -150,26 +143,17 @@ class EfficientCDaR(EfficientFrontier):
|
||||
:return: asset weights for the optimal portfolio
|
||||
:rtype: OrderedDict
|
||||
"""
|
||||
self._objective = self._alpha + 1.0 / (
|
||||
len(self.returns) * (1 - self._beta)
|
||||
) * cp.sum(self._z)
|
||||
|
||||
for obj in self._additional_objectives:
|
||||
self._objective += obj
|
||||
|
||||
self._constraints += [
|
||||
self._z >= self._u[1:] - self._alpha,
|
||||
self._u[1:] >= self._u[:-1] - self.returns.values @ self._w,
|
||||
self._u[0] == 0,
|
||||
self._z >= 0,
|
||||
self._u[1:] >= 0,
|
||||
]
|
||||
|
||||
ret = self.expected_returns.T @ self._w
|
||||
self._constraints.append(ret >= target_return)
|
||||
|
||||
self._make_weight_sum_constraint(market_neutral)
|
||||
update_existing_parameter = self.is_parameter_defined('target_return')
|
||||
if update_existing_parameter:
|
||||
self._validate_market_neutral(market_neutral)
|
||||
self.update_parameter_value('target_return', target_return)
|
||||
return self._solve_cvxpy_opt_problem()
|
||||
else:
|
||||
ret = self.expected_returns.T @ self._w
|
||||
target_return_par = cp.Parameter(value=target_return, name='target_return', nonneg=True)
|
||||
self._constraints.append(ret >= target_return_par)
|
||||
return self.min_cdar(market_neutral)
|
||||
|
||||
def efficient_risk(self, target_cdar, market_neutral=False):
|
||||
"""
|
||||
@@ -185,6 +169,12 @@ class EfficientCDaR(EfficientFrontier):
|
||||
:return: asset weights for the efficient risk portfolio
|
||||
:rtype: OrderedDict
|
||||
"""
|
||||
|
||||
update_existing_parameter = self.is_parameter_defined('target_cdar')
|
||||
if update_existing_parameter:
|
||||
self._validate_market_neutral(market_neutral)
|
||||
self.update_parameter_value('target_cdar', target_cdar)
|
||||
else:
|
||||
self._objective = objective_functions.portfolio_return(
|
||||
self._w, self.expected_returns
|
||||
)
|
||||
@@ -194,18 +184,21 @@ class EfficientCDaR(EfficientFrontier):
|
||||
cdar = self._alpha + 1.0 / (len(self.returns) * (1 - self._beta)) * cp.sum(
|
||||
self._z
|
||||
)
|
||||
self._constraints += [
|
||||
cdar <= target_cdar,
|
||||
self._z >= self._u[1:] - self._alpha,
|
||||
self._u[1:] >= self._u[:-1] - self.returns.values @ self._w,
|
||||
self._u[0] == 0,
|
||||
self._z >= 0,
|
||||
self._u[1:] >= 0,
|
||||
]
|
||||
target_cdar_par = cp.Parameter(value=target_cdar, name='target_cdar', nonneg=True)
|
||||
self.add_constraint(lambda _: cdar <= target_cdar_par)
|
||||
|
||||
self._add_cdar_constraints()
|
||||
|
||||
self._make_weight_sum_constraint(market_neutral)
|
||||
return self._solve_cvxpy_opt_problem()
|
||||
|
||||
def _add_cdar_constraints(self) -> None:
|
||||
self.add_constraint(lambda _: self._z >= self._u[1:] - self._alpha)
|
||||
self.add_constraint(lambda w: self._u[1:] >= self._u[:-1] - self.returns.values @ w)
|
||||
self.add_constraint(lambda _: self._u[0] == 0)
|
||||
self.add_constraint(lambda _: self._z >= 0)
|
||||
self.add_constraint(lambda _: self._u[1:] >= 0)
|
||||
|
||||
def portfolio_performance(self, verbose=False):
|
||||
"""
|
||||
After optimising, calculate (and optionally print) the performance of the optimal
|
||||
|
||||
@@ -57,7 +57,7 @@ class EfficientCVaR(EfficientFrontier):
|
||||
):
|
||||
"""
|
||||
:param expected_returns: expected returns for each asset. Can be None if
|
||||
optimising for semideviation only.
|
||||
optimising for conditional value at risk only.
|
||||
:type expected_returns: pd.Series, list, np.ndarray
|
||||
:param returns: (historic) returns for all your assets (no NaNs).
|
||||
See ``expected_returns.returns_from_prices``.
|
||||
@@ -126,10 +126,8 @@ class EfficientCVaR(EfficientFrontier):
|
||||
for obj in self._additional_objectives:
|
||||
self._objective += obj
|
||||
|
||||
self._constraints += [
|
||||
self._u >= 0.0,
|
||||
self.returns.values @ self._w + self._alpha + self._u >= 0.0,
|
||||
]
|
||||
self.add_constraint(lambda _: self._u >= 0.0)
|
||||
self.add_constraint(lambda w: self.returns.values @ w + self._alpha + self._u >= 0.0)
|
||||
|
||||
self._make_weight_sum_constraint(market_neutral)
|
||||
return self._solve_cvxpy_opt_problem()
|
||||
@@ -148,6 +146,11 @@ class EfficientCVaR(EfficientFrontier):
|
||||
:return: asset weights for the optimal portfolio
|
||||
:rtype: OrderedDict
|
||||
"""
|
||||
update_existing_parameter = self.is_parameter_defined('target_return')
|
||||
if update_existing_parameter:
|
||||
self._validate_market_neutral(market_neutral)
|
||||
self.update_parameter_value('target_return', target_return)
|
||||
else:
|
||||
self._objective = self._alpha + 1.0 / (
|
||||
len(self.returns) * (1 - self._beta)
|
||||
) * cp.sum(self._u)
|
||||
@@ -155,13 +158,12 @@ class EfficientCVaR(EfficientFrontier):
|
||||
for obj in self._additional_objectives:
|
||||
self._objective += obj
|
||||
|
||||
self._constraints += [
|
||||
self._u >= 0.0,
|
||||
self.returns.values @ self._w + self._alpha + self._u >= 0.0,
|
||||
]
|
||||
self.add_constraint(lambda _: self._u >= 0.0)
|
||||
self.add_constraint(lambda w: self.returns.values @ w + self._alpha + self._u >= 0.0)
|
||||
|
||||
ret = self.expected_returns.T @ self._w
|
||||
self._constraints.append(ret >= target_return)
|
||||
target_return_par = cp.Parameter(name='target_return', value=target_return)
|
||||
self.add_constraint(lambda _: ret >= target_return_par)
|
||||
|
||||
self._make_weight_sum_constraint(market_neutral)
|
||||
return self._solve_cvxpy_opt_problem()
|
||||
@@ -172,7 +174,7 @@ class EfficientCVaR(EfficientFrontier):
|
||||
The resulting portfolio will have a CVaR less than the target
|
||||
(but not guaranteed to be equal).
|
||||
|
||||
:param target_cvar: the desired maximum semideviation of the resulting portfolio.
|
||||
:param target_cvar: the desired conditional value at risk of the resulting portfolio.
|
||||
:type target_cvar: float
|
||||
:param market_neutral: whether the portfolio should be market neutral (weights sum to zero),
|
||||
defaults to False. Requires negative lower weight bound.
|
||||
@@ -180,6 +182,11 @@ class EfficientCVaR(EfficientFrontier):
|
||||
:return: asset weights for the efficient risk portfolio
|
||||
:rtype: OrderedDict
|
||||
"""
|
||||
update_existing_parameter = self.is_parameter_defined('target_cvar')
|
||||
if update_existing_parameter:
|
||||
self._validate_market_neutral(market_neutral)
|
||||
self.update_parameter_value('target_cvar', target_cvar)
|
||||
else:
|
||||
self._objective = objective_functions.portfolio_return(
|
||||
self._w, self.expected_returns
|
||||
)
|
||||
@@ -189,11 +196,11 @@ class EfficientCVaR(EfficientFrontier):
|
||||
cvar = self._alpha + 1.0 / (len(self.returns) * (1 - self._beta)) * cp.sum(
|
||||
self._u
|
||||
)
|
||||
self._constraints += [
|
||||
cvar <= target_cvar,
|
||||
self._u >= 0.0,
|
||||
self.returns.values @ self._w + self._alpha + self._u >= 0.0,
|
||||
]
|
||||
target_cvar_par = cp.Parameter(value=target_cvar, name='target_cvar', nonneg=True)
|
||||
|
||||
self.add_constraint(lambda _: cvar <= target_cvar_par)
|
||||
self.add_constraint(lambda _: self._u >= 0.0)
|
||||
self.add_constraint(lambda w: self.returns.values @ w + self._alpha + self._u >= 0.0)
|
||||
|
||||
self._make_weight_sum_constraint(market_neutral)
|
||||
return self._solve_cvxpy_opt_problem()
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
The ``efficient_frontier`` submodule houses the EfficientFrontier class, which generates
|
||||
classical mean-variance optimal portfolios for a variety of objectives and constraints
|
||||
"""
|
||||
|
||||
import copy
|
||||
import warnings
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import cvxpy as cp
|
||||
@@ -86,6 +87,8 @@ class EfficientFrontier(base_optimizer.BaseConvexOptimizer):
|
||||
self.expected_returns = EfficientFrontier._validate_expected_returns(
|
||||
expected_returns
|
||||
)
|
||||
self._max_return_value = None
|
||||
self._market_neutral = None
|
||||
|
||||
if self.expected_returns is None:
|
||||
num_assets = len(cov_matrix)
|
||||
@@ -178,9 +181,11 @@ class EfficientFrontier(base_optimizer.BaseConvexOptimizer):
|
||||
del self._constraints[0]
|
||||
del self._constraints[0]
|
||||
|
||||
self._constraints.append(cp.sum(self._w) == 0)
|
||||
|
||||
self.add_constraint(lambda w: cp.sum(w) == 0)
|
||||
else:
|
||||
self._constraints.append(cp.sum(self._w) == 1)
|
||||
self.add_constraint(lambda w: cp.sum(w) == 1)
|
||||
self._market_neutral = is_market_neutral
|
||||
|
||||
def min_volatility(self):
|
||||
"""
|
||||
@@ -195,7 +200,7 @@ class EfficientFrontier(base_optimizer.BaseConvexOptimizer):
|
||||
for obj in self._additional_objectives:
|
||||
self._objective += obj
|
||||
|
||||
self._constraints.append(cp.sum(self._w) == 1)
|
||||
self.add_constraint(lambda w: cp.sum(w) == 1)
|
||||
return self._solve_cvxpy_opt_problem()
|
||||
|
||||
def _max_return(self, return_value=True):
|
||||
@@ -212,13 +217,10 @@ class EfficientFrontier(base_optimizer.BaseConvexOptimizer):
|
||||
self._w, self.expected_returns
|
||||
)
|
||||
|
||||
self._constraints.append(cp.sum(self._w) == 1)
|
||||
self.add_constraint(lambda w: cp.sum(w) == 1)
|
||||
|
||||
res = self._solve_cvxpy_opt_problem()
|
||||
|
||||
# Cleanup constraints since this is a helper method
|
||||
del self._constraints[-1]
|
||||
|
||||
if return_value:
|
||||
return -self._opt.value
|
||||
else:
|
||||
@@ -308,6 +310,11 @@ class EfficientFrontier(base_optimizer.BaseConvexOptimizer):
|
||||
if risk_aversion <= 0:
|
||||
raise ValueError("risk aversion coefficient must be greater than zero")
|
||||
|
||||
update_existing_parameter = self.is_parameter_defined('risk_aversion')
|
||||
if update_existing_parameter:
|
||||
self._validate_market_neutral(market_neutral)
|
||||
self.update_parameter_value('risk_aversion', risk_aversion)
|
||||
else:
|
||||
self._objective = objective_functions.quadratic_utility(
|
||||
self._w, self.expected_returns, self.cov_matrix, risk_aversion=risk_aversion
|
||||
)
|
||||
@@ -345,6 +352,11 @@ class EfficientFrontier(base_optimizer.BaseConvexOptimizer):
|
||||
)
|
||||
)
|
||||
|
||||
update_existing_parameter = self.is_parameter_defined('target_variance')
|
||||
if update_existing_parameter:
|
||||
self._validate_market_neutral(market_neutral)
|
||||
self.update_parameter_value('target_variance', target_volatility ** 2)
|
||||
else:
|
||||
self._objective = objective_functions.portfolio_return(
|
||||
self._w, self.expected_returns
|
||||
)
|
||||
@@ -353,7 +365,8 @@ class EfficientFrontier(base_optimizer.BaseConvexOptimizer):
|
||||
for obj in self._additional_objectives:
|
||||
self._objective += obj
|
||||
|
||||
self._constraints.append(variance <= target_volatility ** 2)
|
||||
target_variance = cp.Parameter(name="target_variance", value=target_volatility ** 2, nonneg=True)
|
||||
self.add_constraint(lambda _: variance <= target_variance)
|
||||
self._make_weight_sum_constraint(market_neutral)
|
||||
return self._solve_cvxpy_opt_problem()
|
||||
|
||||
@@ -373,11 +386,18 @@ class EfficientFrontier(base_optimizer.BaseConvexOptimizer):
|
||||
"""
|
||||
if not isinstance(target_return, float) or target_return < 0:
|
||||
raise ValueError("target_return should be a positive float")
|
||||
if target_return > self._max_return():
|
||||
if not self._max_return_value:
|
||||
self._max_return_value = copy.deepcopy(self)._max_return()
|
||||
if target_return > self._max_return_value:
|
||||
raise ValueError(
|
||||
"target_return must be lower than the maximum possible return"
|
||||
)
|
||||
|
||||
update_existing_parameter = self.is_parameter_defined('target_return')
|
||||
if update_existing_parameter:
|
||||
self._validate_market_neutral(market_neutral)
|
||||
self.update_parameter_value('target_return', target_return)
|
||||
else:
|
||||
self._objective = objective_functions.portfolio_variance(
|
||||
self._w, self.cov_matrix
|
||||
)
|
||||
@@ -388,7 +408,8 @@ class EfficientFrontier(base_optimizer.BaseConvexOptimizer):
|
||||
for obj in self._additional_objectives:
|
||||
self._objective += obj
|
||||
|
||||
self._constraints.append(ret >= target_return)
|
||||
target_return_par = cp.Parameter(name='target_return', value=target_return)
|
||||
self.add_constraint(lambda _: ret >= target_return_par)
|
||||
self._make_weight_sum_constraint(market_neutral)
|
||||
return self._solve_cvxpy_opt_problem()
|
||||
|
||||
@@ -423,3 +444,6 @@ class EfficientFrontier(base_optimizer.BaseConvexOptimizer):
|
||||
verbose,
|
||||
risk_free_rate,
|
||||
)
|
||||
|
||||
def _validate_market_neutral(self, market_neutral: bool) -> None:
|
||||
assert self._market_neutral == market_neutral, 'A new instance must be created when changing market_neutral'
|
||||
|
||||
@@ -125,7 +125,7 @@ class EfficientSemivariance(EfficientFrontier):
|
||||
self._objective += obj
|
||||
|
||||
B = (self.returns.values - self.benchmark) / np.sqrt(self._T)
|
||||
self._constraints.append(B @ self._w - p + n == 0)
|
||||
self.add_constraint(lambda w: B @ w - p + n == 0)
|
||||
self._make_weight_sum_constraint(market_neutral)
|
||||
return self._solve_cvxpy_opt_problem()
|
||||
|
||||
@@ -146,16 +146,22 @@ class EfficientSemivariance(EfficientFrontier):
|
||||
if risk_aversion <= 0:
|
||||
raise ValueError("risk aversion coefficient must be greater than zero")
|
||||
|
||||
update_existing_parameter = self.is_parameter_defined('risk_aversion')
|
||||
if update_existing_parameter:
|
||||
self._validate_market_neutral(market_neutral)
|
||||
self.update_parameter_value('risk_aversion', risk_aversion)
|
||||
else:
|
||||
p = cp.Variable(self._T, nonneg=True)
|
||||
n = cp.Variable(self._T, nonneg=True)
|
||||
mu = objective_functions.portfolio_return(self._w, self.expected_returns)
|
||||
mu /= self.frequency
|
||||
self._objective = mu + 0.5 * risk_aversion * cp.sum(cp.square(n))
|
||||
risk_aversion_par = cp.Parameter(value=risk_aversion, name='risk_aversion', nonneg=True)
|
||||
self._objective = mu + 0.5 * risk_aversion_par * cp.sum(cp.square(n))
|
||||
for obj in self._additional_objectives:
|
||||
self._objective += obj
|
||||
|
||||
B = (self.returns.values - self.benchmark) / np.sqrt(self._T)
|
||||
self._constraints.append(B @ self._w - p + n == 0)
|
||||
self.add_constraint(lambda w: B @ w - p + n == 0)
|
||||
self._make_weight_sum_constraint(market_neutral)
|
||||
return self._solve_cvxpy_opt_problem()
|
||||
|
||||
@@ -173,6 +179,11 @@ class EfficientSemivariance(EfficientFrontier):
|
||||
:return: asset weights for the efficient risk portfolio
|
||||
:rtype: OrderedDict
|
||||
"""
|
||||
update_existing_parameter = self.is_parameter_defined('target_semivariance')
|
||||
if update_existing_parameter:
|
||||
self._validate_market_neutral(market_neutral)
|
||||
self.update_parameter_value('target_semivariance', target_semideviation ** 2)
|
||||
else:
|
||||
self._objective = objective_functions.portfolio_return(
|
||||
self._w, self.expected_returns
|
||||
)
|
||||
@@ -182,11 +193,10 @@ class EfficientSemivariance(EfficientFrontier):
|
||||
p = cp.Variable(self._T, nonneg=True)
|
||||
n = cp.Variable(self._T, nonneg=True)
|
||||
|
||||
self._constraints.append(
|
||||
self.frequency * cp.sum(cp.square(n)) <= (target_semideviation ** 2)
|
||||
)
|
||||
target_semivariance = cp.Parameter(value=target_semideviation**2, name='target_semivariance', nonneg=True)
|
||||
self.add_constraint(lambda _: self.frequency * cp.sum(cp.square(n)) <= target_semivariance)
|
||||
B = (self.returns.values - self.benchmark) / np.sqrt(self._T)
|
||||
self._constraints.append(B @ self._w - p + n == 0)
|
||||
self.add_constraint(lambda w: B @ w - p + n == 0)
|
||||
self._make_weight_sum_constraint(market_neutral)
|
||||
return self._solve_cvxpy_opt_problem()
|
||||
|
||||
@@ -210,17 +220,23 @@ class EfficientSemivariance(EfficientFrontier):
|
||||
raise ValueError(
|
||||
"target_return must be lower than the largest expected return"
|
||||
)
|
||||
|
||||
update_existing_parameter = self.is_parameter_defined('target_return')
|
||||
if update_existing_parameter:
|
||||
self._validate_market_neutral(market_neutral)
|
||||
self.update_parameter_value('target_return', target_return)
|
||||
else:
|
||||
|
||||
p = cp.Variable(self._T, nonneg=True)
|
||||
n = cp.Variable(self._T, nonneg=True)
|
||||
self._objective = cp.sum(cp.square(n))
|
||||
for obj in self._additional_objectives:
|
||||
self._objective += obj
|
||||
|
||||
self._constraints.append(
|
||||
cp.sum(self._w @ self.expected_returns) >= target_return
|
||||
)
|
||||
target_return_par = cp.Parameter(name='target_return', value=target_return)
|
||||
self.add_constraint(lambda w: cp.sum(w @ self.expected_returns) >= target_return_par)
|
||||
B = (self.returns.values - self.benchmark) / np.sqrt(self._T)
|
||||
self._constraints.append(B @ self._w - p + n == 0)
|
||||
self.add_constraint(lambda w: B @ w - p + n == 0)
|
||||
self._make_weight_sum_constraint(market_neutral)
|
||||
return self._solve_cvxpy_opt_problem()
|
||||
|
||||
|
||||
@@ -158,7 +158,8 @@ def quadratic_utility(w, expected_returns, cov_matrix, risk_aversion, negative=T
|
||||
mu = w @ expected_returns
|
||||
variance = cp.quad_form(w, cov_matrix)
|
||||
|
||||
utility = mu - 0.5 * risk_aversion * variance
|
||||
risk_aversion_par = cp.Parameter(value=risk_aversion, name='risk_aversion', nonneg=True)
|
||||
utility = mu - 0.5 * risk_aversion_par * variance
|
||||
return _objective_value(w, sign * utility)
|
||||
|
||||
|
||||
|
||||
@@ -168,15 +168,13 @@ def _plot_ef(ef, ef_param, ef_param_range, ax, show_assets):
|
||||
|
||||
# Create a portfolio for each value of ef_param_range
|
||||
for param_value in ef_param_range:
|
||||
ef_i = copy.deepcopy(ef)
|
||||
|
||||
try:
|
||||
if ef_param == "utility":
|
||||
ef_i.max_quadratic_utility(param_value)
|
||||
ef.max_quadratic_utility(param_value)
|
||||
elif ef_param == "risk":
|
||||
ef_i.efficient_risk(param_value)
|
||||
ef.efficient_risk(param_value)
|
||||
elif ef_param == "return":
|
||||
ef_i.efficient_return(param_value)
|
||||
ef.efficient_return(param_value)
|
||||
else:
|
||||
raise NotImplementedError(
|
||||
"ef_param should be one of {'utility', 'risk', 'return'}"
|
||||
@@ -190,7 +188,7 @@ def _plot_ef(ef, ef_param, ef_param_range, ax, show_assets):
|
||||
)
|
||||
)
|
||||
|
||||
ret, sigma, _ = ef_i.portfolio_performance()
|
||||
ret, sigma, _ = ef.portfolio_performance()
|
||||
mus.append(ret)
|
||||
sigmas.append(sigma)
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import pandas as pd
|
||||
import pytest
|
||||
import cvxpy as cp
|
||||
|
||||
from pypfopt import EfficientFrontier
|
||||
from pypfopt import EfficientFrontier, objective_functions
|
||||
from pypfopt import exceptions
|
||||
from pypfopt.base_optimizer import portfolio_performance, BaseOptimizer
|
||||
from tests.utilities_for_tests import get_data, setup_efficient_frontier
|
||||
@@ -56,7 +56,10 @@ def test_weight_bounds_minus_one_to_one():
|
||||
*setup_efficient_frontier(data_only=True), weight_bounds=(-1, 1)
|
||||
)
|
||||
assert ef.max_sharpe()
|
||||
assert ef.min_volatility()
|
||||
ef2 = EfficientFrontier(
|
||||
*setup_efficient_frontier(data_only=True), weight_bounds=(-1, 1)
|
||||
)
|
||||
assert ef2.min_volatility()
|
||||
|
||||
|
||||
def test_none_bounds():
|
||||
@@ -189,12 +192,13 @@ def test_efficient_frontier_init_errors():
|
||||
|
||||
|
||||
def test_set_weights():
|
||||
ef = setup_efficient_frontier()
|
||||
w1 = ef.min_volatility()
|
||||
test_weights = ef.weights
|
||||
ef.min_volatility()
|
||||
ef.set_weights(w1)
|
||||
np.testing.assert_array_almost_equal(test_weights, ef.weights)
|
||||
ef1 = setup_efficient_frontier()
|
||||
w1 = ef1.min_volatility()
|
||||
test_weights = ef1.weights
|
||||
ef2 = setup_efficient_frontier()
|
||||
ef2.min_volatility()
|
||||
ef2.set_weights(w1)
|
||||
np.testing.assert_array_almost_equal(test_weights, ef2.weights)
|
||||
|
||||
|
||||
def test_save_weights_to_file():
|
||||
@@ -287,3 +291,38 @@ def test_problem_access():
|
||||
ef = setup_efficient_frontier()
|
||||
ef.max_sharpe()
|
||||
assert isinstance(ef._opt, cp.Problem)
|
||||
|
||||
|
||||
def test_exception_immutability():
|
||||
ef = setup_efficient_frontier()
|
||||
ef.efficient_return(0.2)
|
||||
|
||||
with pytest.raises(Exception,
|
||||
match='Adding constraints to an already solved problem might have unintended consequences'):
|
||||
ef.min_volatility()
|
||||
|
||||
ef = setup_efficient_frontier()
|
||||
ef.efficient_return(0.2)
|
||||
with pytest.raises(Exception,
|
||||
match='Adding constraints to an already solved problem might have unintended consequences'):
|
||||
ef.add_constraint(lambda w: w >= 0.1)
|
||||
|
||||
ef = setup_efficient_frontier()
|
||||
ef.efficient_return(0.2)
|
||||
prev_w = np.array([1 / ef.n_assets] * ef.n_assets)
|
||||
with pytest.raises(Exception,
|
||||
match='Adding objectives to an already solved problem might have unintended consequences'):
|
||||
ef.add_objective(objective_functions.transaction_cost, w_prev=prev_w)
|
||||
|
||||
ef = setup_efficient_frontier()
|
||||
ef.efficient_return(0.2)
|
||||
ef._constraints += [ef._w >= 0.1]
|
||||
with pytest.raises(Exception,
|
||||
match='The constraints were changed after the initial optimization'):
|
||||
ef.efficient_return(0.2)
|
||||
|
||||
ef = setup_efficient_frontier()
|
||||
ef.efficient_return(0.2, market_neutral=True)
|
||||
with pytest.raises(Exception,
|
||||
match='A new instance must be created when changing market_neutral'):
|
||||
ef.efficient_return(0.2, market_neutral=False)
|
||||
|
||||
@@ -40,7 +40,6 @@ def test_custom_convex_abs_exposure():
|
||||
)
|
||||
|
||||
ef.add_constraint(lambda x: cp.norm(x, 1) <= 2)
|
||||
ef.min_volatility()
|
||||
ef.convex_objective(
|
||||
objective_functions.portfolio_variance,
|
||||
cov_matrix=ef.cov_matrix,
|
||||
|
||||
@@ -350,15 +350,14 @@ def test_efficient_risk_L2_reg():
|
||||
atol=1e-4,
|
||||
)
|
||||
|
||||
ef2 = setup_efficient_cdar()
|
||||
cd.add_objective(objective_functions.L2_reg, gamma=1)
|
||||
ef2.efficient_risk(0.18)
|
||||
cd2 = setup_efficient_cdar()
|
||||
cd2.efficient_risk(0.18)
|
||||
|
||||
# L2_reg should pull close to equal weight
|
||||
equal_weight = np.full((cd.n_assets,), 1 / cd.n_assets)
|
||||
assert (
|
||||
np.abs(equal_weight - cd.weights).sum()
|
||||
< np.abs(equal_weight - ef2.weights).sum()
|
||||
< np.abs(equal_weight - cd2.weights).sum()
|
||||
)
|
||||
|
||||
|
||||
@@ -455,3 +454,13 @@ def test_cdar_errors():
|
||||
historical_rets = historical_rets.iloc[:, :-1]
|
||||
with pytest.raises(ValueError):
|
||||
EfficientCDaR(mu, historical_rets)
|
||||
|
||||
|
||||
def test_parametrization():
|
||||
cd = setup_efficient_cdar()
|
||||
cd.efficient_risk(0.08)
|
||||
cd.efficient_risk(0.07)
|
||||
|
||||
cd = setup_efficient_cdar()
|
||||
cd.efficient_return(0.08)
|
||||
cd.efficient_return(0.07)
|
||||
|
||||
@@ -348,15 +348,14 @@ def test_efficient_risk_L2_reg():
|
||||
atol=1e-4,
|
||||
)
|
||||
|
||||
ef2 = setup_efficient_cvar()
|
||||
cv.add_objective(objective_functions.L2_reg, gamma=1)
|
||||
ef2.efficient_risk(0.03)
|
||||
cv2 = setup_efficient_cvar()
|
||||
cv2.efficient_risk(0.19)
|
||||
|
||||
# L2_reg should pull close to equal weight
|
||||
equal_weight = np.full((cv.n_assets,), 1 / cv.n_assets)
|
||||
assert (
|
||||
np.abs(equal_weight - cv.weights).sum()
|
||||
< np.abs(equal_weight - ef2.weights).sum()
|
||||
< np.abs(equal_weight - cv2.weights).sum()
|
||||
)
|
||||
|
||||
|
||||
@@ -456,3 +455,13 @@ def test_cvar_errors():
|
||||
historical_rets = historical_rets.iloc[:, :-1]
|
||||
with pytest.raises(ValueError):
|
||||
EfficientCVaR(mu, historical_rets)
|
||||
|
||||
|
||||
def test_parametrization():
|
||||
cv = setup_efficient_cvar()
|
||||
cv.efficient_risk(0.19)
|
||||
cv.efficient_risk(0.19)
|
||||
|
||||
cv = setup_efficient_cvar()
|
||||
cv.efficient_return(0.25)
|
||||
cv.efficient_return(0.25)
|
||||
|
||||
@@ -203,8 +203,9 @@ def test_min_volatility_L2_reg_many_values():
|
||||
ef.min_volatility()
|
||||
# Count the number of weights more 1%
|
||||
initial_number = sum(ef.weights > 0.01)
|
||||
for _ in range(10):
|
||||
ef.add_objective(objective_functions.L2_reg, gamma=0.05)
|
||||
for gamma_multiplier in range(1, 10):
|
||||
ef = setup_efficient_frontier()
|
||||
ef.add_objective(objective_functions.L2_reg, gamma=0.05*gamma_multiplier)
|
||||
ef.min_volatility()
|
||||
np.testing.assert_almost_equal(ef.weights.sum(), 1)
|
||||
new_number = sum(ef.weights > 0.01)
|
||||
@@ -388,7 +389,7 @@ def test_max_sharpe_error():
|
||||
|
||||
# An unsupported constraint type, which is incidentally meaningless.
|
||||
v = cp.Variable((2, 2), PSD=True)
|
||||
ef._constraints.append(v >> np.zeros((2, 2)))
|
||||
ef.add_constraint(lambda _: v >> np.zeros((2, 2)))
|
||||
with pytest.raises(TypeError):
|
||||
ef.max_sharpe()
|
||||
|
||||
@@ -824,8 +825,9 @@ def test_max_quadratic_utility():
|
||||
|
||||
ret1, var1, _ = ef.portfolio_performance()
|
||||
# increasing risk_aversion should lower both vol and return
|
||||
ef.max_quadratic_utility(10)
|
||||
ret2, var2, _ = ef.portfolio_performance()
|
||||
ef2 = setup_efficient_frontier()
|
||||
ef2.max_quadratic_utility(10)
|
||||
ret2, var2, _ = ef2.portfolio_performance()
|
||||
assert ret2 < ret1 and var2 < var1
|
||||
|
||||
|
||||
|
||||
@@ -465,7 +465,6 @@ def test_efficient_risk_L2_reg():
|
||||
)
|
||||
|
||||
ef2 = setup_efficient_semivariance()
|
||||
es.add_objective(objective_functions.L2_reg, gamma=1)
|
||||
ef2.efficient_risk(0.19)
|
||||
|
||||
# L2_reg should pull close to equal weight
|
||||
@@ -582,3 +581,17 @@ def test_efficient_semivariance_vs_heuristic_weekly():
|
||||
|
||||
assert semi_deviation < semi_deviation_ef
|
||||
assert mu_es / semi_deviation > mu_ef / semi_deviation_ef
|
||||
|
||||
|
||||
def test_parametrization():
|
||||
es = setup_efficient_semivariance()
|
||||
es.efficient_risk(0.19)
|
||||
es.efficient_risk(0.19)
|
||||
|
||||
es = setup_efficient_semivariance()
|
||||
es.efficient_return(0.25)
|
||||
es.efficient_return(0.25)
|
||||
|
||||
es = setup_efficient_semivariance()
|
||||
es.max_quadratic_utility(1)
|
||||
es.max_quadratic_utility(1)
|
||||
|
||||
Reference in New Issue
Block a user