#!/usr/bin/env python3
# coding: utf-8
r""" Ex-ante risk decomposition for portfolios.
Marginal and absolute risk contributions of a weight vector to portfolio
variance, plus a causal rolling variant for walk-forward decomposition of
a `(T, N)` weight path against sliding windows of returns.
Main entry points
-----------------
- :func:`marginal_risk` — marginal contribution of each asset to portfolio
volatility.
- :func:`risk_contribution` — absolute (or percentage) risk contribution
of each asset.
- :func:`roll_risk_contribution` — causal rolling risk contributions over
a time series.
"""
from __future__ import annotations
# Built-in packages
from typing import Callable
# Third-party packages
import numpy as np
from numpy.typing import NDArray
__all__ = ['marginal_risk', 'risk_contribution', 'roll_risk_contribution']
[docs]
def marginal_risk(w: NDArray, sigma: NDArray) -> NDArray:
r""" Marginal contribution of each asset to portfolio volatility.
Compute the gradient of portfolio volatility with respect to weights:
:math:`\partial \sigma_p / \partial w = (\Sigma w) / \sigma_p`, where
:math:`\sigma_p = \sqrt{w^\top \Sigma w}` is the portfolio volatility.
Parameters
----------
w : array_like
Portfolio weights, shape ``(N,)`` or ``(N, 1)``.
sigma : array_like
Symmetric ``(N, N)`` covariance matrix.
Returns
-------
np.ndarray
Marginal risk per asset, shape ``(N,)``. If portfolio volatility
is zero, returns zeros.
Examples
--------
>>> import numpy as np
>>> w = np.array([0.6, 0.4])
>>> sigma = np.array([[0.04, 0.0], [0.0, 0.01]])
>>> mr = marginal_risk(w, sigma)
>>> mr.shape
(2,)
>>> bool(np.all(mr > 0))
True
"""
w = np.asarray(w, dtype=np.float64).ravel()
sigma = np.asarray(sigma, dtype=np.float64)
if sigma.ndim != 2 or sigma.shape[0] != sigma.shape[1]:
raise ValueError("sigma must be a square 2-D covariance matrix.")
if sigma.shape[0] != w.shape[0]:
raise ValueError(
f"sigma shape {sigma.shape} does not match w shape {w.shape}."
)
if not np.all(np.isfinite(sigma)):
raise ValueError("sigma contains non-finite values (NaN or inf).")
if not np.all(np.isfinite(w)):
raise ValueError("w contains non-finite values (NaN or inf).")
sigma_w = sigma @ w
sigma_p = np.sqrt(w @ sigma_w)
if sigma_p == 0.0:
return np.zeros_like(w)
return sigma_w / sigma_p
[docs]
def risk_contribution(
w: NDArray,
sigma: NDArray,
pct: bool = True,
) -> NDArray:
r""" Risk contribution of each asset to portfolio variance.
Absolute risk contribution: :math:`RC_i = w_i \cdot MR_i`, where
:math:`MR_i` is the marginal risk. When :math:`pct=True`, normalize
by portfolio volatility so contributions sum to 1 (percentage).
Parameters
----------
w : array_like
Portfolio weights, shape ``(N,)`` or ``(N, 1)``.
sigma : array_like
Symmetric ``(N, N)`` covariance matrix.
pct : bool, optional
If True (default), return percentage contributions that sum to 1.
If False, return absolute contributions that sum to portfolio
volatility.
Returns
-------
np.ndarray
Risk contribution per asset, shape ``(N,)``. If portfolio
volatility is zero, returns zeros.
Examples
--------
>>> import numpy as np
>>> w = np.array([0.5, 0.5])
>>> sigma = np.array([[0.04, 0.0], [0.0, 0.01]])
>>> rc_pct = risk_contribution(w, sigma, pct=True)
>>> np.allclose(rc_pct, [0.8, 0.2])
True
"""
w = np.asarray(w, dtype=np.float64).ravel()
sigma = np.asarray(sigma, dtype=np.float64)
if sigma.ndim != 2 or sigma.shape[0] != sigma.shape[1]:
raise ValueError("sigma must be a square 2-D covariance matrix.")
if sigma.shape[0] != w.shape[0]:
raise ValueError(
f"sigma shape {sigma.shape} does not match w shape {w.shape}."
)
if not np.all(np.isfinite(sigma)):
raise ValueError("sigma contains non-finite values (NaN or inf).")
if not np.all(np.isfinite(w)):
raise ValueError("w contains non-finite values (NaN or inf).")
mr = marginal_risk(w, sigma)
rc = w * mr
if not pct:
return rc
# Normalize to sum to 1
sigma_w = sigma @ w
sigma_p = np.sqrt(w @ sigma_w)
if sigma_p == 0.0:
return np.zeros_like(w)
return rc / sigma_p
[docs]
def roll_risk_contribution(
W: NDArray,
X: NDArray,
n: int = 252,
cov: Callable[[NDArray], NDArray] | None = None,
pct: bool = True,
) -> NDArray:
r""" Causal rolling risk contributions over a time series.
For each time step :math:`t \geq n`, estimate the covariance matrix
from returns in the past :math:`n` periods and compute risk
contributions for the weights at time :math:`t`. Earlier rows are
filled with NaN (no covariance estimate yet).
Parameters
----------
W : array_like
Weight time series, shape ``(T, N)``.
X : array_like
Returns panel, shape ``(T, N)``, rows in chronological order
(oldest first).
n : int, optional
Window length for covariance estimation. Default 252.
cov : callable, optional
Covariance estimator callable that accepts a returns panel and
returns a symmetric ``(N, N)`` matrix. If None (default), uses
:func:`numpy.cov` with ``rowvar=False``.
pct : bool, optional
If True (default), return percentage contributions summing to 1.
If False, return absolute contributions summing to portfolio
volatility per period.
Returns
-------
np.ndarray
Risk contributions per period, shape ``(T, N)``. Rows ``t < n``
are filled with NaN.
Examples
--------
>>> import numpy as np
>>> rng = np.random.default_rng(0)
>>> X = rng.standard_normal((50, 3))
>>> W = rng.uniform(0.2, 0.4, (50, 3))
>>> W /= W.sum(axis=1, keepdims=True)
>>> rc = roll_risk_contribution(W, X, n=20)
>>> rc.shape
(50, 3)
>>> np.isnan(rc[:20]).all()
True
"""
W = np.asarray(W, dtype=np.float64)
X = np.asarray(X, dtype=np.float64)
if W.ndim != 2 or X.ndim != 2:
raise ValueError("W and X must both be 2-D arrays.")
if W.shape != X.shape:
raise ValueError(
f"W shape {W.shape} does not match X shape {X.shape}."
)
if n < 1 or n >= W.shape[0]:
raise ValueError(
f"Window length n={n} must be >= 1 and < T={W.shape[0]}."
)
if not np.all(np.isfinite(W)):
raise ValueError("W contains non-finite values (NaN or inf).")
if not np.all(np.isfinite(X)):
raise ValueError("X contains non-finite values (NaN or inf).")
T, N = W.shape
rc = np.full((T, N), np.nan, dtype=np.float64)
for t in range(n, T):
window = X[t - n : t]
# Estimate covariance: use provided callable or numpy.cov
if cov is not None:
sigma_t = cov(window)
else:
sigma_t = np.atleast_2d(np.cov(window, rowvar=False))
# Compute risk contribution for weights at time t
rc[t] = risk_contribution(W[t], sigma_t, pct=pct)
return rc