Source code for fynance.metrics.ratios

#!/usr/bin/env python3
# coding: utf-8

""" Risk-adjusted ratios (Sharpe, Sortino, Calmar, diversification) and volatility. """

from __future__ import annotations

# Built-in packages
# Third-party packages
import numpy as np
from numpy.typing import NDArray

# Local packages
from fynance._exceptions import ArraySizeError
from fynance._wrappers import WrapperArray
from fynance.features._metrics_helpers import *  # noqa: F401,F403
from fynance.features.metrics_cy import *

__all__ = ['annual_volatility', 'sharpe', 'sortino', 'calmar', 'diversified_ratio', 'roll_annual_volatility', 'roll_sharpe', 'roll_calmar']


[docs] @WrapperArray('dtype', 'axis', 'null', 'ddof', min_size=2) def annual_volatility(X: NDArray, period: int = 252, log: bool = True, axis: int = 0, dtype=None, ddof: int = 0) -> NDArray: r""" Compute the annualized volatility of each `X`' series. In finance, volatility is the degree of variation of a trading price series over time as measured by the standard deviation of logarithmic returns [2]_. Notes ----- Let :math:`Var` the variance function of a random variable: .. math:: annualVolatility = \sqrt{period \times Var(R_{1:T})} Where, :math:`R_1 = 0` and :math:`R_{2:T} = \begin{cases}ln(\frac{X_{2:T}} {X_{1:T-1}}) \text{, if log=True} \\ \frac{X_{2:T}}{X_{1:T-1}} - 1 \text{, otherwise} \\ \end{cases}` Parameters ---------- X : np.ndarray[dtype, ndim=1 or 2] Time-series of price, performance or index. period : int, optional Number of period per year, default is 252 (trading days per year). log : bool, optional - If True then logarithmic returns are computed. - Else then returns in percentage are computed. axis : {0, 1}, optional Axis along wich the computation is done. Default is 0. dtype : np.dtype, optional The type of the output array. If `dtype` is not given, infer the data type from `X` input. ddof : int, optional Means Delta Degrees of Freedom, the divisor used in calculations is ``T - ddof``, where ``T`` represents the number of elements in time axis. Default is 0. Returns ------- dtype or np.ndarray([dtype, ndim=1]) Values of annualized volatility for each series. References ---------- .. [2] https://en.wikipedia.org/wiki/Volatility_(finance) Examples -------- Assume series of monthly prices: >>> X = np.array([100, 110, 105, 110, 120, 108]).astype(np.float64) >>> annual_volatility(X, period=12, log=True, ddof=1) 0.2731896268610321 >>> annual_volatility(X.reshape([6, 1]), period=12, log=False) array([0.24961719]) See Also -------- mdd, drawdown, sharpe, annual_return """ return _annual_volatility(X, period, log, axis, ddof)
[docs] @WrapperArray('dtype', 'axis', 'null', 'ddof', min_size=2) def sharpe(X: NDArray, rf: float = 0, period: int = 252, log: bool = False, axis: int = 0, dtype=None, ddof: int = 0) -> NDArray: r""" Compute the Sharpe ratio for each `X`' series. Annualized excess return per unit of volatility — the most widely used risk-adjusted performance metric. Higher is better; ratios above 1 are usually considered good and above 2 excellent for long-horizon strategies. Note that the Sharpe ratio penalizes both upside and downside volatility symmetrically; use the Sortino or Calmar ratio (:func:`calmar`) when only downside risk should be penalized. The ``period`` argument controls annualization (252 for daily trading data, 12 for monthly, etc.). For a rolling estimate, see :func:`roll_sharpe`. Notes ----- Sharpe ratio [7]_ is computed as the annualized expected returns (:func:`~annual_return`) minus the risk-free rate (noted :math:`rf`) over the annualized volatility of returns (:func:`~annual_volatility`) such that: .. math:: sharpeRatio = \frac{E(R) - rf}{\sqrt{period \times Var(R)}} \\ \\ where, :math:`R_1 = 0` and :math:`R_{2:T} = \begin{cases}ln(\frac{X_{2:T}} {X_{1:T-1}}) \text{, if log=True}\\ \frac{X_{2:T}}{X_{1:T-1}} - 1 \text{, otherwise} \\ \end{cases}` Parameters ---------- X : np.ndarray[dtype, ndim=1 or 2] Time-series of prices, performances or index. rf : float, optional Means the annualized risk-free rate, default is 0. period : int, optional Number of period per year, default is 252 (trading days). log : bool, optional If true compute sharpe with the formula for log-returns, default is False. axis : {0, 1}, optional Axis along wich the computation is done. Default is 0. dtype : np.dtype, optional The type of the output array. If `dtype` is not given, infer the data type from `X` input. ddof : int, optional Means Delta Degrees of Freedom, the divisor used in calculations is ``T - ddof``, where ``T`` represents the number of elements in time axis. Default is 0. Returns ------- dtype or np.ndarray[dtype, ndim=1] Value of Sharpe ratio for each series. References ---------- .. [7] https://en.wikipedia.org/wiki/Sharpe_ratio Examples -------- Assume a series X of monthly prices: >>> X = np.array([70, 100, 80, 120, 160, 80]).astype(np.float64) >>> sharpe(X, period=12) 0.24475518072327812 >>> sharpe(X.reshape([6, 1]), period=12) array([0.24475518]) See Also -------- mdd, calmar, drawdown, roll_sharpe """ ret = _annual_return(X, period, ddof) vol = _annual_volatility(X, period, log, axis, ddof) if (vol == 0.).any(): res = ret - rf res[vol == 0.] = np.inf res[vol != 0.] = res[vol != 0.] / vol[vol != 0.] return res return (ret - rf) / vol
[docs] @WrapperArray('dtype', 'axis', 'null', 'ddof', min_size=2) def sortino( X: NDArray, rf: float = 0, period: int = 252, log: bool = False, axis: int = 0, dtype=None, ddof: int = 0, ) -> NDArray: r""" Compute the Sortino ratio for each `X`' series. Annualized excess return per unit of *downside* volatility. Unlike the Sharpe ratio (:func:`sharpe`), only negative returns contribute to the denominator, so strategies that generate frequent large gains are not penalized for their upside variance. Notes ----- The Sortino ratio is computed as the annualized expected return minus the risk-free rate divided by the annualized downside deviation: .. math:: sortinoRatio = \frac{E(R) - rf}{\sqrt{period \times Var(R^{-})}} where :math:`R^{-}_t = \min(R_t, 0)` and :math:`R` is defined as for :func:`sharpe`. Parameters ---------- X : np.ndarray[dtype, ndim=1 or 2] Time-series of prices, performances or index. rf : float, optional Annualized risk-free rate. Default is 0. period : int, optional Number of periods per year. Default is 252 (trading days). log : bool, optional If True, compute returns as log-returns. Default is False. axis : {0, 1}, optional Axis along which the computation is done. Default is 0. dtype : np.dtype, optional Output array dtype. Inferred from ``X`` if not given. ddof : int, optional Delta Degrees of Freedom. Default is 0. Returns ------- dtype or np.ndarray[dtype, ndim=1] Sortino ratio for each series. Returns ``inf`` when downside volatility is zero (all returns are non-negative). Examples -------- Assume a series X of monthly prices: >>> X = np.array([70, 100, 80, 120, 160, 80]).astype(np.float64) >>> sortino(X, period=12) 0.4742428587192754 >>> sortino(X.reshape([6, 1]), period=12) array([0.47424286]) See Also -------- sharpe, calmar, mdd """ R = _compute_returns(X, log) ret = _annual_return(X, period, ddof) downside_vol = np.sqrt(period) * np.std(np.where(R < 0, R, 0.), axis=axis, ddof=ddof) if (downside_vol == 0.).any(): res = ret - rf res[downside_vol == 0.] = np.inf res[downside_vol != 0.] = res[downside_vol != 0.] / downside_vol[downside_vol != 0.] return res return (ret - rf) / downside_vol
[docs] @WrapperArray('dtype', 'axis', 'ddof', min_size=2) def calmar(X: NDArray, period: int = 252, axis: int = 0, dtype=None, ddof: int = 0) -> NDArray: r""" Compute the Calmar Ratio for each `X`' series. Notes ----- Calmar ratio [3]_ is the compouned annual return (:func:`~fynance.metrics.annual_return`) over the maximum drawdown (:func:`~fynance.metrics.mdd`). Let :math:`T` the number of time observations, DD the vector of drawdown: .. math:: calmarRatio = \frac{annualReturn}{MDD} With, :math:`annualReturn = \frac{X_T}{X_1}^{\frac{period}{T}} - 1` and :math:`MDD = max(DD_{1:T})`. Where, :math:`DD_t = 1 - \frac{X_t}{max(X_{1:t})}`, :math:`\forall t \in [1:T]`. Parameters ---------- X : np.ndarray[dtype, ndim=1 or 2] Time-series price, performance or index. period : int, optional Number of period per year, default is 252 (trading days per year). axis : {0, 1}, optional Axis along wich the computation is done. Default is 0. dtype : np.dtype, optional The type of the output array. If `dtype` is not given, infer the data type from `X` input. ddof : int, optional Means Delta Degrees of Freedom, the divisor used in calculations is ``T - ddof``, where ``T`` represents the number of elements in time axis. Default is 0. Returns ------- dtype or np.ndarray([dtype, ndim=1]) Values of Calmar ratio for each series. References ---------- .. [3] https://en.wikipedia.org/wiki/Calmar_ratio Examples -------- Assume a series of monthly prices: >>> X = np.array([70, 100, 80, 120, 160, 105, 80]).astype(np.float64) >>> calmar(X, period=12, ddof=1) array(0.6122449) >>> calmar(X.reshape([7, 1]), period=12) array([0.51446018]) See Also -------- mdd, drawdown, sharpe, roll_calmar """ ret = _annual_return(X, period, ddof) dd = _drawdown(X, False) mdd = np.max(dd, axis=axis) calmar = np.zeros(ret.shape) slice_bool = (mdd != 0) calmar[slice_bool] = ret[slice_bool] / mdd[slice_bool] return calmar
[docs] @WrapperArray('axis') def diversified_ratio(X: NDArray, W: NDArray | None = None, std_method: str = 'std', axis: int = 0) -> float: """ Compute diversification ratio of a portfolio. Notes ----- Diversification ratio, denoted D, is defined as the ratio of the portfolio's weighted average volatility to its overll volatility, developed by Choueifaty and Coignard [4]_. .. math:: D(P) = \\frac{P' \\Sigma}{\\sqrt{P'VP}} With :math:`\\Sigma` vector of asset volatilities, :math:`P` vector of weights of asset of portfolio, and :math:`V` matrix of variance-covariance of these assets. Parameters ---------- X : np.ndarray[ndim=2, dtype=np.float64] of shape (T, N) Portfolio matrix of N assets and T time periods, each column correspond to one series of prices. W : np.array[ndim=1 or 2, dtype=np.float64] of size N, optional Vector of weights, default is None it means it will equaly weighted. std_method : str, optional /!\\ Not yet implemented /!\ Method to compute variance vector and covariance matrix. axis : {0, 1}, optional Axis along wich the computation is done. Default is 0. Returns ------- np.float64 Value of diversification ratio of the portfolio. References ---------- .. [4] `Choueifaty, Y., and Coignard, Y., 2008, Toward Maximum \ Diversification. <https://www.tobam.fr/wp-content/uploads/2014/12/\ TOBAM-JoPM-Maximum-Div-2008.pdf>`_ """ # append examples T, N = X.shape if W is None: W = np.ones([N, 1]) / N else: W = W.reshape([N, 1]) sigma = np.std(X, axis=0).reshape([N, 1]) V = np.cov(X, rowvar=False, bias=True).reshape([N, N]) return (W.T @ sigma) / np.sqrt(W.T @ V @ W)
[docs] @WrapperArray('dtype', 'axis', 'null', 'window', 'ddof', min_size=3) def roll_annual_volatility( X: NDArray, period: int = 252, log: bool = True, w: int | None = None, axis: int = 0, dtype=None, ddof: int = 0, ) -> NDArray: r""" Compute the annualized volatility of each `X`' series. In finance, volatility is the degree of variation of a trading price series over time as measured by the standard deviation of logarithmic returns [2]_. Notes ----- The rolling annualized volatility of returns is computed such that :math:`\forall t \in [1, T]`: .. math:: annualVolatility_t = \sqrt{period \times Var(R_{1:t})} \\ \\ Where, :math:`R_1 = 0` and :math:`R_{2:t} = \begin{cases}ln(\frac{X_{2:t}} {X_{1:t-1}}) \text{, if log=True}\\ \frac{X_{2:t}}{X_{1:t-1}} - 1 \text{, otherwise} \\ \end{cases}`. Parameters ---------- X : np.ndarray[dtype, ndim=1 or 2] Time-series of price, performance or index. period : int, optional Number of period per year, default is 252 (trading days per year). log : bool, optional - If True then logarithmic returns are computed. - Else then returns in percentage are computed. w : int, optional Size of the lagged window of the rolling function, must be positive. If ``w is None`` or ``w=0``, then ``w=X.shape[axis]``. Default is None. axis : {0, 1}, optional Axis along wich the computation is done. Default is 0. dtype : np.dtype, optional The type of the output array. If `dtype` is not given, infer the data type from `X` input. ddof : int, optional Means Delta Degrees of Freedom, the divisor used in calculations is ``t - ddof``, where ``t`` represents the number of elements in time axis. Default is 0. Returns ------- dtype or np.ndarray([dtype, ndim=1 or 2]) Rolling annualized volatility for each series. References ---------- .. [2] https://en.wikipedia.org/wiki/Volatility_(finance) Examples -------- Assume series of monthly prices: >>> X = np.array([100, 110, 105, 110, 120, 108]).astype(np.float64) >>> roll_annual_volatility(X, period=12, log=False, ddof=1) array([0. , 0.24494897, 0.25777176, 0.21655755, 0.21313847, 0.27344193]) >>> roll_annual_volatility(X.reshape([6, 1]), period=12, log=False) array([[0. ], [0.17320508], [0.21046976], [0.18754434], [0.19063685], [0.24961719]]) See Also -------- mdd, drawdown, sharpe, annual_return """ return _roll_annual_volatility(X, period, log, w, axis, ddof)
[docs] @WrapperArray('dtype', 'axis', 'window', 'ddof', min_size=2) def roll_sharpe( X: NDArray, rf: float = 0, period: int = 252, w: int | None = None, log: bool = False, axis: int = 0, dtype=None, ddof: int = 0, ) -> NDArray: r""" Compute rolling sharpe ratio of each `X`' series. Notes ----- Sharpe ratio [7]_ is computed as the rolling annualized expected returns (:func:`~roll_annual_return`) minus the risk-free rate (noted :math:`rf`) over the rolling annualized volatility of returns (:func:`~roll_annual_volatility`) such that :math:`\forall t \in [1:T]`: .. math:: sharpeRatio_t = \frac{E(R | R_{1:t}) - rf_t}{\sqrt{period \times Var(R | R_{1:t})}} Where, :math:`R_1 = 0` and :math:`R_{2:T} = \begin{cases}ln(\frac{X_{2:T}} {X_{1:T-1}}) \text{, if log=True}\\ \frac{X_{2:T}}{X_{1:T-1}} - 1 \text{, otherwise} \\ \end{cases}`. Parameters ---------- X : np.ndarray[dtype, ndim=1 or 2] Time-series of prices, performances or index. rf : float or np.ndarray[dtype, ndim=1 or 2], optional Means the annualized risk-free rate, default is 0. If an array is passed, it must be of the same shape than ``X``. period : int, optional Number of period per year, default is 252 (trading days). w : int, optional Size of the lagged window of the rolling function, must be positive. If ``w is None`` or ``w=0``, then ``w=X.shape[axis]``. Default is None. log : bool, optional If true compute sharpe with the formula for log-returns, default is False. axis : {0, 1}, optional Axis along wich the computation is done. Default is 0. dtype : np.dtype, optional The type of the output array. If `dtype` is not given, infer the data type from `X` input. ddof : int, optional Means Delta Degrees of Freedom, the divisor used in calculations is ``T - ddof``, where ``T`` represents the number of elements in time axis. Default is 0. Returns ------- np.ndarray[dtype, ndim=1 or 2] Serires of rolling Sharpe ratio. References ---------- .. [7] https://en.wikipedia.org/wiki/Sharpe_ratio Examples -------- Assume a monthly series of prices: >>> X = np.array([70, 100, 80, 120, 160, 80]).astype(np.float64) >>> roll_sharpe(X, period=12) array([ 0. , 10.10344078, 0.77721579, 3.99243019, 6.754557 , 0.24475518]) See Also -------- roll_calmar, sharpe, roll_mdd """ ret = _roll_annual_return(X, period, w, ddof) vol = _roll_annual_volatility(X, period, log, w, axis, ddof) sharpe = np.zeros(X.shape) slice_bool = (vol != 0) if isinstance(rf, float) or isinstance(rf, int): _rf = rf elif isinstance(rf, np.ndarray) and rf.shape[0] != X.shape[0]: msg_prefix = 'rf must be ' raise ArraySizeError(X.shape[0], msg_prefix=msg_prefix) else: _rf = rf[slice_bool] sharpe[slice_bool] = (ret[slice_bool] - _rf) / vol[slice_bool] return sharpe
[docs] @WrapperArray('dtype', 'axis', 'window', 'ddof', min_size=2) def roll_calmar(X: NDArray, period: float = 252., w: int | None = None, axis: int = 0, dtype=None, ddof: int = 0) -> NDArray: r""" Compute the rolling Calmar ratio of each `X`' series. Notes ----- Calmar ratio [3]_ is the rolling compouned annual return (:func:`~fynance.metrics.roll_annual_return`) over the rolling maximum drawdown (:func:`~fynance.metrics.roll_mdd`). Let :math:`T` the number of time observations, DD the vector of drawdown, :math:`\forall t \in [1:T]`: .. math:: calmarRatio_t = \frac{annualReturn_t}{MDD_t} \\ \\ With, :math:`annualReturn_t = \frac{X_t}{X_1}^{\frac{period}{t}} - 1` and :math:`MDD_t = max(DD_t)`, where :math:`DD_t = 1 - \frac{X_t}{max(X_{1:t})}`. Parameters ---------- X : np.ndarray[dtype, ndim=1 or 2] Time-series of price, performance or index. period : int, optional Number of period per year, default is 252 (trading days per year). w : int, optional Size of the lagged window of the rolling function, must be positive. If ``w is None`` or ``w=0``, then ``w=X.shape[axis]``. Default is None. axis : {0, 1}, optional Axis along wich the computation is done. Default is 0. dtype : np.dtype, optional The type of the output array. If `dtype` is not given, infer the data type from `X` input. ddof : int, optional Means Delta Degrees of Freedom, the divisor used in calculations is ``t - ddof``, where ``t`` represents the number of elements in time axis. Default is 0. Returns ------- np.ndarray[dtype, ndim=1 or 2] Series of rolling Calmar ratio. References ---------- .. [3] https://en.wikipedia.org/wiki/Calmar_ratio Examples -------- Assume a monthly series of prices: >>> X = np.array([70, 100, 80, 120, 160, 80]).astype(np.float64) >>> roll_calmar(X, period=12) array([ 0. , 0. , 3.52977926, 20.18950437, 31.35989887, 0.6122449 ]) See Also -------- roll_mdd, roll_sharpe, calmar """ ret = _roll_annual_return(X, period, w, ddof) mdd = _roll_mdd(X, w, False) calmar = np.zeros(X.shape) slice_bool = (mdd != 0) calmar[slice_bool] = ret[slice_bool] / mdd[slice_bool] return calmar