Files
PyPortfolioOpt/pypfopt/efficient_frontier.py
2019-12-10 21:58:25 +00:00

285 lines
12 KiB
Python

"""
The ``efficient_frontier`` module houses the EfficientFrontier class, which
generates optimal portfolios 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, base_optimizer
class EfficientFrontier(base_optimizer.BaseScipyOptimizer):
"""
An EfficientFrontier object (inheriting from BaseScipyOptimizer) contains multiple
optimisation methods that can be called (corresponding to different objective
functions) with various parameters.
Instance variables:
- Inputs:
- ``n_assets`` - int
- ``tickers`` - str list
- ``bounds`` - float tuple OR (float tuple) list
- ``cov_matrix`` - pd.DataFrame
- ``expected_returns`` - pd.Series
- Optimisation parameters:
- ``initial_guess``
- ``constraints``
- Output: ``weights``
Public methods:
- ``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
- ``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), gamma=0):
"""
:param expected_returns: expected returns for each asset. Set to None if
optimising for volatility only.
: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 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
:raises TypeError: if ``expected_returns`` is not a series, list or array
:raises TypeError: if ``cov_matrix`` is not a dataframe or array
"""
# Inputs
self.cov_matrix = cov_matrix
if expected_returns is not None:
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
if isinstance(expected_returns, pd.Series):
tickers = list(expected_returns.index)
elif isinstance(cov_matrix, pd.DataFrame):
tickers = list(cov_matrix.columns)
else:
tickers = list(range(len(expected_returns)))
super().__init__(len(tickers), tickers, weight_bounds)
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.gamma = gamma
def max_sharpe(self, 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 risk_free_rate: risk-free rate of borrowing/lending, defaults to 0.02.
The period of the risk-free rate should correspond to the
frequency of expected returns.
:type risk_free_rate: float, optional
:raises ValueError: if ``risk_free_rate`` is non-numeric
:return: asset weights for the Sharpe-maximising portfolio
:rtype: dict
"""
if not isinstance(risk_free_rate, (int, float)):
raise ValueError("risk_free_rate should be numeric")
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="SLSQP",
bounds=self.bounds,
constraints=self.constraints,
)
self.weights = result["x"]
return dict(zip(self.tickers, self.weights))
def min_volatility(self):
"""
Minimise volatility.
:return: asset weights for the volatility-minimising portfolio
:rtype: dict
"""
args = (self.cov_matrix, self.gamma)
result = sco.minimize(
objective_functions.volatility,
x0=self.initial_guess,
args=args,
method="SLSQP",
bounds=self.bounds,
constraints=self.constraints,
)
self.weights = result["x"]
return dict(zip(self.tickers, self.weights))
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="SLSQP",
bounds=self.bounds,
constraints=self.constraints,
)
self.weights = result["x"]
return dict(zip(self.tickers, self.weights))
def efficient_risk(self, target_risk, 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 risk_free_rate: risk-free rate of borrowing/lending, defaults to 0.02.
The period of the risk-free rate should correspond to the
frequency of expected returns.
: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 ``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(risk_free_rate, (int, float)):
raise ValueError("risk_free_rate should be numeric")
args = (self.expected_returns, self.cov_matrix, self.gamma, risk_free_rate)
target_constraint = {
"type": "ineq",
"fun": lambda w: target_risk
- np.sqrt(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, 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 market_neutral: whether the portfolio should be market neutral (weights sum to zero),
defaults to False. Requires negative lower weight bound.
:type market_neutral: bool, optional
:raises ValueError: if ``target_return`` is not a positive float
:return: asset weights for the Markowitz portfolio
:rtype: dict
"""
if not isinstance(target_return, float) or target_return < 0:
raise ValueError("target_return should be a positive float")
args = (self.cov_matrix, self.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, risk_free_rate=0.02):
"""
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
:type verbose: bool, optional
:param risk_free_rate: risk-free rate of borrowing/lending, defaults to 0.02.
The period of the risk-free rate should correspond to the
frequency of expected returns.
:type risk_free_rate: float, optional
:raises ValueError: if weights have not been calcualted yet
:return: expected return, volatility, Sharpe ratio.
:rtype: (float, float, float)
"""
return base_optimizer.portfolio_performance(
self.expected_returns,
self.cov_matrix,
self.weights,
verbose,
risk_free_rate,
)