Source code for perfana.monte_carlo.returns

from typing import Iterable, Optional, Union

import numpy as np

from perfana.types import Scalar, Vector, is_iterable
from perfana.utils import lru_cache_ext
from ._types import Attribution, Frequency
from ._utility import infer_frequency

__all__ = [
    "annualized_returns_m",
    "annualized_bmk_returns_m",
    "annualized_quantile_returns_m",
    "annualized_bmk_quantile_returns_m",
    "returns_attr",
    "returns_distribution",
    "returns_path",
]


[docs]def annualized_returns_m(data: np.ndarray, weights: Vector, freq: Frequency, geometric: bool = True, rebalance: bool = True) -> float: r""" Calculates the annualized returns from the Monte Carlo simulation The formula for annualized geometric returns is formulated by raising the compound return to the number of periods in a year, and taking the root to the number of total observations. For the rebalance, geometric returns, the annualized returns is derived by: .. math:: &y = M / s \\ &\frac{1}{N}\sum^N_i \left[\prod_j^T \left(1 + \sum^A_k (r_{ijk} \cdot w_k \right) \right]^{\frac{1}{y}} - 1 where `s` is the number of observations in a year, and `M` is the total number of observations, `N` is the number of trials in the simulation, `T` is the number of trials in the simulation and `A` is the number of assets in the simulation. For simple returns (geometric=FALSE), the formula for the rebalanced case is: .. math:: \frac{s}{NM} \left[\sum^N_i \sum^T_j \sum^A_k (r_{ijk} \cdot w_k) \right] Parameters ---------- data Monte carlo simulation data. This must be 3 dimensional with the axis representing time, trial and asset respectively. weights Weights of the portfolio. This must be 1 dimensional and must match the dimension of the data's last axis. freq Frequency of the data. Can either be a string ('week', 'month', 'quarter', 'semi-annual', 'year') or an integer specifying the number of units per year. Week: 52, Month: 12, Quarter: 4, Semi-annual: 6, Year: 1. geometric If True, calculates the geometric mean, otherwise, calculates the arithmetic mean. rebalance If True, portfolio is assumed to be rebalanced at every step. Returns ------- float Annualized returns of the portfolio Examples -------- >>> from perfana.datasets import load_cube >>> from perfana.monte_carlo import annualized_returns_m >>> cube = load_cube()[..., :7] >>> weights = [0.25, 0.18, 0.13, 0.11, 0.24, 0.05, 0.04] >>> annualized_returns_m(cube, weights, 'month') 0.02111728739277985 """ d = returns_distribution(data, weights, freq, geometric=geometric, rebalance=rebalance) return d.mean()
[docs]def annualized_bmk_returns_m(data: np.ndarray, weights: Vector, bmk_weights: Vector, freq: Frequency, geometric: bool = True, rebalance: bool = True) -> float: """ Calculates the returns of the portfolio relative to a benchmark portfolio. The benchmark components must be placed after the portfolio components in the simulated returns cube. Parameters ---------- data Monte carlo simulation data. This must be 3 dimensional with the axis representing time, trial and asset respectively. weights Weights of the portfolio. This must be 1 dimensional and must match the dimension of the data's last axis. bmk_weights Weights of the benchmark portfolio. freq Frequency of the data. Can either be a string ('week', 'month', 'quarter', 'semi-annual', 'year') or an integer specifying the number of units per year. Week: 52, Month: 12, Quarter: 4, Semi-annual: 6, Year: 1. geometric If True, calculates the geometric mean, otherwise, calculates the arithmetic mean. rebalance If True, portfolio is assumed to be rebalanced at every step. Returns ------- float The portfolio returns relative to the benchmark Examples -------- >>> from perfana.datasets import load_cube >>> from perfana.monte_carlo import annualized_bmk_returns_m >>> cube = load_cube() >>> weights = [0.25, 0.18, 0.13, 0.11, 0.24, 0.05, 0.04] >>> bmk_weights = [0.65, 0.35] >>> freq = "quarterly" >>> annualized_bmk_returns_m(cube, weights, bmk_weights, freq) -0.006819613944426206 """ freq = infer_frequency(freq) w1, w2 = np.ravel(weights), np.ravel(bmk_weights) y = len(data) / freq # number of years n = len(w1) port, bmk = data[..., :n], data[..., n:] if rebalance: if geometric: d = ((port @ w1) + 1).prod(0) / ((bmk @ w2) + 1).prod(0) return (np.sign(d) * np.abs(d) ** (1 / y)).mean() - 1 else: return ((port @ w1) / (bmk @ w2)).mean() * freq else: d = ((port + 1).prod(0) @ w1) / ((bmk + 1).prod(0) @ w2) if geometric: return (np.sign(d) * np.abs(d) ** (1 / y)).mean() - 1 else: return (d - 1).mean() * freq
[docs]def annualized_quantile_returns_m(data: np.ndarray, weights: Vector, quantile: Union[float, Iterable[float]], freq: Frequency, geometric: bool = True, rebalance: bool = True, interpolation="midpoint") -> Union[float, np.ndarray]: """ Compute the q-th quantile of the returns in the simulated data cube. Parameters ---------- data Monte carlo simulation data. This must be 3 dimensional with the axis representing time, trial and asset respectively. weights Weights of the portfolio. This must be 1 dimensional and must match the dimension of the data's last axis. quantile Quantile or sequence of quantiles to compute, which must be between 0 and 1 inclusive freq Frequency of the data. Can either be a string ('week', 'month', 'quarter', 'semi-annual', 'year') or an integer specifying the number of units per year. Week: 52, Month: 12, Quarter: 4, Semi-annual: 6, Year: 1. geometric If True, calculates the geometric mean, otherwise, calculates the arithmetic mean. rebalance If True, portfolio is assumed to be rebalanced at every step. interpolation This optional parameter specifies the interpolation method to use when the desired quantile lies between two data points ``i < j``: * linear: ``i + (j - i) * fraction``, where ``fraction`` is the fractional part of the index surrounded by ``i`` and ``j``. * lower: ``i``. * higher: ``j``. * nearest: ``i`` or ``j``, whichever is nearest. * midpoint: ``(i + j) / 2``. Returns ------- float The returns of the portfolio relative to the benchmark at the specified quantile Examples -------- >>> from perfana.datasets import load_cube >>> from perfana.monte_carlo import annualized_quantile_returns_m >>> cube = load_cube()[..., :7] >>> weights = [0.25, 0.18, 0.13, 0.11, 0.24, 0.05, 0.04] >>> freq = "quarterly" >>> q = 0.25 >>> annualized_quantile_returns_m(cube, weights, q, freq) 0.005468353416130167 >>> q = [0.25, 0.75] >>> annualized_quantile_returns_m(cube, weights, q, freq) array([0.00546835, 0.03845033]) """ w = np.ravel(weights) # force weights to be a 1D vector d = returns_distribution(data, w, freq, True, geometric, rebalance) return np.quantile(d, quantile, interpolation=interpolation)
[docs]def annualized_bmk_quantile_returns_m(data: np.ndarray, weights: Vector, bmk_weights: Vector, quantile: Union[float, Iterable[float]], freq: Frequency, geometric: bool = True, rebalance: bool = True, interpolation="midpoint") -> Union[float, np.ndarray]: """ Compares the annualized returns against a benchmark at the specified quantiles. The benchmark components must be placed after the portfolio components in the simulated returns cube. Parameters ---------- data Monte carlo simulation data. This must be 3 dimensional with the axis representing time, trial and asset respectively. weights Weights of the portfolio. This must be 1 dimensional and must match the dimension of the data's last axis. bmk_weights Weights of the benchmark portfolio. quantile Quantile or sequence of quantiles to compute, which must be between 0 and 1 inclusive freq Frequency of the data. Can either be a string ('week', 'month', 'quarter', 'semi-annual', 'year') or an integer specifying the number of units per year. Week: 52, Month: 12, Quarter: 4, Semi-annual: 6, Year: 1. geometric If True, calculates the geometric mean, otherwise, calculates the arithmetic mean. rebalance If True, portfolio is assumed to be rebalanced at every step. interpolation This optional parameter specifies the interpolation method to use when the desired quantile lies between two data points ``i < j``: * linear: ``i + (j - i) * fraction``, where ``fraction`` is the fractional part of the index surrounded by ``i`` and ``j``. * lower: ``i``. * higher: ``j``. * nearest: ``i`` or ``j``, whichever is nearest. * midpoint: ``(i + j) / 2``. Returns ------- float or array_like of floats The returns of the portfolio over the benchmark at the specified quantiles Examples -------- >>> from perfana.datasets import load_cube >>> from perfana.monte_carlo import annualized_bmk_quantile_returns_m >>> cube = load_cube() >>> weights = [0.25, 0.18, 0.13, 0.11, 0.24, 0.05, 0.04] >>> bmk_weights = [0.65, 0.35] >>> freq = "quarterly" >>> q = 0.25 >>> annualized_bmk_quantile_returns_m(cube, weights, bmk_weights, q, freq) -0.010792419409674459 >>> q = [0.25, 0.75] >>> annualized_bmk_quantile_returns_m(cube, weights, bmk_weights, q, freq) array([-0.01079242, -0.0025487 ]) """ freq = infer_frequency(freq) w1, w2 = np.ravel(weights), np.ravel(bmk_weights) y = len(data) / freq # number of years n = len(w1) port, bmk = data[..., :n], data[..., n:] if rebalance: if geometric: d = ((port @ w1) + 1).prod(0) / ((bmk @ w2) + 1).prod(0) return np.quantile((np.sign(d) * np.abs(d) ** (1 / y)), quantile, interpolation=interpolation) - 1 else: return np.quantile(((port @ w1) / (bmk @ w2)), quantile, interpolation=interpolation) * freq else: d = ((port + 1).prod(0) @ w1) / ((bmk + 1).prod(0) @ w2) if geometric: return np.quantile((np.sign(d) * np.abs(d) ** (1 / y)), quantile, interpolation=interpolation) - 1 else: return np.quantile((d - 1), quantile, interpolation=interpolation) * freq
[docs]def returns_attr(data: np.ndarray, weights: Vector, freq: Frequency, geometric: bool = True, rebalance: bool = True) -> Attribution: """ Derives the returns attribution given a data cube and weights. Notes ----- The return values are defined as follows: - **marginal** The absolute marginal contribution of the asset class towards the portfolio returns. It is essentially the percentage attribution multiplied by the portfolio returns. - **percentage** The percentage contribution of the asset class towards the portfolio returns. This number though named in percentage is actually in decimals. Thus 0.01 represents a 1% contribution. Parameters ---------- data Monte carlo simulation data. This must be 3 dimensional with the axis representing time, trial and asset respectively. weights Weights of the portfolio. This must be 1 dimensional and must match the dimension of the data's last axis. freq Frequency of the data. Can either be a string ('week', 'month', 'quarter', 'semi-annual', 'year') or an integer specifying the number of units per year. Week: 52, Month: 12, Quarter: 4, Semi-annual: 6, Year: 1. geometric If True, calculates the geometric mean, otherwise, calculates the arithmetic mean. rebalance If True, portfolio is assumed to be rebalanced at every step. Returns ------- Attribution A named tuple of marginal and percentage returns attribution respectively. The marginal attribution is the returns of the simulated data over time multiplied by the percentage attribution. Examples -------- >>> from perfana.datasets import load_cube >>> from perfana.monte_carlo import returns_attr >>> cube = load_cube()[..., :3] >>> weights = [0.33, 0.34, 0.33] >>> freq = "quarterly" >>> attr = returns_attr(cube, weights, freq) >>> attr.marginal array([0.00996204, 0.00733369, 0.00963802]) >>> attr.percentage array([0.36987203, 0.27228623, 0.35784174]) >>> attr.marginal is attr[0] True >>> attr.percentage is attr[1] True """ data = np.asarray(data) t, n, a = data.shape assert a == len(weights), "length of weights must match number of assets in data cube" freq = infer_frequency(freq) # calculate annualized returns before reshape gr = annualized_returns_m(data, weights, freq, geometric, rebalance) data = (data + 1).reshape(t // freq, freq, n, a).prod(1) - 1 ar = data.mean((0, 1)) # arithmetic returns attr_p = (ar * weights) / (ar @ weights) attr_m = attr_p * gr return Attribution(attr_m, attr_p)
[docs]@lru_cache_ext def returns_distribution(data: np.ndarray, weights: Vector, freq: Optional[Frequency] = None, annualize=True, geometric: bool = True, rebalance: bool = True) -> np.ndarray: r""" Calculates the returns distribution of the simulation cube Parameters ---------- data Monte carlo simulation data. This must be 3 dimensional with the axis representing time, trial and asset respectively. weights Weights of the portfolio. This must be 1 dimensional and must match the dimension of the data's last axis. freq Frequency of the data. Can either be a string ('week', 'month', 'quarter', 'semi-annual', 'year') or an integer specifying the number of units per year. Week: 52, Month: 12, Quarter: 4, Semi-annual: 6, Year: 1. If :code:`annualize` is False, :code:`freq` can be ignored. annualize If true, the returns distribution values are annualized geometric If True, calculates the geometric mean, otherwise, calculates the arithmetic mean. rebalance If True, portfolio is assumed to be rebalanced at every step. Returns ------- Array A vector of the distribution of returns Examples -------- >>> from perfana.datasets import load_cube >>> from perfana.monte_carlo import returns_distribution >>> cube = load_cube()[..., :3] >>> weights = [0.33, 0.34, 0.33] >>> freq = "quarterly" >>> returns_distribution(cube, weights, freq).shape (1000,) """ w = np.ravel(weights) # force weights to be a 1D vector if annualize: freq = infer_frequency(freq) y = len(data) / freq # number of years else: y = None if rebalance: if geometric: d = (data @ w + 1).prod(0) if annualize: d = np.sign(d) * np.abs(d) ** (1 / y) - 1 else: d -= 1 else: d = (data @ w).mean(0) if annualize: d *= freq else: d = (data + 1).prod(0) @ w if geometric: if annualize: d = (np.sign(d) * np.abs(d) ** (1 / y)) - 1 else: d -= 1 else: d = (d - 1).mean(0) if annualize: d = d * freq return d
[docs]def returns_path(data: np.ndarray, weights: Vector, rebalance: bool = True, quantile: Union[Scalar, Vector] = None) -> np.ndarray: r""" Returns a matrix of the returns path of the portfolio. The first axis represents the time and the second axis represents the trials. If the :code:`quantile` argument is specified, the specific quantile for each time period will be returned. Thus, if the 0.75 quantile is specified, it is the 75th quantile for each time period and not the path the 75th quantile in the terminal period took. Parameters ---------- data Monte carlo simulation data. This must be 3 dimensional with the axis representing time, trial and asset respectively. weights Weights of the portfolio. This must be 1 dimensional and must match the dimension of the data's last axis. rebalance If True, portfolio is assumed to be rebalanced at every step. quantile Quantile or sequence of quantiles to compute, which must be between 0 and 1 inclusive Returns ------- ndarray The returns path for the portfolio Examples -------- >>> from perfana.datasets import load_cube >>> from perfana.monte_carlo import returns_path >>> cube = load_cube()[..., :3] >>> weights = [0.33, 0.34, 0.33] >>> returns_path(cube, weights).shape (1000, 81) >>> returns_path(cube, weights, quantile=0.75).shape # 75th quantile (81, ) >>> returns_path(cube, weights, quantile=[0.25, 0.5, 0.75]).shape # 25th, 50th and 75th quantile (3, 81) .. plot:: plots/returns_path.py :include-source: """ w = np.ravel(weights) # force weights to be a 1D vector # add zeros to the start _, n, a = data.shape data = np.append(np.zeros([1, n, a]), data, 0) if rebalance: d = (data @ w + 1).cumprod(0) else: d = (data + 1).cumprod(0) @ w if quantile is None: return d.T if not is_iterable(quantile): quantile = [quantile] quantile = np.asarray(quantile) assert np.alltrue([0 <= quantile, quantile <= 1]), "quantile values must be between 0 and 1" return np.squeeze(np.quantile(d, quantile, 1))