Files
PyPortfolioOpt/pypfopt/efficient_frontier.py
2018-06-09 11:31:58 +08:00

305 lines
12 KiB
Python

"""
The ``efficient_frontier`` module houses the EfficientFrontier object, 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
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``
- 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), gamma=0):
"""
: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
: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
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)
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
# 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}]
# 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, 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
: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)
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):
"""
Minimise volatility.
:raises ValueError: if risk_free_rate is non-numeric
:return: asset weights for the volatility-minimising portfolio
:rtype: dict
"""
args = (self.cov_matrix, self.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, 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
: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")
self.n_assets = len(self.expected_returns)
args = (self.expected_returns, self.cov_matrix, self.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, 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.
:param 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_risk should be a positive float")
self.n_assets = len(self.expected_returns)
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 clean_weights(self, cutoff=1e-4, rounding=5):
"""
Cleans the raw weights, setting any weights whose absolute values are below the cutoff
to zero, and rounding the rest.
:param cutoff: the lower bound, defaults to 1e-4
:type cutoff: float, optional
:param rounding: number of decimal places to round the weights, defaults to 5. Set to None if
rounding is not desired.
:type rounding: int, optional
:return: asset weights
:rtype: dict
"""
if not isinstance(rounding, int) or rounding < 1:
raise ValueError("rounding must be a positive integer")
clean_weights = self.weights.copy()
clean_weights[np.abs(clean_weights) < cutoff] = 0
if rounding is not None:
clean_weights = np.round(clean_weights, rounding)
return dict(zip(self.tickers, clean_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
:param verbose: bool, optional
:param risk_free_rate: risk free rate of borrowing/lending, defaults to 0.02
: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)
"""
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, 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