Source code for wraquant.risk.tail

"""Tail risk analytics for non-normal return distributions.

Standard risk measures (VaR, volatility) assume or approximate normality.
In practice, financial returns are fat-tailed (excess kurtosis) and
left-skewed. This module provides tail-aware risk measures that account
for higher moments and drawdown-based risk.

Functions:

1. **Cornish-Fisher VaR** -- adjusts the normal VaR quantile for skewness
   and kurtosis using the Cornish-Fisher expansion.
2. **ES decomposition** -- per-asset contribution to Expected Shortfall.
3. **Conditional Drawdown at Risk (CDaR)** -- the average of worst-alpha%
   drawdowns (analogous to CVaR but for drawdowns).
4. **Tail ratio analysis** -- 95th/5th percentile ratio with diagnostics.
5. **Drawdown at Risk (DaR)** -- worst alpha-quantile drawdown.

References:
    - Cornish & Fisher (1937), "Moments and Cumulants in the Specification
      of Distributions"
    - Chekhlov, Uryasev & Zabarankin (2005), "Drawdown Measure in
      Portfolio Optimization"
"""

from __future__ import annotations

from typing import Any

import numpy as np
import pandas as pd
from scipy import stats as sp_stats

from wraquant.risk.var import value_at_risk as _value_at_risk


