"""
The ``expected_returns`` module provides functions for estimating the expected returns of
the assets, which is a required input in mean-variance optimization.
By convention, the output of these methods is expected *annual* returns. It is assumed that
*daily* prices are provided, though in reality the functions are agnostic
to the time period (just change the ``frequency`` parameter). Asset prices must be given as
a pandas dataframe, as per the format described in the :ref:`user-guide`.
All of the functions process the price data into percentage returns data, before
calculating their respective estimates of expected returns.
Currently implemented:
- general return model function, allowing you to run any return model from one function.
- mean historical return
- exponentially weighted mean historical return
- CAPM estimate of returns
Additionally, we provide utility functions to convert from returns to prices and vice-versa.
"""
import warnings
import numpy as np
import pandas as pd
def _check_returns(returns):
# Check NaNs excluding leading NaNs
if np.any(np.isnan(returns.mask(returns.ffill().isnull(), 0))):
warnings.warn(
"Some returns are NaN. Please check your price data.", UserWarning
)
if np.any(np.isinf(returns)):
warnings.warn(
"Some returns are infinite. Please check your price data.", UserWarning
)
[docs]def returns_from_prices(prices, log_returns=False):
"""
Calculate the returns given prices.
:param prices: adjusted (daily) closing prices of the asset, each row is a
date and each column is a ticker/id.
:type prices: pd.DataFrame
:param log_returns: whether to compute using log returns
:type log_returns: bool, defaults to False
:return: (daily) returns
:rtype: pd.DataFrame
"""
if log_returns:
returns = np.log(1 + prices.pct_change()).dropna(how="all")
else:
returns = prices.pct_change().dropna(how="all")
return returns
[docs]def prices_from_returns(returns, log_returns=False):
"""
Calculate the pseudo-prices given returns. These are not true prices because
the initial prices are all set to 1, but it behaves as intended when passed
to any PyPortfolioOpt method.
:param returns: (daily) percentage returns of the assets
:type returns: pd.DataFrame
:param log_returns: whether to compute using log returns
:type log_returns: bool, defaults to False
:return: (daily) pseudo-prices.
:rtype: pd.DataFrame
"""
if log_returns:
ret = np.exp(returns)
else:
ret = 1 + returns
ret.iloc[0] = 1 # set first day pseudo-price
return ret.cumprod()
def return_model(prices, method="mean_historical_return", **kwargs):
"""
Compute an estimate of future returns, using the return model specified in ``method``.
:param prices: adjusted closing prices of the asset, each row is a date
and each column is a ticker/id.
:type prices: pd.DataFrame
:param returns_data: if true, the first argument is returns instead of prices.
:type returns_data: bool, defaults to False.
:param method: the return model to use. Should be one of:
- ``mean_historical_return``
- ``ema_historical_return``
- ``capm_return``
:type method: str, optional
:raises NotImplementedError: if the supplied method is not recognised
:return: annualised sample covariance matrix
:rtype: pd.DataFrame
"""
if method == "mean_historical_return":
return mean_historical_return(prices, **kwargs)
elif method == "ema_historical_return":
return ema_historical_return(prices, **kwargs)
elif method == "capm_return":
return capm_return(prices, **kwargs)
else:
raise NotImplementedError("Return model {} not implemented".format(method))
[docs]def mean_historical_return(
prices, returns_data=False, compounding=True, frequency=252, log_returns=False
):
"""
Calculate annualised mean (daily) historical return from input (daily) asset prices.
Use ``compounding`` to toggle between the default geometric mean (CAGR) and the
arithmetic mean.
:param prices: adjusted closing prices of the asset, each row is a date
and each column is a ticker/id.
:type prices: pd.DataFrame
:param returns_data: if true, the first argument is returns instead of prices.
These **should not** be log returns.
:type returns_data: bool, defaults to False.
:param compounding: computes geometric mean returns if True,
arithmetic otherwise, optional.
:type compounding: bool, defaults to True
:param frequency: number of time periods in a year, defaults to 252 (the number
of trading days in a year)
:type frequency: int, optional
:param log_returns: whether to compute using log returns
:type log_returns: bool, defaults to False
:return: annualised mean (daily) return for each asset
:rtype: pd.Series
"""
if not isinstance(prices, pd.DataFrame):
warnings.warn("prices are not in a dataframe", RuntimeWarning)
prices = pd.DataFrame(prices)
if returns_data:
returns = prices
else:
returns = returns_from_prices(prices, log_returns)
_check_returns(returns)
if compounding:
return (1 + returns).prod() ** (frequency / returns.count()) - 1
else:
return returns.mean() * frequency
[docs]def ema_historical_return(
prices,
returns_data=False,
compounding=True,
span=500,
frequency=252,
log_returns=False,
):
"""
Calculate the exponentially-weighted mean of (daily) historical returns, giving
higher weight to more recent data.
:param prices: adjusted closing prices of the asset, each row is a date
and each column is a ticker/id.
:type prices: pd.DataFrame
:param returns_data: if true, the first argument is returns instead of prices.
These **should not** be log returns.
:type returns_data: bool, defaults to False.
:param compounding: computes geometric mean returns if True,
arithmetic otherwise, optional.
:type compounding: bool, defaults to True
:param frequency: number of time periods in a year, defaults to 252 (the number
of trading days in a year)
:type frequency: int, optional
:param span: the time-span for the EMA, defaults to 500-day EMA.
:type span: int, optional
:param log_returns: whether to compute using log returns
:type log_returns: bool, defaults to False
:return: annualised exponentially-weighted mean (daily) return of each asset
:rtype: pd.Series
"""
if not isinstance(prices, pd.DataFrame):
warnings.warn("prices are not in a dataframe", RuntimeWarning)
prices = pd.DataFrame(prices)
if returns_data:
returns = prices
else:
returns = returns_from_prices(prices, log_returns)
_check_returns(returns)
if compounding:
return (1 + returns.ewm(span=span).mean().iloc[-1]) ** frequency - 1
else:
return returns.ewm(span=span).mean().iloc[-1] * frequency
[docs]def capm_return(
prices,
market_prices=None,
returns_data=False,
risk_free_rate=0.02,
compounding=True,
frequency=252,
log_returns=False,
):
"""
Compute a return estimate using the Capital Asset Pricing Model. Under the CAPM,
asset returns are equal to market returns plus a :math:`\beta` term encoding
the relative risk of the asset.
.. math::
R_i = R_f + \\beta_i (E(R_m) - R_f)
:param prices: adjusted closing prices of the asset, each row is a date
and each column is a ticker/id.
:type prices: pd.DataFrame
:param market_prices: adjusted closing prices of the benchmark, defaults to None
:type market_prices: pd.DataFrame, optional
:param returns_data: if true, the first arguments are returns instead of prices.
:type returns_data: bool, defaults to False.
:param risk_free_rate: risk-free rate of borrowing/lending, defaults to 0.02.
You should use the appropriate time period, corresponding
to the frequency parameter.
:type risk_free_rate: float, optional
:param compounding: computes geometric mean returns if True,
arithmetic otherwise, optional.
:type compounding: bool, defaults to True
:param frequency: number of time periods in a year, defaults to 252 (the number
of trading days in a year)
:type frequency: int, optional
:param log_returns: whether to compute using log returns
:type log_returns: bool, defaults to False
:return: annualised return estimate
:rtype: pd.Series
"""
if not isinstance(prices, pd.DataFrame):
warnings.warn("prices are not in a dataframe", RuntimeWarning)
prices = pd.DataFrame(prices)
market_returns = None
if returns_data:
returns = prices.copy()
if market_prices is not None:
market_returns = market_prices
else:
returns = returns_from_prices(prices, log_returns)
if market_prices is not None:
if not isinstance(market_prices, pd.DataFrame):
warnings.warn("market prices are not in a dataframe", RuntimeWarning)
market_prices = pd.DataFrame(market_prices)
market_returns = returns_from_prices(market_prices, log_returns)
# Use the equally-weighted dataset as a proxy for the market
if market_returns is None:
# Append market return to right and compute sample covariance matrix
returns["mkt"] = returns.mean(axis=1)
else:
market_returns.columns = ["mkt"]
returns = returns.join(market_returns, how="left")
_check_returns(returns)
# Compute covariance matrix for the new dataframe (including markets)
cov = returns.cov()
# The far-right column of the cov matrix is covariances to market
betas = cov["mkt"] / cov.loc["mkt", "mkt"]
betas = betas.drop("mkt")
# Find mean market return on a given time period
if compounding:
mkt_mean_ret = (1 + returns["mkt"]).prod() ** (
frequency / returns["mkt"].count()
) - 1
else:
mkt_mean_ret = returns["mkt"].mean() * frequency
# CAPM formula
return risk_free_rate + betas * (mkt_mean_ret - risk_free_rate)