"""Risk-based allocation: CVaR optimization, maximum diversification, risk parity and HRP.
These rules need no expected returns, or treat them only as a
constraint. Rockafellar and Uryasev showed that minimizing conditional
value at risk over scenarios is a linear program,
.. math::
\\min_{w,\\,\\zeta,\\,u}\\; \\zeta + \\frac{1}{(1-\\alpha) n}\\sum_k u_k,
\\qquad u_k \\ge -r_k'w - \\zeta,\\; u_k \\ge 0,
whose optimal :math:`\\zeta` is the value at risk. Choueifaty and Coignard
maximize the diversification ratio
:math:`D(w) = w'\\sigma / \\sqrt{w'\\Sigma w}`. Maillard, Roncalli and
Teiletche characterized the equal risk contribution portfolio, in which
every asset's Euler contribution :math:`w_i(\\Sigma w)_i` to the variance
is the same, computed here through Spinu's (2013) convex program. Lopez
de Prado's hierarchical risk parity clusters the correlation matrix,
orders assets by the dendrogram and splits weight top-down by inverse
cluster variance, never inverting the covariance matrix.
References: R. T. Rockafellar and S. Uryasev, "Optimization of
Conditional Value-at-Risk," *J. Risk* 2 (2000) 21-41; Y. Choueifaty and
Y. Coignard, "Toward Maximum Diversification," *J. Portfolio Mgmt.* 35
(2008) 40-51; S. Maillard, T. Roncalli and J. Teiletche, "The
Properties of Equally Weighted Risk Contribution Portfolios," *J.
Portfolio Mgmt.* 36 (2010) 60-70; F. Spinu, "An Algorithm for Computing
Risk Parity Weights," SSRN 2297383 (2013); M. Lopez de Prado, "Building
Diversified Portfolios that Outperform Out of Sample," *J. Portfolio
Mgmt.* 42 (2016) 59-69.
"""
from __future__ import annotations
from typing import Optional
import numpy as np
from numpy.typing import ArrayLike
from scipy import optimize
from scipy.cluster import hierarchy
from scipy.spatial.distance import squareform
from financialkit.constants import DEFAULT_ATOL, DEFAULT_MAX_ITER
from financialkit.portfolio.core.base import CVaRResult, HRPResult, PortfolioResult
from financialkit.portfolio.systems.mean_variance import min_variance_portfolio
from financialkit.portfolio.utils.optimize import as_mean_cov, portfolio_moments
from financialkit.risk.utils.tail import check_alpha
__all__ = [
"cvar_optimization",
"diversification_ratio",
"most_diversified_portfolio",
"inverse_volatility_portfolio",
"risk_parity",
"hierarchical_risk_parity",
]
[docs]
def cvar_optimization(
scenarios: ArrayLike,
alpha: float = 0.95,
target_return: Optional[float] = None,
long_only: bool = True,
) -> CVaRResult:
"""Rockafellar-Uryasev minimum-CVaR portfolio by linear programming.
Parameters
----------
scenarios : array_like, shape (n, d)
Equally likely joint return scenarios.
alpha : float
Confidence level.
target_return : float, optional
Lower bound on the mean scenario return.
long_only : bool
Exclude short sales (otherwise weights lie in ``[-1, 2]``, keeping
the program bounded).
Returns
-------
CVaRResult
Examples
--------
Two scenarios, one asset safe in each: hedging both equally removes all loss.
>>> res = cvar_optimization([[0.1, -0.1], [-0.1, 0.1]], alpha=0.5)
>>> np.round(res.weights, 6), round(res.cvar, 6) + 0.0
(array([0.5, 0.5]), 0.0)
"""
R = np.asarray(scenarios, dtype=np.float64)
n, d = R.shape
a = check_alpha(alpha)
# Variables: w (d), zeta (1), u (n).
c = np.concatenate([np.zeros(d), [1.0], np.full(n, 1.0 / ((1.0 - a) * n))])
A_ub = np.hstack([-R, -np.ones((n, 1)), -np.eye(n)])
b_ub = np.zeros(n)
if target_return is not None:
A_ub = np.vstack([A_ub, np.concatenate([-R.mean(axis=0), [0.0], np.zeros(n)])])
b_ub = np.append(b_ub, -target_return)
A_eq = np.concatenate([np.ones(d), [0.0], np.zeros(n)])[None, :]
wb = (0.0, 1.0) if long_only else (-1.0, 2.0)
res = optimize.linprog(c, A_ub=A_ub, b_ub=b_ub, A_eq=A_eq, b_eq=[1.0], bounds=[wb] * d + [(None, None)] + [(0.0, None)] * n, method="highs")
if not res.success:
raise RuntimeError(f"CVaR linear program failed: {res.message}")
w = res.x[:d]
return CVaRResult(weights=w, var=float(res.x[d]), cvar=float(res.fun), expected_return=float(R.mean(axis=0) @ w), alpha=a)
[docs]
def diversification_ratio(weights: ArrayLike, cov: ArrayLike) -> float:
"""Choueifaty and Coignard's ratio of weighted average volatility to portfolio volatility.
Parameters
----------
weights : array_like, shape (d,)
cov : array_like, shape (d, d)
Returns
-------
float
At least one for long-only weights; ``sqrt(d)`` for ``d``
uncorrelated equal-volatility assets held equally.
Examples
--------
>>> diversification_ratio([0.25] * 4, np.eye(4))
2.0
"""
_, C = as_mean_cov(None, cov)
w = np.asarray(weights, dtype=np.float64)
return float(w @ np.sqrt(np.diag(C)) / np.sqrt(w @ C @ w))
[docs]
def most_diversified_portfolio(cov: ArrayLike, mu: Optional[ArrayLike] = None, long_only: bool = True) -> PortfolioResult:
"""The portfolio with the largest diversification ratio.
Maximizing :math:`D(w)` is the minimum-variance problem on the
correlation matrix followed by dividing each weight by the asset's
volatility and renormalizing (Choueifaty and Coignard 2008); the
unconstrained answer is :math:`w \\propto \\Sigma^{-1}\\sigma`.
Parameters
----------
cov : array_like, shape (d, d)
mu : array_like, shape (d,), optional
Used only to report the mean.
long_only : bool
Returns
-------
PortfolioResult
Examples
--------
Uncorrelated assets: weights proportional to inverse volatility.
>>> np.round(most_diversified_portfolio(np.diag([0.01, 0.04])).weights, 6)
array([0.666667, 0.333333])
"""
m, C = as_mean_cov(mu, cov)
s = np.sqrt(np.diag(C))
y = min_variance_portfolio(C / np.outer(s, s), long_only=long_only).weights / s
w = y / y.sum()
mean, vol = portfolio_moments(w, m, C)
return PortfolioResult(weights=w, expected_return=mean, volatility=vol, method="most_diversified")
[docs]
def inverse_volatility_portfolio(cov: ArrayLike, mu: Optional[ArrayLike] = None) -> PortfolioResult:
"""Weights proportional to :math:`1/\\sigma_i`, the naive risk parity rule.
Parameters
----------
cov : array_like, shape (d, d)
mu : array_like, shape (d,), optional
Returns
-------
PortfolioResult
Examples
--------
>>> np.round(inverse_volatility_portfolio(np.diag([0.01, 0.04])).weights, 6)
array([0.666667, 0.333333])
"""
m, C = as_mean_cov(mu, cov)
y = 1.0 / np.sqrt(np.diag(C))
w = y / y.sum()
mean, vol = portfolio_moments(w, m, C)
return PortfolioResult(weights=w, expected_return=mean, volatility=vol, method="inverse_volatility")
[docs]
def risk_parity(cov: ArrayLike, budgets: Optional[ArrayLike] = None, mu: Optional[ArrayLike] = None) -> PortfolioResult:
"""Equal (or budgeted) risk contribution portfolio.
Solves Spinu's strictly convex program
:math:`\\min_{y > 0} \\tfrac12 y'\\Sigma y - \\sum_i b_i \\log y_i`,
whose first-order condition :math:`y_i(\\Sigma y)_i = b_i` makes each
risk contribution proportional to its budget, by his damped Newton
iteration (hand-written: SciPy's trust-region methods stall on this
problem at rounding level), then normalizes :math:`w = y / \\mathbf 1'y`.
Parameters
----------
cov : array_like, shape (d, d)
budgets : array_like, shape (d,), optional
Positive risk budgets; equal by default.
mu : array_like, shape (d,), optional
Returns
-------
PortfolioResult
Examples
--------
With two assets, risk parity is inverse volatility whatever the correlation.
>>> cov = np.array([[0.04, 0.018], [0.018, 0.09]])
>>> np.round(risk_parity(cov).weights, 6)
array([0.6, 0.4])
"""
m, C = as_mean_cov(mu, cov)
d = C.shape[0]
b = np.full(d, 1.0 / d) if budgets is None else np.asarray(budgets, dtype=np.float64)
if np.any(b <= 0):
raise ValueError("risk budgets must be positive")
b = b / b.sum()
# Spinu's damped Newton method: the objective is self-concordant, so the
# damped step 1 / (1 + lambda) keeps y positive and converges globally,
# and the full Newton step converges quadratically once the Newton
# decrement lambda is below (3 - sqrt 5) / 2.
Cs = C / np.mean(np.diag(C))
y = 1.0 / np.sqrt(np.diag(Cs))
y /= np.sqrt(y @ Cs @ y)
for _ in range(DEFAULT_MAX_ITER):
g = Cs @ y - b / y
step = np.linalg.solve(Cs + np.diag(b / y**2), g)
lam = float(np.sqrt(g @ step))
y = y - (step / (1.0 + lam) if lam > 0.95 * (3.0 - np.sqrt(5.0)) / 2.0 else step)
if lam < DEFAULT_ATOL:
break
else:
raise RuntimeError("risk parity did not converge")
w = y / y.sum()
mean, vol = portfolio_moments(w, m, C)
return PortfolioResult(weights=w, expected_return=mean, volatility=vol, method="risk_parity")
[docs]
def hierarchical_risk_parity(cov: ArrayLike, method: str = "single") -> HRPResult:
"""Lopez de Prado's hierarchical risk parity.
1. Cluster the assets with :func:`scipy.cluster.hierarchy.linkage` on
the distance :math:`\\sqrt{(1 - \\rho_{ij})/2}`.
2. Reorder them by the dendrogram's leaves (quasi-diagonalization).
3. Bisect the ordered list recursively, splitting each parent's weight
between its halves in inverse proportion to the variance of each
half's inverse-variance portfolio.
Parameters
----------
cov : array_like, shape (d, d)
method : str
Linkage method (``"single"`` in the original paper).
Returns
-------
HRPResult
Examples
--------
With a diagonal covariance, HRP reduces to inverse-variance weights.
>>> res = hierarchical_risk_parity(np.diag([0.01, 0.04, 0.04, 0.16]))
>>> np.round(res.weights * 25, 6)
array([16., 4., 4., 1.])
"""
_, C = as_mean_cov(None, cov)
d = C.shape[0]
s = np.sqrt(np.diag(C))
rho = np.clip(C / np.outer(s, s), -1.0, 1.0)
dist = np.sqrt(np.clip((1.0 - rho) / 2.0, 0.0, None))
np.fill_diagonal(dist, 0.0)
link = hierarchy.linkage(squareform(dist, checks=False), method=method)
order = hierarchy.leaves_list(link)
def cluster_var(idx: np.ndarray) -> float:
sub = C[np.ix_(idx, idx)]
ivp = 1.0 / np.diag(sub)
ivp /= ivp.sum()
return float(ivp @ sub @ ivp)
w = np.ones(d)
stack = [order]
while stack:
items = stack.pop()
if items.size < 2:
continue
left, right = items[: items.size // 2], items[items.size // 2 :]
vl, vr = cluster_var(left), cluster_var(right)
a = 1.0 - vl / (vl + vr)
w[left] *= a
w[right] *= 1.0 - a
stack += [left, right]
return HRPResult(weights=w, order=order, linkage=link)