[docs] def cornish_fisher_var( returns: pd.Series, alpha: float = 0.05, ) -> dict[str, Any]: """Cornish-Fisher expansion VaR (skewness and kurtosis adjusted). The Cornish-Fisher expansion modifies the standard normal quantile to account for skewness (S) and excess kurtosis (K) of the return distribution. This produces a more accurate VaR than parametric (Gaussian) VaR for non-normal distributions. When to use: Use Cornish-Fisher VaR when: - Returns are detectably non-normal (skewness != 0 or kurtosis != 3). - You want a quick analytical adjustment without fitting a full distribution (e.g., Student-t or EVT). - The sample is too short for reliable historical VaR but long enough to estimate skewness/kurtosis (>100 observations). Mathematical formulation: z_cf = z + (z^2 - 1) * S/6 + (z^3 - 3z) * K/24 - (2z^3 - 5z) * S^2/36 CF-VaR = -(mu + sigma * z_cf) where z = Phi^{-1}(alpha), S = skewness, K = excess kurtosis. How to interpret: Compare ``cf_var`` to ``normal_var``. If cf_var > normal_var, the distribution has fatter left tails than normal (typical for equities). The ``adjustment_factor`` (cf_var / normal_var) tells you how much the normal VaR underestimates tail risk. Parameters: returns: Simple return series. alpha: Significance level (0.05 = 95% VaR). Returns: Dictionary containing: - **cf_var** (*float*) -- Cornish-Fisher adjusted VaR (positive number = loss). - **normal_var** (*float*) -- Standard parametric (Gaussian) VaR. - **z_cf** (*float*) -- Adjusted quantile. - **z_normal** (*float*) -- Standard normal quantile. - **skewness** (*float*) -- Sample skewness. - **excess_kurtosis** (*float*) -- Sample excess kurtosis. - **adjustment_factor** (*float*) -- cf_var / normal_var. Example: >>> import pandas as pd, numpy as np >>> np.random.seed(42) >>> returns = pd.Series(np.random.normal(0, 0.01, 1000)) >>> result = cornish_fisher_var(returns, alpha=0.05) >>> result["cf_var"] > 0 True See Also: wraquant.risk.var.value_at_risk: Historical and parametric VaR. tail_ratio_analysis: Tail shape diagnostics. References: - Cornish & Fisher (1937), "Moments and Cumulants in the Specification of Distributions" - Maillard (2012), "A User's Guide to the Cornish Fisher Expansion" """ clean = returns.dropna().values mu = float(np.mean(clean)) sigma = float(np.std(clean, ddof=1)) s = float(sp_stats.skew(clean)) k = float(sp_stats.kurtosis(clean)) # excess kurtosis z = sp_stats.norm.ppf(alpha) # Cornish-Fisher expansion z_cf = ( z + (z**2 - 1) * s / 6 + (z**3 - 3 * z) * k / 24 - (2 * z**3 - 5 * z) * s**2 / 36 ) cf_var = -(mu + sigma * z_cf) normal_var = -(mu + sigma * z) adjustment = cf_var / normal_var if normal_var != 0 else 1.0 return { "cf_var": float(cf_var), "normal_var": float(normal_var), "z_cf": float(z_cf), "z_normal": float(z), "skewness": s, "excess_kurtosis": k, "adjustment_factor": float(adjustment), }
[docs] def expected_shortfall_decomposition( weights: np.ndarray, returns: pd.DataFrame, alpha: float = 0.05, ) -> pd.Series: """Decompose Expected Shortfall (CVaR) into per-asset contributions. Each asset's contribution to portfolio ES is computed as its average return on the days when the portfolio return is in the worst alpha tail. These contributions are additive (they sum to total portfolio ES). When to use: Use ES decomposition for: - Identifying which assets drive tail losses. - Setting per-asset ES limits. - Comparing tail-risk concentration to normal-market risk concentration. Mathematical formulation: ES_i = w_i * E[r_i | r_p <= VaR_alpha(r_p)] where r_p = w' @ r is the portfolio return. Parameters: weights: Portfolio weight vector (n_assets,). returns: Multi-asset return DataFrame (columns = assets). alpha: Significance level (0.05 = worst 5% of days). Returns: pd.Series of per-asset ES contributions. Sum equals portfolio ES (as a positive number). Example: >>> import pandas as pd, numpy as np >>> np.random.seed(42) >>> returns = pd.DataFrame({ ... "A": np.random.normal(0.0005, 0.01, 500), ... "B": np.random.normal(0.0003, 0.015, 500), ... }) >>> weights = np.array([0.6, 0.4]) >>> es = expected_shortfall_decomposition(weights, returns, alpha=0.05) >>> es.sum() > 0 # positive = loss True See Also: component_var: Euler decomposition of VaR. cornish_fisher_var: Skewness-adjusted VaR. """ clean = returns.dropna() port_returns = clean.values @ weights cutoff = np.percentile(port_returns, alpha * 100) tail_mask = port_returns <= cutoff if tail_mask.sum() == 0: return pd.Series(np.zeros(len(weights)), index=returns.columns) tail_returns = clean.values[tail_mask] avg_tail = tail_returns.mean(axis=0) contributions = -weights * avg_tail return pd.Series(contributions, index=returns.columns, name="es_contribution")
[docs] def conditional_drawdown_at_risk( returns: pd.Series, alpha: float = 0.05, ) -> float: """Conditional Drawdown at Risk (CDaR). CDaR is the average of the worst alpha fraction of drawdowns in the return series. It is analogous to CVaR (Expected Shortfall) but operates on drawdowns rather than returns. CDaR is a coherent risk measure and is used in drawdown-constrained portfolio optimisation. When to use: Use CDaR when drawdown is a primary risk constraint (e.g., hedge funds with max drawdown mandates). CDaR penalises sustained drawdowns, not just point-in-time losses. A portfolio optimised to minimise CDaR will have better drawdown recovery properties than one optimised for VaR. Parameters: returns: Simple return series. alpha: Fraction of worst drawdowns to average (0.05 = worst 5%). Returns: CDaR as a positive float (e.g., 0.15 = average worst-5% drawdown is 15%). Example: >>> import pandas as pd, numpy as np >>> np.random.seed(42) >>> returns = pd.Series(np.random.normal(0.0005, 0.01, 500)) >>> cdar = conditional_drawdown_at_risk(returns, alpha=0.05) >>> cdar >= 0 True See Also: drawdown_at_risk: Quantile-based drawdown measure (DaR). wraquant.risk.metrics.max_drawdown: Single worst drawdown. References: - Chekhlov, Uryasev & Zabarankin (2005), "Drawdown Measure in Portfolio Optimization" """ clean = returns.dropna() cum = (1 + clean).cumprod() running_max = cum.cummax() drawdowns = (cum - running_max) / running_max # negative values # CDaR: mean of worst alpha fraction dd_values = drawdowns.values n_tail = max(1, int(len(dd_values) * alpha)) sorted_dd = np.sort(dd_values)[:n_tail] # most negative first return float(-np.mean(sorted_dd))
[docs] def tail_ratio_analysis(returns: pd.Series) -> dict[str, Any]: """Tail ratio analysis with interpretation. The tail ratio is the ratio of the right tail (gains) to the absolute value of the left tail (losses) at a given percentile. A ratio > 1 means the distribution has fatter right tails (gains are larger than losses at the extremes). A ratio < 1 means fatter left tails (losses are larger than gains). When to use: Use tail ratio analysis to: - Assess payoff asymmetry: trend-following should have tail ratio > 1 (large gains, small frequent losses). - Detect negative skew: mean-reversion and short vol strategies typically have tail ratio < 1. - Compare strategies beyond Sharpe ratio. Parameters: returns: Simple return series. Returns: Dictionary containing: - **tail_ratio** (*float*) -- 95th percentile / abs(5th percentile). - **right_tail** (*float*) -- 95th percentile return. - **left_tail** (*float*) -- 5th percentile return. - **tail_ratio_99** (*float*) -- 99th/1st percentile ratio. - **skewness** (*float*) -- Sample skewness. - **excess_kurtosis** (*float*) -- Sample excess kurtosis. - **interpretation** (*str*) -- Human-readable assessment. Example: >>> import pandas as pd, numpy as np >>> np.random.seed(42) >>> returns = pd.Series(np.random.normal(0, 0.01, 1000)) >>> result = tail_ratio_analysis(returns) >>> result["tail_ratio"] > 0 True See Also: cornish_fisher_var: Skewness-adjusted VaR. """ clean = returns.dropna().values p5 = float(np.percentile(clean, 5)) p95 = float(np.percentile(clean, 95)) p1 = float(np.percentile(clean, 1)) p99 = float(np.percentile(clean, 99)) tail_ratio_95 = p95 / abs(p5) if abs(p5) > 1e-15 else float("inf") tail_ratio_99 = p99 / abs(p1) if abs(p1) > 1e-15 else float("inf") skew = float(sp_stats.skew(clean)) kurt = float(sp_stats.kurtosis(clean)) if tail_ratio_95 > 1.2: interp = "Right-skewed: gains are larger than losses at the tails. Favorable for trend-following." elif tail_ratio_95 < 0.8: interp = "Left-skewed: losses are larger than gains at the tails. Typical for short-vol or mean-reversion." else: interp = ( "Approximately symmetric tails. Consistent with near-normal distribution." ) return { "tail_ratio": float(tail_ratio_95), "right_tail": p95, "left_tail": p5, "tail_ratio_99": float(tail_ratio_99), "skewness": skew, "excess_kurtosis": kurt, "interpretation": interp, }
[docs] def drawdown_at_risk( returns: pd.Series, alpha: float = 0.05, ) -> float: """Drawdown at Risk (DaR): worst alpha-quantile drawdown. DaR is to drawdowns what VaR is to returns. It is the alpha-percentile of the drawdown distribution -- i.e., the drawdown that is exceeded only alpha% of the time. When to use: Use DaR when setting drawdown limits: - "With 95% confidence, the drawdown will not exceed DaR." - Useful for fund prospectuses and investor communications. - More intuitive than VaR for many stakeholders because drawdowns are easier to understand than daily P&L. Parameters: returns: Simple return series. alpha: Significance level (0.05 = 95th percentile drawdown). Returns: DaR as a positive float (e.g., 0.12 = 12% drawdown). Example: >>> import pandas as pd, numpy as np >>> np.random.seed(42) >>> returns = pd.Series(np.random.normal(0.0005, 0.01, 500)) >>> dar = drawdown_at_risk(returns, alpha=0.05) >>> dar >= 0 True See Also: conditional_drawdown_at_risk: Average of worst drawdowns (CDaR). wraquant.risk.metrics.max_drawdown: Single worst drawdown. """ clean = returns.dropna() cum = (1 + clean).cumprod() running_max = cum.cummax() drawdowns = (cum - running_max) / running_max # negative values # DaR: alpha-percentile of drawdowns dar = float(np.percentile(drawdowns.values, alpha * 100)) return -dar