Source code for kaxanuk.attribution_analysis.modules.performance_functions

"""
Portfolio and asset performance/risk metric functions.

This module provides a collection of statistical and financial functions for
analyzing return series, including higher-moment statistics (skewness,
kurtosis), return compounding and annualization, risk-adjusted performance
(Sharpe ratio), drawdown analysis, and Value-at-Risk / Conditional
Value-at-Risk estimation (historic and Gaussian/Cornish-Fisher).

All functions that accept a returns argument ``r`` are designed to work
transparently with either a :class:`pandas.Series` (a single return series)
or a :class:`pandas.DataFrame` (multiple return series, one per column),
returning a scalar or a :class:`pandas.Series` of per-column results
accordingly.
"""

import numpy as np
import pandas as pd
from scipy.stats import norm


[docs] def skewness(r: pd.Series | pd.DataFrame) -> float | pd.Series: """ Compute the skewness of the supplied returns. Alternative to :func:`scipy.stats.skew` that uses the population standard deviation (``ddof=0``). Parameters ---------- r : pandas.Series or pandas.DataFrame Series or DataFrame of returns. Returns ------- float or pandas.Series The skewness of `r`. Returns a float if `r` is a Series, or a Series of per-column skewness values if `r` is a DataFrame. """ demeaned_r = r - r.mean() # use the population standard deviation, so set dof=0 sigma_r = r.std(ddof=0) exp = (demeaned_r**3).mean() return exp / sigma_r**3
[docs] def kurtosis(r: pd.Series | pd.DataFrame) -> float | pd.Series: """ Compute the kurtosis of the supplied returns. Alternative to :func:`scipy.stats.kurtosis` that uses the population standard deviation (``ddof=0``). Parameters ---------- r : pandas.Series or pandas.DataFrame Series or DataFrame of returns. Returns ------- float or pandas.Series The kurtosis of `r`. Returns a float if `r` is a Series, or a Series of per-column kurtosis values if `r` is a DataFrame. """ demeaned_r = r - r.mean() # use the population standard deviation, so set dof=0 sigma_r = r.std(ddof=0) exp = (demeaned_r**4).mean() return exp / sigma_r**4
[docs] def compound(r: pd.Series | pd.DataFrame) -> float | pd.Series: """ Compound the set of returns in `r`. Parameters ---------- r : pandas.Series or pandas.DataFrame Series or DataFrame of periodic returns. Returns ------- float or pandas.Series The compounded (total) return over the whole period. Returns a float if `r` is a Series, or a Series of per-column compounded returns if `r` is a DataFrame. """ return np.expm1(np.log1p(r).sum())
[docs] def annualize_rets(r: pd.Series | pd.DataFrame, periods_per_year: int) -> float | pd.Series: """ Annualize a set of returns. Notes ----- We should infer the periods per year, but that is currently left as an exercise to the reader :-) Parameters ---------- r : pandas.Series or pandas.DataFrame Series or DataFrame of periodic returns. periods_per_year : int Number of periods per year (e.g. 252 for daily returns, 12 for monthly returns). Returns ------- float or pandas.Series The annualized return. Returns a float if `r` is a Series, or a Series of per-column annualized returns if `r` is a DataFrame. """ compounded_growth = (1 + r).prod() n_periods = r.shape[0] return compounded_growth**(periods_per_year / n_periods) - 1
[docs] def annualize_vol(r: pd.Series | pd.DataFrame, periods_per_year: int) -> float | pd.Series: """ Annualize the volatility of a set of returns. Notes ----- We should infer the periods per year, but that is currently left as an exercise to the reader :-) Parameters ---------- r : pandas.Series or pandas.DataFrame Series or DataFrame of periodic returns. periods_per_year : int Number of periods per year (e.g. 252 for daily returns, 12 for monthly returns). Returns ------- float or pandas.Series The annualized volatility. Returns a float if `r` is a Series, or a Series of per-column annualized volatilities if `r` is a DataFrame. """ return r.std() * (periods_per_year**0.5)
[docs] def sharpe_ratio( r: pd.Series | pd.DataFrame, riskfree_rate: float, periods_per_year: int, ) -> float | pd.Series: """ Compute the annualized Sharpe ratio of a set of returns. Parameters ---------- r : pandas.Series or pandas.DataFrame Series or DataFrame of periodic returns. riskfree_rate : float Annual risk-free rate, expressed as a decimal (e.g. ``0.03`` for 3%). It's converted internally to a per-period rate using `periods_per_year`. periods_per_year : int Number of periods per year (e.g. 252 for daily returns, 12 for monthly returns). Returns ------- float or pandas.Series The annualized Sharpe ratio. Returns a float if `r` is a Series, or a Series of per-column Sharpe ratios if `r` is a DataFrame. """ # convert the annual riskfree rate to per period rf_per_period = (1 + riskfree_rate)**(1 / periods_per_year) - 1 excess_ret = r - rf_per_period ann_ex_ret = annualize_rets(excess_ret, periods_per_year) ann_vol = annualize_vol(r, periods_per_year) return ann_ex_ret / ann_vol
[docs] def drawdown(return_series: pd.Series) -> pd.DataFrame: """ Compute the wealth index, running maximum, and drawdown of a return series. Parameters ---------- return_series : pandas.Series Time series of asset returns. Returns ------- pandas.DataFrame DataFrame indexed like `return_series`, with columns: - ``Wealth``: the cumulative wealth index, starting at 1000. - ``Previous Peak``: the running maximum of the wealth index. - ``Drawdown``: the percentage drawdown from the previous peak. """ wealth_index = 1000 * (1 + return_series).cumprod() previous_peaks = wealth_index.cummax() drawdowns = (wealth_index - previous_peaks) / previous_peaks return pd.DataFrame({ "Wealth": wealth_index, "Previous Peak": previous_peaks, "Drawdown": drawdowns, })
[docs] def semideviation(r: pd.Series | pd.DataFrame) -> float | pd.Series: """ Compute the semideviation (negative semideviation) of `r`. Parameters ---------- r : pandas.Series or pandas.DataFrame Series or DataFrame of returns. Returns ------- float or pandas.Series The semideviation, computed as the population standard deviation (``ddof=0``) of the negative values in `r`. Returns a float if `r` is a Series, or a Series of per-column semideviations if `r` is a DataFrame. Raises ------ TypeError If `r` is neither a :class:`pandas.Series` nor a :class:`pandas.DataFrame`. """ if isinstance(r, pd.Series): is_negative = r < 0 return r[is_negative].std(ddof=0) elif isinstance(r, pd.DataFrame): return r.aggregate(semideviation) else: raise TypeError("Expected r to be a Series or DataFrame")
[docs] def var_historic(r: pd.Series | pd.DataFrame, level: float = 5) -> float | pd.Series: """ Compute the historic Value at Risk (VaR) at a specified level. Returns the number such that `level` percent of the returns fall below that number (as a positive loss figure), and ``100 - level`` percent are above it. Parameters ---------- r : pandas.Series or pandas.DataFrame Series or DataFrame of returns. level : float, default 5 The percentile level, in percent (e.g. ``5`` for the 5th percentile / 95% VaR). Returns ------- float or pandas.Series The historic VaR, expressed as a positive number. Returns a float if `r` is a Series, or a Series of per-column VaR values if `r` is a DataFrame. Raises ------ TypeError If `r` is neither a :class:`pandas.Series` nor a :class:`pandas.DataFrame`. """ if isinstance(r, pd.DataFrame): return r.aggregate(var_historic, level=level) elif isinstance(r, pd.Series): return -np.percentile(r, level) else: raise TypeError("Expected r to be a Series or DataFrame")
[docs] def cvar_historic(r: pd.Series | pd.DataFrame, level: float = 5) -> float | pd.Series: """ Compute the historic Conditional Value at Risk (CVaR) of `r`. The CVaR (also known as expected shortfall) is the average of the returns that fall beyond the historic VaR threshold. Parameters ---------- r : pandas.Series or pandas.DataFrame Series or DataFrame of returns. level : float, default 5 The percentile level, in percent, used to compute the underlying VaR threshold (see :func:`var_historic`). Returns ------- float or pandas.Series The historic CVaR, expressed as a positive number. Returns a float if `r` is a Series, or a Series of per-column CVaR values if `r` is a DataFrame. Raises ------ TypeError If `r` is neither a :class:`pandas.Series` nor a :class:`pandas.DataFrame`. """ if isinstance(r, pd.Series): is_beyond = r <= -var_historic(r, level=level) return -r[is_beyond].mean() elif isinstance(r, pd.DataFrame): return r.aggregate(cvar_historic, level=level) else: raise TypeError("Expected r to be a Series or DataFrame")
[docs] def var_gaussian( r: pd.Series | pd.DataFrame, level: float = 5, *, modified: bool = False, ) -> float | pd.Series: """ Compute the parametric Gaussian VaR of `r`. Parameters ---------- r : pandas.Series or pandas.DataFrame Series or DataFrame of returns. level : float, default 5 The percentile level, in percent (e.g. ``5`` for the 5th percentile / 95% VaR). modified : bool, default False If True, apply the Cornish-Fisher modification to the Z-score using the observed skewness and kurtosis of `r`, returning the modified VaR instead of the plain Gaussian VaR. Returns ------- float or pandas.Series The parametric (optionally Cornish-Fisher modified) Gaussian VaR, expressed as a positive number. Returns a float if `r` is a Series, or a Series of per-column VaR values if `r` is a DataFrame. """ # compute the Z score assuming it was Gaussian z = norm.ppf(level / 100) if modified: # modify the Z score based on observed skewness and kurtosis s = skewness(r) k = kurtosis(r) z = ( z + (z**2 - 1) * s / 6 + (z**3 - 3 * z) * (k - 3) / 24 - (2 * z**3 - 5 * z) * (s**2) / 36 ) return -(r.mean() + z * r.std(ddof=0))
[docs] def summary_stats( r: pd.DataFrame, riskfree_rate: float = 0.0, periods_per_year: int = 252, ) -> pd.DataFrame: """ Compute aggregated summary statistics for the returns in `r`. Parameters ---------- r : pandas.DataFrame DataFrame of periodic returns, one column per asset/strategy. riskfree_rate : float, default 0.0 Annual risk-free rate, expressed as a decimal, used to compute the Sharpe ratio (see :func:`sharpe_ratio`). periods_per_year : int, default 252 Number of periods per year used for annualizing returns, volatility, and the Sharpe ratio. Returns ------- pandas.DataFrame A single-row DataFrame (indexed by ``'Strategy'``) with one column per summary statistic: ``Annualized Return``, ``Annualized Vol``, ``Sharpe Ratio``, ``Max Drawdown``, ``Skewness``, ``Kurtosis``, ``Cornish-Fisher VaR (5%)``, and ``Historic CVaR (5%)``. """ ann_r = r.aggregate(annualize_rets, periods_per_year=periods_per_year) ann_vol = r.aggregate(annualize_vol, periods_per_year=periods_per_year) ann_sr = r.aggregate(sharpe_ratio, riskfree_rate=riskfree_rate, periods_per_year=periods_per_year) dd = r.aggregate(lambda r: drawdown(r).Drawdown.min()) skew = r.aggregate(skewness) kurt = r.aggregate(kurtosis) cf_var5 = r.aggregate(var_gaussian, modified=True) hist_cvar5 = r.aggregate(cvar_historic) return pd.DataFrame({ "Annualized Return": ann_r, "Annualized Vol": ann_vol, "Sharpe Ratio": ann_sr, "Max Drawdown": dd, "Skewness": skew, "Kurtosis": kurt, "Cornish-Fisher VaR (5%)": cf_var5, "Historic CVaR (5%)": hist_cvar5, }, index=['Strategy'])
[docs] def portfolio_return_daily_ts( portfolio_weights: pd.DataFrame, asset_returns: pd.DataFrame, ) -> pd.Series: """ Calculate daily portfolio returns from weights and asset returns. The two DataFrames are aligned on their common dates (index) and common assets (columns) before computing the weighted sum of returns. Parameters ---------- portfolio_weights : pandas.DataFrame DataFrame with dates as index and assets as columns, containing the portfolio weight of each asset on each date. asset_returns : pandas.DataFrame DataFrame with dates as index and assets as columns, containing the periodic return of each asset on each date. Returns ------- pandas.Series Series of daily portfolio returns, indexed by the dates common to both `portfolio_weights` and `asset_returns`. """ # Align indices common_dates = portfolio_weights.index.intersection(asset_returns.index) common_stocks = portfolio_weights.columns.intersection(asset_returns.columns) aligned_weights = portfolio_weights.loc[common_dates, common_stocks] aligned_returns = asset_returns.loc[common_dates, common_stocks] # Calculate daily portfolio returns return (aligned_weights * aligned_returns).sum(axis=1)