mirror of
https://github.com/robertmartin8/PyPortfolioOpt.git
synced 2022-11-27 18:02:41 +03:00
306 lines
13 KiB
Python
306 lines
13 KiB
Python
""" Optimise a portfolio with the Efficient Frontier algorithm.
|
|
|
|
EfficientFrontier(expected_returns, cov_matrix, weight_bounds) initialises an object which
|
|
will generate an optimal portfolio for various possible objective functions and parameters.
|
|
"""
|
|
|
|
import warnings
|
|
import numpy as np
|
|
import pandas as pd
|
|
import scipy.optimize as sco
|
|
from . import objective_functions
|
|
|
|
|
|
class EfficientFrontier:
|
|
|
|
"""
|
|
An EfficientFrontier object contains multiple optimisation methods that can be called
|
|
(corresponding to different objective functions) with various parameters.
|
|
|
|
Instance variables:
|
|
|
|
- Inputs:
|
|
- cov_matrix
|
|
- n_assets
|
|
- tickers
|
|
- bounds
|
|
- Optimisation parameters:
|
|
- initial_guess
|
|
- constraints
|
|
- risk_free_rate
|
|
- Output: weights
|
|
|
|
Public methods:
|
|
|
|
- max_sharpe() optimises for maximal Sharpe ratio (a.k.a the tangency portfolio)
|
|
- min_volatility() optimises for minimum volatility
|
|
- 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
|
|
the optimised portfolio.
|
|
"""
|
|
|
|
def __init__(self, expected_returns, cov_matrix, weight_bounds=(0, 1)):
|
|
"""
|
|
:param expected_returns: expected returns for each asset
|
|
: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
|
|
:raises TypeError: if expected returns is not a series, list or array
|
|
:raises TypeError: if cov_matrix is not a dataframe or array
|
|
"""
|
|
# Inputs
|
|
if not isinstance(expected_returns, (pd.Series, list, np.ndarray)):
|
|
raise TypeError("Expected returns is not a series, list or array")
|
|
if not isinstance(cov_matrix, (pd.DataFrame, np.ndarray)):
|
|
raise TypeError("cov_matrix is not a dataframe or array")
|
|
|
|
self.expected_returns = expected_returns
|
|
self.cov_matrix = cov_matrix
|
|
self.n_assets = len(expected_returns)
|
|
self.tickers = list(expected_returns.index)
|
|
self.bounds = self._make_valid_bounds(weight_bounds)
|
|
|
|
# Optimisation parameters
|
|
self.initial_guess = np.array([1 / self.n_assets] * self.n_assets)
|
|
self.constraints = [{"type": "eq", "fun": lambda x: np.sum(x) - 1}]
|
|
self.risk_free_rate = 0.02
|
|
# Outputs
|
|
self.weights = None
|
|
|
|
def _make_valid_bounds(self, test_bounds):
|
|
"""
|
|
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.
|
|
: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):
|
|
raise ValueError(
|
|
"test_bounds must be a tuple of (lower bound, upper bound)"
|
|
)
|
|
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
|
|
|
|
def max_sharpe(self, gamma=0, risk_free_rate=0.02):
|
|
"""
|
|
Maximise the Sharpe Ratio.
|
|
|
|
The result is also referred to as the tangency portfolio, as it is the tangent to the
|
|
efficient frontier curve that intercepts the risk free rate.
|
|
|
|
:param gamma: L2 regularisation parameter, defaults to 0. Increase if you want more
|
|
non-negligible weights
|
|
:param gamma: float, optional
|
|
:param risk_free_rate: risk free rate of borrowing/lending, defaults to 0.02
|
|
:type risk_free_rate: float, optional
|
|
:raises ValueError: if gamma or risk_free_rate is non-numeric
|
|
:return: asset weights for the Sharpe-maximising portfolio
|
|
:rtype: dict
|
|
"""
|
|
if not isinstance(gamma, (int, float)):
|
|
raise ValueError("gamma should be numeric")
|
|
if gamma < 0:
|
|
warnings.warn("in most cases, gamma should be positive", UserWarning)
|
|
if not isinstance(risk_free_rate, (int, float)):
|
|
raise ValueError("risk_free_rate should be numeric")
|
|
|
|
self.risk_free_rate = risk_free_rate
|
|
args = (self.expected_returns, self.cov_matrix, gamma, risk_free_rate)
|
|
constraints = self.constraints
|
|
result = sco.minimize(
|
|
objective_functions.negative_sharpe,
|
|
x0=self.initial_guess,
|
|
args=args,
|
|
method="SLSQP",
|
|
bounds=self.bounds,
|
|
constraints=constraints,
|
|
)
|
|
self.weights = result["x"]
|
|
return dict(zip(self.tickers, self.weights))
|
|
|
|
def min_volatility(self, gamma=0):
|
|
"""
|
|
Minimise volatility.
|
|
|
|
:param gamma: L2 regularisation parameter, defaults to 0. Increase if you want more
|
|
non-negligible weights
|
|
:param gamma: float, optional
|
|
:raises ValueError: if gamma or risk_free_rate is non-numeric
|
|
:return: asset weights for the volatility-minimising portfolio
|
|
:rtype: dict
|
|
"""
|
|
if not isinstance(gamma, (int, float)):
|
|
raise ValueError("gamma should be numeric")
|
|
if gamma < 0:
|
|
warnings.warn("in most cases, gamma should be positive", UserWarning)
|
|
|
|
args = (self.cov_matrix, gamma)
|
|
constraints = self.constraints
|
|
result = sco.minimize(
|
|
objective_functions.volatility,
|
|
x0=self.initial_guess,
|
|
args=args,
|
|
method="SLSQP",
|
|
bounds=self.bounds,
|
|
constraints=constraints,
|
|
)
|
|
self.weights = result["x"]
|
|
return dict(zip(self.tickers, self.weights))
|
|
|
|
def efficient_risk(
|
|
self, target_risk, gamma=0, risk_free_rate=0.02, market_neutral=False
|
|
):
|
|
"""
|
|
Calculate the Sharpe-maximising portfolio for a given volatility (i.e max return
|
|
for a target risk).
|
|
|
|
:param target_risk: the desired volatility of the resulting portfolio.
|
|
:type target_risk: float
|
|
:param gamma: L2 regularisation parameter, defaults to 0. Increase if you want more
|
|
non-negligible weights
|
|
:param gamma: float, optional
|
|
:param risk_free_rate: risk free rate of borrowing/lending, defaults to 0.02
|
|
:type risk_free_rate: float, optional
|
|
:param market_neutral: whether the portfolio should be market neutral (weights sum to zero),
|
|
defaults to False. Requires negative lower weight bound.
|
|
:param market_neutral: bool, optional
|
|
:raises ValueError: if target_risk is not a positive float
|
|
:raises ValueError: if gamma or risk_free_rate is non-numeric
|
|
:return: asset weights for the efficient risk portfolio
|
|
:rtype: dict
|
|
"""
|
|
if not isinstance(target_risk, float) or target_risk < 0:
|
|
raise ValueError("target_risk should be a positive float")
|
|
if not isinstance(gamma, (int, float)):
|
|
raise ValueError("gamma should be numeric")
|
|
if gamma < 0:
|
|
warnings.warn("in most cases, gamma should be positive", UserWarning)
|
|
if not isinstance(risk_free_rate, (int, float)):
|
|
raise ValueError("risk_free_rate should be numeric")
|
|
|
|
self.n_assets = len(self.expected_returns)
|
|
args = (self.expected_returns, self.cov_matrix, gamma, risk_free_rate)
|
|
target_constraint = {
|
|
"type": "ineq",
|
|
"fun": lambda w: target_risk
|
|
- objective_functions.volatility(w, self.cov_matrix),
|
|
}
|
|
# The equality constraint is either "weights sum to 1" (default), or
|
|
# "weights sum to 0" (market neutral).
|
|
if market_neutral:
|
|
if self.bounds[0][0] is not None and self.bounds[0][0] >= 0:
|
|
warnings.warn(
|
|
"Market neutrality requires shorting - bounds have been amended",
|
|
RuntimeWarning,
|
|
)
|
|
self.bounds = self._make_valid_bounds((-1, 1))
|
|
constraints = [
|
|
{"type": "eq", "fun": lambda x: np.sum(x)},
|
|
target_constraint,
|
|
]
|
|
else:
|
|
constraints = self.constraints + [target_constraint]
|
|
|
|
result = sco.minimize(
|
|
objective_functions.negative_sharpe,
|
|
x0=self.initial_guess,
|
|
args=args,
|
|
method="SLSQP",
|
|
bounds=self.bounds,
|
|
constraints=constraints,
|
|
)
|
|
self.weights = result["x"]
|
|
return dict(zip(self.tickers, self.weights))
|
|
|
|
def efficient_return(self, target_return, gamma=0, market_neutral=False):
|
|
"""
|
|
Calculate the 'Markowitz portfolio', minimising volatility for a given target return.
|
|
|
|
:param target_return: the desired return of the resulting portfolio.
|
|
:type target_return: float
|
|
:param gamma: L2 regularisation parameter, defaults to 0. Increase if you want more
|
|
non-negligible weights
|
|
:param gamma: float, optional
|
|
:param market_neutral: whether the portfolio should be market neutral (weights sum to zero),
|
|
defaults to False. Requires negative lower weight bound.
|
|
:param market_neutral: bool, optional
|
|
:raises ValueError: if target_return is not a positive float
|
|
:raises ValueError: if gamma is non-numeric
|
|
:return: asset weights for the Markowitz portfolio
|
|
:rtype: dict
|
|
"""
|
|
if not isinstance(target_return, float) or target_return < 0:
|
|
raise ValueError("target_risk should be a positive float")
|
|
if not isinstance(gamma, (int, float)):
|
|
raise ValueError("gamma should be numeric")
|
|
if gamma < 0:
|
|
warnings.warn("in most cases, gamma should be positive", UserWarning)
|
|
|
|
self.n_assets = len(self.expected_returns)
|
|
args = (self.cov_matrix, gamma)
|
|
target_constraint = {
|
|
"type": "eq",
|
|
"fun": lambda w: w.dot(self.expected_returns) - target_return,
|
|
}
|
|
# The equality constraint is either "weights sum to 1" (default), or
|
|
# "weights sum to 0" (market neutral).
|
|
if market_neutral:
|
|
if self.bounds[0][0] is not None and self.bounds[0][0] >= 0:
|
|
warnings.warn(
|
|
"Market neutrality requires shorting - bounds have been amended",
|
|
RuntimeWarning,
|
|
)
|
|
self.bounds = self._make_valid_bounds((-1, 1))
|
|
constraints = [
|
|
{"type": "eq", "fun": lambda x: np.sum(x)},
|
|
target_constraint,
|
|
]
|
|
else:
|
|
constraints = self.constraints + [target_constraint]
|
|
|
|
result = sco.minimize(
|
|
objective_functions.volatility,
|
|
x0=self.initial_guess,
|
|
args=args,
|
|
method="SLSQP",
|
|
bounds=self.bounds,
|
|
constraints=constraints,
|
|
)
|
|
self.weights = result["x"]
|
|
return dict(zip(self.tickers, self.weights))
|
|
|
|
def portfolio_performance(self, verbose=False):
|
|
"""
|
|
After optimising, calculate (and optionally print) the performance of the optimal
|
|
portfolio. Currently calculates expected return, volatility, and the Sharpe ratio.
|
|
|
|
:param verbose: whether performance should be printed, defaults to False
|
|
:param verbose: bool, optional
|
|
:raises ValueError: if weights have not been calcualted yet
|
|
:return: expected return, volatility, Sharpe ratio.
|
|
:rtype: (float, float, float)
|
|
"""
|
|
if self.weights is None:
|
|
raise ValueError("Weights not calculated yet")
|
|
sigma = objective_functions.volatility(self.weights, self.cov_matrix)
|
|
mu = self.weights.dot(self.expected_returns)
|
|
|
|
sharpe = -objective_functions.negative_sharpe(
|
|
self.weights, self.expected_returns, self.cov_matrix, self.risk_free_rate
|
|
)
|
|
if verbose:
|
|
print("Expected annual return: {:.1f}%".format(100 * mu))
|
|
print("Annual volatility: {:.1f}%".format(100 * sigma))
|
|
print("Sharpe Ratio: {:.2f}".format(sharpe))
|
|
return mu, sigma, sharpe
|