Source code for perfana.monte_carlo.sensitivity

from typing import List

import numpy as np
import pandas as pd

from perfana.monte_carlo._utility import infer_frequency
from perfana.types import Vector
from ._types import Frequency
from .returns import annualized_returns_m
from .risk import cvar_m, volatility_m

__all__ = ["sensitivity_m", "sensitivity_cvar_m", "sensitivity_returns_m", "sensitivity_vol_m"]


[docs]def sensitivity_m(data: np.ndarray, weights: Vector, freq: Frequency, shock: float = 0.05, geometric: bool = True, rebalance: bool = True, cov: np.ndarray = None, cvar_cutoff: int = 3, cvar_data: np.ndarray = None, alpha=0.95, invert=True, names: List[str] = None, leveraged=False, distribute=True) -> pd.DataFrame: """ Calculates the sensitivity of adding and removing from the asset class on the portfolio. This is a wrapper function for the 3 sensitivity calculations. For more granular usages, use the base functions instead. Notes ----- When given a positive shock and a "proportionate" distribution strategy, each asset class is given an additional amount by removing from the other asset classes proportionately. For example, given a portfolio with weights :code:`[0.1, 0.2, 0.3, 0.4]`, a shock of 5% to the first asset in the portfolio will result in weights :code:`[0.15, 0.19, 0.28, 0.38]`. A negative shock works by removing from the asset class and adding to the other asset classes proportionately. If the distribution strategy is set to :code:`False`, the asset class' weight is increased without removing from the other asset classes. Thus the sum of the portfolio weights will not equal 1. By default, the portfolio is **not** leveraged. This means that the asset class be shorted (negative shock) to go below 0 and levered (positive shock) to go above 1. The asset class weight is thus capped between 0 and 1 by default. If the :code:`leverage` option is set to :code:`True`, then this value is no longer capped. Parameters ---------- data: ndarray Monte carlo simulation data. This must be 3 dimensional with the axis representing time, trial and asset respectively. weights: array_like Weights of the portfolio. This must be 1 dimensional and must match the dimension of the data's last axis. freq: Frequency 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. shock: float The amount to shock each asset class by. A positive number represents adding to the asset class by proportionately removing from the other asset class. A negative number represents removing from the asset class and adding to the other asset class proportionately. geometric: bool If True, calculates the geometric mean, otherwise, calculates the arithmetic mean. cov: ndarray Asset covariance matrix cvar_cutoff: int Number of years to trim the data cube by for cvar calculation. cvar_data: np.ndarray If specified, will use this data cube instead of the main data cube for cvar calculations. alpha: float Confidence level for calculation. invert: bool Whether to invert the confidence interval level rebalance: bool If True, portfolio is assumed to be rebalanced at every step. names: list of str Asset class names leveraged: bool If True, asset weights are allowed to go below 0 and above 1. This represents that the asset class can be shorted or levered. distribute: bool If True, asset value changes are distributed proportionately to all other asset classes. See Notes for more information. Returns ------- DataFrame A dataframe with the asset names as the indices and with columns (ret, vol, cvar) representing returns, volatility and CVaR respectively. Examples -------- >>> from perfana.datasets import load_cube >>> from perfana.monte_carlo import sensitivity_m >>> data = load_cube()[..., :7] >>> weights = [0.25, 0.18, 0.13, 0.11, 0.24, 0.05, 0.04] >>> freq = 'quarterly' >>> shock = 0.05 # 5% absolute shock >>> sensitivity_m(data, weights, freq, shock) ret vol cvar Asset_1 0.022403 0.113284 -0.485220 Asset_2 0.020484 0.121786 -0.542988 Asset_3 0.022046 0.113964 -0.492411 Asset_4 0.020854 0.109301 -0.478581 Asset_5 0.020190 0.104626 -0.459786 Asset_6 0.020335 0.106652 -0.467798 Asset_7 0.020220 0.106140 -0.468692 """ assert isinstance(cvar_cutoff, int) and cvar_cutoff > 0, "cvar_cutoff must be a positive integer" cov_or_data = data if cov is None else cov if cvar_data is None: cvar_data = data cvar_data = cvar_data[:cvar_cutoff * infer_frequency(freq)] ret = sensitivity_returns_m(data, weights, freq, shock, geometric, rebalance, names, leveraged, distribute) vol = sensitivity_vol_m(cov_or_data, weights, freq, shock, names, leveraged, distribute) cvar = sensitivity_cvar_m(cvar_data, weights, shock, alpha, rebalance, invert, names, leveraged, distribute) return pd.merge(ret, vol, left_index=True, right_index=True).merge(cvar, left_index=True, right_index=True)
[docs]def sensitivity_cvar_m(data: np.ndarray, weights: Vector, shock: float = 0.05, alpha=0.95, rebalance: bool = True, invert=True, names: List[str] = None, leveraged=False, distribute=True) -> pd.Series: """ Calculates the sensitivity of a shock to the CVaR of the portfolio Notes ----- When given a positive shock and a "proportionate" distribution strategy, each asset class is given an additional amount by removing from the other asset classes proportionately. For example, given a portfolio with weights :code:`[0.1, 0.2, 0.3, 0.4]`, a shock of 5% to the first asset in the portfolio will result in weights :code:`[0.15, 0.19, 0.28, 0.38]`. A negative shock works by removing from the asset class and adding to the other asset classes proportionately. If the distribution strategy is set to :code:`False`, the asset class' weight is increased without removing from the other asset classes. Thus the sum of the portfolio weights will not equal 1. By default, the portfolio is **not** leveraged. This means that the asset class be shorted (negative shock) to go below 0 and levered (positive shock) to go above 1. The asset class weight is thus capped between 0 and 1 by default. If the :code:`leverage` option is set to :code:`True`, then this value is no longer capped. Parameters ---------- data: ndarray Monte carlo simulation data. This must be 3 dimensional with the axis representing time, trial and asset respectively. weights: array_like Weights of the portfolio. This must be 1 dimensional and must match the dimension of the data's last axis. shock: float The amount to shock each asset class by. A positive number represents adding to the asset class by proportionately removing from the other asset class. A negative number represents removing from the asset class and adding to the other asset class proportionately. alpha: float Confidence level for calculation. invert: bool Whether to invert the confidence interval level rebalance: bool If True, portfolio is assumed to be rebalanced at every step. names: list of str Asset class names leveraged: bool If True, asset weights are allowed to go below 0 and above 1. This represents that the asset class can be shorted or levered. distribute: bool If True, asset value changes are distributed proportionately to all other asset classes. See Notes for more information. Returns ------- Series A series with asset names as the index and CVaR as its value Examples -------- >>> from perfana.datasets import load_cube >>> from perfana.monte_carlo import sensitivity_cvar_m >>> data = load_cube()[..., :7] >>> weights = [0.25, 0.18, 0.13, 0.11, 0.24, 0.05, 0.04] >>> freq = 'quarterly' >>> shock = 0.05 # 5% absolute shock >>> sensitivity_cvar_m(data, weights, shock) Asset_1 -0.485220 Asset_2 -0.542988 Asset_3 -0.492411 Asset_4 -0.478581 Asset_5 -0.459786 Asset_6 -0.467798 Asset_7 -0.468692 Name: cvar, dtype: float64 """ weight_matrix = _setup(weights, shock, leveraged, distribute) names = _setup_names(weights, names) cvar = [cvar_m(data, w, alpha, rebalance, invert) for w in weight_matrix] return pd.Series(cvar, names, name="cvar")
[docs]def sensitivity_returns_m(data: np.ndarray, weights: Vector, freq: Frequency, shock: float = 0.05, geometric: bool = True, rebalance: bool = True, names: List[str] = None, leveraged=False, distribute=True) -> pd.Series: """ Calculates the sensitivity of a shock to the annualized returns of the portfolio Notes ----- When given a positive shock and a "proportionate" distribution strategy, each asset class is given an additional amount by removing from the other asset classes proportionately. For example, given a portfolio with weights :code:`[0.1, 0.2, 0.3, 0.4]`, a shock of 5% to the first asset in the portfolio will result in weights :code:`[0.15, 0.19, 0.28, 0.38]`. A negative shock works by removing from the asset class and adding to the other asset classes proportionately. If the distribution strategy is set to :code:`False`, the asset class' weight is increased without removing from the other asset classes. Thus the sum of the portfolio weights will not equal 1. By default, the portfolio is **not** leveraged. This means that the asset class be shorted (negative shock) to go below 0 and levered (positive shock) to go above 1. The asset class weight is thus capped between 0 and 1 by default. If the :code:`leverage` option is set to :code:`True`, then this value is no longer capped. Parameters ---------- data: ndarray Monte carlo simulation data. This must be 3 dimensional with the axis representing time, trial and asset respectively. weights: array_like Weights of the portfolio. This must be 1 dimensional and must match the dimension of the data's last axis. shock: float The amount to shock each asset class by. A positive number represents adding to the asset class by proportionately removing from the other asset class. A negative number represents removing from the asset class and adding to the other asset class proportionately. freq: Frequency 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: bool If True, calculates the geometric mean, otherwise, calculates the arithmetic mean. rebalance: bool If True, portfolio is assumed to be rebalanced at every step. names: list of str Asset class names leveraged: bool If True, asset weights are allowed to go below 0 and above 1. This represents that the asset class can be shorted or levered. distribute: bool If True, asset value changes are distributed proportionately to all other asset classes. See Notes for more information. Returns ------- Series A series with asset names as the index and annualized returns as its value Examples -------- >>> from perfana.datasets import load_cube >>> from perfana.monte_carlo import sensitivity_returns_m >>> data = load_cube()[..., :7] >>> weights = [0.25, 0.18, 0.13, 0.11, 0.24, 0.05, 0.04] >>> freq = 'quarterly' >>> shock = 0.05 # 5% absolute shock >>> sensitivity_returns_m(data, weights, freq, shock) Asset_1 0.022403 Asset_2 0.020484 Asset_3 0.022046 Asset_4 0.020854 Asset_5 0.020190 Asset_6 0.020335 Asset_7 0.020220 Name: ret, dtype: float64 """ weight_matrix = _setup(weights, shock, leveraged, distribute) names = _setup_names(weights, names) ret = [annualized_returns_m(data, w, freq, geometric, rebalance) for w in weight_matrix] return pd.Series(ret, names, name="ret")
[docs]def sensitivity_vol_m(cov_or_data: np.ndarray, weights: Vector, freq: Frequency = None, shock: float = 0.05, names: List[str] = None, leveraged=False, distribute=True) -> pd.Series: """ Calculates the sensitivity of a shock to the annualized volatility of the portfolio Parameters ---------- cov_or_data Monte carlo simulation data or covariance matrix. If simulation cube, this must be 3 dimensional with the axis representing time, trial and asset respectively and frequency will also need to be specified. weights: array_like Weights of the portfolio. This must be 1 dimensional and must match the dimension of the data's last axis. freq: Frequency 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. shock: float The amount to shock each asset class by. A positive number represents adding to the asset class by proportionately removing from the other asset class. A negative number represents removing from the asset class and adding to the other asset class proportionately. names: list of str Asset class names leveraged: bool If True, asset weights are allowed to go below 0 and above 1. This represents that the asset class can be shorted or levered. distribute: bool If True, asset value changes are distributed proportionately to all other asset classes. See Notes for more information. Returns ------- Series A series with asset names as the index and annualized volatility as its value Examples -------- >>> from perfana.datasets import load_cube >>> from perfana.monte_carlo import sensitivity_vol_m >>> data = load_cube()[..., :7] >>> weights = [0.25, 0.18, 0.13, 0.11, 0.24, 0.05, 0.04] >>> freq = 'quarterly' >>> shock = 0.05 # 5% absolute shock >>> sensitivity_vol_m(data, weights, freq, shock) Asset_1 0.113284 Asset_2 0.121786 Asset_3 0.113964 Asset_4 0.109301 Asset_5 0.104626 Asset_6 0.106652 Asset_7 0.106140 Name: vol, dtype: float64 """ weight_matrix = _setup(weights, shock, leveraged, distribute) names = _setup_names(weights, names) vol = [volatility_m(cov_or_data, w, freq) for w in weight_matrix] return pd.Series(vol, names, name="vol")
def _setup(weights: Vector, shock: float = 0.05, leveraged=False, distribute=True): """Common setup for sensitivity analytics""" assert -1 <= shock <= 1, "shock must be between [-1, 1]" weights = np.ravel(weights) # if leverage is True: # Prevents shocks beyond the asset's current allocation. That is if the shock is # -5% and the asset only has 2% allocation, then the shock is effectively -2%. # And prevent shocks that bring the assets beyond 100% allocation. shocks = np.array([ shock if (0 <= w + shock <= 1) or leveraged else -w if shock < 0 else 1 - w for w in weights ]) n = len(weights) matrix = np.tile(weights, (n, 1)) + np.diag(shocks) if distribute: weight_matrix = np.tile(weights, (n, 1)) np.fill_diagonal(weight_matrix, 0) matrix -= weight_matrix * (shocks / weight_matrix.sum(1))[:, None] return matrix def _setup_names(weights: np.ndarray, names: List[str] = None): if names is None: return [f"Asset_{i + 1}" for i in range(len(weights))] names = list(names) assert len(names) == len(weights), "number of names given is not equal to length of weight vector" return names