"""Module that includes all Measures functions used across `skfolio`."""
# Copyright (c) 2023-2026
# Author: Hugo Delatte <hugo.delatte@skfoliolabs.com>
# SPDX-License-Identifier: BSD-3-Clause
# Gini mean difference and OWA GMD weights features are derived
# from Riskfolio-Lib, Copyright (c) 2020-2023, Dany Cajas, Licensed under BSD 3 clause.
from __future__ import annotations
import warnings
import numpy as np
import scipy.optimize as sco
import scipy.special as scs
import scipy.stats as st
from skfolio.typing import ArrayLike, FloatArray
from skfolio.utils.stats import safe_divide
from skfolio.utils.tools import (
_normalize_sample_weight,
_validate_non_negative_real,
_validate_positive_real,
_validate_sample_weight,
)
[docs]
def mean(
returns: ArrayLike, sample_weight: FloatArray | None = None
) -> float | FloatArray:
"""Compute the mean.
Parameters
----------
returns : ndarray of shape (n_observations,) or (n_observations, n_assets)
Array of return values.
sample_weight : ndarray of shape (n_observations,), optional
Relative observation weights. Must be finite and nonnegative. If None,
equal weights are assumed.
Returns
-------
value : float or ndarray of shape (n_assets,)
The computed mean.
If `returns` is a 1D-array, the result is a float.
If `returns` is a 2D-array, the result is a ndarray of shape (n_assets,).
Notes
-----
NaN handling:
NaN returns are excluded from each column's calculation. Remaining sample
weights are rescaled to sum to one. The result is NaN if no observations
or no positive weight remain.
"""
returns = np.asarray(returns, dtype=float)
if sample_weight is None:
return _mean(returns)
n_observations = returns.shape[0]
sample_weight = _validate_sample_weight(
sample_weight, n_observations=n_observations
)
if n_observations == 0:
return np.full(returns.shape[1:], np.nan)[()]
result = _normalize_sample_weight(sample_weight) @ returns
if not np.isnan(result).any():
return result
returns, sample_weight = _prepare_weighted_returns(
returns, sample_weight=sample_weight
)
return _mean(returns, sample_weight=sample_weight)
[docs]
def mean_absolute_deviation(
returns: ArrayLike,
min_acceptable_return: float | FloatArray | None = None,
sample_weight: FloatArray | None = None,
) -> float | FloatArray:
"""Compute the mean absolute deviation (MAD).
Parameters
----------
returns : ndarray of shape (n_observations,) or (n_observations, n_assets)
Array of return values.
min_acceptable_return : float or ndarray of shape (n_assets,) optional
Minimum acceptable return. It is the return target to distinguish "downside" and
"upside" returns. The default (`None`) is to use the returns' mean.
sample_weight : ndarray of shape (n_observations,), optional
Relative observation weights. Must be finite and nonnegative. If None,
equal weights are assumed.
Returns
-------
value : float or ndarray of shape (n_assets,)
Mean absolute deviation.
If `returns` is a 1D-array, the result is a float.
If `returns` is a 2D-array, the result is a ndarray of shape (n_assets,).
Notes
-----
NaN handling:
NaN returns are excluded from each column's calculation. Remaining sample
weights are rescaled to sum to one. The result is NaN if no observations
or no positive weight remain.
"""
deviations, sample_weight, _ = _prepare_deviations(
returns, sample_weight=sample_weight, target=min_acceptable_return
)
return _mean(np.abs(deviations), sample_weight=sample_weight)
[docs]
def first_lower_partial_moment(
returns: ArrayLike,
min_acceptable_return: float | FloatArray | None = None,
sample_weight: FloatArray | None = None,
) -> float | FloatArray:
"""Compute the first lower partial moment.
The first lower partial moment is the mean of the returns below a minimum
acceptable return.
Parameters
----------
returns : ndarray of shape (n_observations,) or (n_observations, n_assets)
Array of return values.
min_acceptable_return : float or ndarray of shape (n_assets,) optional
Minimum acceptable return. It is the return target to distinguish "downside" and
"upside" returns. The default (`None`) is to use the returns' mean.
sample_weight : ndarray of shape (n_observations,), optional
Relative observation weights. Must be finite and nonnegative. If None,
equal weights are assumed.
Returns
-------
value : float or ndarray of shape (n_assets,)
First lower partial moment.
If `returns` is a 1D-array, the result is a float.
If `returns` is a 2D-array, the result is a ndarray of shape (n_assets,).
Notes
-----
NaN handling:
NaN returns are excluded from each column's calculation. Remaining sample
weights are rescaled to sum to one. The result is NaN if no observations
or no positive weight remain.
"""
deviations, sample_weight, _ = _prepare_deviations(
returns, sample_weight=sample_weight, target=min_acceptable_return
)
return _mean(np.maximum(-deviations, 0), sample_weight=sample_weight)
[docs]
def variance(
returns: ArrayLike,
biased: bool = False,
sample_weight: FloatArray | None = None,
) -> float | FloatArray:
"""Compute the variance (second moment).
Parameters
----------
returns : ndarray of shape (n_observations,) or (n_observations, n_assets)
Array of return values.
biased : bool, default=False
If False (default), computes the sample variance (unbiased); otherwise,
computes the population variance (biased).
sample_weight : ndarray of shape (n_observations,), optional
Relative observation weights. Must be finite and nonnegative. If None,
equal weights are assumed.
Returns
-------
value : float or ndarray of shape (n_assets,)
Variance.
If `returns` is a 1D-array, the result is a float.
If `returns` is a 2D-array, the result is a ndarray of shape (n_assets,).
Notes
-----
NaN handling:
NaN returns are excluded from each column's calculation. Remaining sample
weights are rescaled to sum to one. The result is NaN if no observations
or no positive weight remain.
"""
returns = np.asarray(returns)
if sample_weight is None:
# Ignore NaNs and suppress warnings for all-NaN slices
with warnings.catch_warnings():
warnings.simplefilter("ignore", category=RuntimeWarning)
return np.nanvar(returns, ddof=0 if biased else 1, axis=0)
return _weighted_variance(returns, sample_weight=sample_weight, biased=biased)
[docs]
def semi_variance(
returns: ArrayLike,
min_acceptable_return: float | FloatArray | None = None,
sample_weight: FloatArray | None = None,
biased: bool = False,
) -> float | FloatArray:
"""Compute the semi-variance (second lower partial moment).
The semi-variance is the variance of the returns below a minimum acceptable return.
Parameters
----------
returns : ndarray of shape (n_observations,) or (n_observations, n_assets)
Array of return values.
min_acceptable_return : float or ndarray of shape (n_assets,) optional
Minimum acceptable return. It is the return target to distinguish "downside" and
"upside" returns. The default (`None`) is to use the returns' mean.
biased : bool, default=False
If False (default), computes the sample semi-variance (unbiased); otherwise,
computes the population semi-variance (biased).
sample_weight : ndarray of shape (n_observations,), optional
Relative observation weights. Must be finite and nonnegative. If None,
equal weights are assumed.
Returns
-------
value : float or ndarray of shape (n_assets,)
Semi-variance.
If `returns` is a 1D-array, the result is a float.
If `returns` is a 2D-array, the result is a ndarray of shape (n_assets,).
Notes
-----
NaN handling:
NaN returns are excluded from each column's calculation. Remaining sample
weights are rescaled to sum to one. The result is NaN if no observations
or no positive weight remain.
"""
returns = np.asarray(returns, dtype=float)
if sample_weight is not None:
return _weighted_variance(
returns,
sample_weight=sample_weight,
biased=biased,
min_acceptable_return=min_acceptable_return,
downside=True,
)
if min_acceptable_return is None:
min_acceptable_return = mean(returns, sample_weight=sample_weight)
biased_semi_var = mean(
np.maximum(0, min_acceptable_return - returns) ** 2, sample_weight=sample_weight
)
if biased:
return biased_semi_var
# Apply the Bessel correction using each column's non-NaN count.
n_observations = np.count_nonzero(~np.isnan(returns), axis=0)
correction = safe_divide(n_observations, n_observations - 1, fill_value=np.nan)
return biased_semi_var * correction
[docs]
def standard_deviation(
returns: ArrayLike,
sample_weight: FloatArray | None = None,
biased: bool = False,
) -> float | FloatArray:
"""Compute the standard-deviation (square root of the second moment).
Parameters
----------
returns : ndarray of shape (n_observations,) or (n_observations, n_assets)
Array of return values.
biased : bool, default=False
If False (default), computes the sample standard-deviation (unbiased);
otherwise, computes the population standard-deviation (biased).
sample_weight : ndarray of shape (n_observations,), optional
Relative observation weights. Must be finite and nonnegative. If None,
equal weights are assumed.
Returns
-------
value : float or ndarray of shape (n_assets,)
Standard-deviation.
If `returns` is a 1D-array, the result is a float.
If `returns` is a 2D-array, the result is a ndarray of shape (n_assets,).
Notes
-----
NaN handling:
NaN returns are excluded from each column's calculation. Remaining sample
weights are rescaled to sum to one. The result is NaN if no observations
or no positive weight remain.
"""
return np.sqrt(variance(returns, sample_weight=sample_weight, biased=biased))
[docs]
def semi_deviation(
returns: ArrayLike,
min_acceptable_return: float | FloatArray | None = None,
sample_weight: FloatArray | None = None,
biased: bool = False,
) -> float | FloatArray:
"""Compute the semi-deviation (square root of the second lower partial moment).
Parameters
----------
returns : ndarray of shape (n_observations,) or (n_observations, n_assets)
Array of return values.
min_acceptable_return : float or ndarray of shape (n_assets,) optional
Minimum acceptable return. It is the return target to distinguish "downside" and
"upside" returns. The default (`None`) is to use the returns' mean.
biased : bool, default=False
If False (default), computes the sample semi-deviation (unbiased); otherwise,
computes the population semi-seviation (biased).
sample_weight : ndarray of shape (n_observations,), optional
Relative observation weights. Must be finite and nonnegative. If None,
equal weights are assumed.
Returns
-------
value : float or ndarray of shape (n_assets,)
Semi-deviation.
If `returns` is a 1D-array, the result is a float.
If `returns` is a 2D-array, the result is a ndarray of shape (n_assets,).
Notes
-----
NaN handling:
NaN returns are excluded from each column's calculation. Remaining sample
weights are rescaled to sum to one. The result is NaN if no observations
or no positive weight remain.
"""
return np.sqrt(
semi_variance(
returns,
min_acceptable_return=min_acceptable_return,
biased=biased,
sample_weight=sample_weight,
)
)
[docs]
def third_central_moment(
returns: ArrayLike, sample_weight: FloatArray | None = None
) -> float | FloatArray:
"""Compute the third central moment.
Parameters
----------
returns : ndarray of shape (n_observations,) or (n_observations, n_assets)
Array of return values.
sample_weight : ndarray of shape (n_observations,), optional
Relative observation weights. Must be finite and nonnegative. If None,
equal weights are assumed.
Returns
-------
value : float or ndarray of shape (n_assets,)
Third central moment.
If `returns` is a 1D-array, the result is a float.
If `returns` is a 2D-array, the result is a ndarray of shape (n_assets,).
Notes
-----
NaN handling:
NaN returns are excluded from each column's calculation. Remaining sample
weights are rescaled to sum to one. The result is NaN if no observations
or no positive weight remain.
"""
deviations, sample_weight, _ = _prepare_deviations(
returns, sample_weight=sample_weight
)
return _mean(deviations**3, sample_weight=sample_weight)
[docs]
def skew(
returns: ArrayLike, sample_weight: FloatArray | None = None
) -> float | FloatArray:
"""Compute the Skew.
The Skew is a measure of the lopsidedness of the distribution.
A symmetric distribution have a Skew of zero.
Higher Skew corresponds to longer right tail.
Parameters
----------
returns : ndarray of shape (n_observations,) or (n_observations, n_assets)
Array of return values.
sample_weight : ndarray of shape (n_observations,), optional
Relative observation weights. Must be finite and nonnegative. If None,
equal weights are assumed.
Returns
-------
value : float or ndarray of shape (n_assets,)
Skew.
If `returns` is a 1D-array, the result is a float.
If `returns` is a 2D-array, the result is a ndarray of shape (n_assets,).
Notes
-----
NaN handling:
NaN returns are excluded from each column's calculation. Remaining sample
weights are rescaled to sum to one. The result is NaN if no observations
or no positive weight remain.
Nearly constant returns produce NaN when the second central moment is at
most `(eps * mean)**2`. Here `eps` is machine precision and `mean` uses the
same sample weights.
"""
return _standardized_moment(returns, order=3, sample_weight=sample_weight)
[docs]
def fourth_central_moment(
returns: ArrayLike, sample_weight: FloatArray | None = None
) -> float | FloatArray:
"""Compute the Fourth central moment.
Parameters
----------
returns : ndarray of shape (n_observations,) or (n_observations, n_assets)
Array of return values.
sample_weight : ndarray of shape (n_observations,), optional
Relative observation weights. Must be finite and nonnegative. If None,
equal weights are assumed.
Returns
-------
value : float or ndarray of shape (n_assets,)
Fourth central moment.
If `returns` is a 1D-array, the result is a float.
If `returns` is a 2D-array, the result is a ndarray of shape (n_assets,).
Notes
-----
NaN handling:
NaN returns are excluded from each column's calculation. Remaining sample
weights are rescaled to sum to one. The result is NaN if no observations
or no positive weight remain.
"""
deviations, sample_weight, _ = _prepare_deviations(
returns, sample_weight=sample_weight
)
return _mean(deviations**4, sample_weight=sample_weight)
[docs]
def kurtosis(
returns: ArrayLike, sample_weight: FloatArray | None = None
) -> float | FloatArray:
"""Compute the Kurtosis.
The Kurtosis is a measure of the heaviness of the tail of the distribution.
Higher Kurtosis corresponds to greater extremity of deviations (fat tails).
Parameters
----------
returns : ndarray of shape (n_observations,) or (n_observations, n_assets)
Array of return values.
sample_weight : ndarray of shape (n_observations,), optional
Relative observation weights. Must be finite and nonnegative. If None,
equal weights are assumed.
Returns
-------
value : float or ndarray of shape (n_assets,)
Kurtosis.
If `returns` is a 1D-array, the result is a float.
If `returns` is a 2D-array, the result is a ndarray of shape (n_assets,).
Notes
-----
NaN handling:
NaN returns are excluded from each column's calculation. Remaining sample
weights are rescaled to sum to one. The result is NaN if no observations
or no positive weight remain.
Nearly constant returns produce NaN when the second central moment is at
most `(eps * mean)**2`. Here `eps` is machine precision and `mean` uses the
same sample weights.
"""
return _standardized_moment(returns, order=4, sample_weight=sample_weight)
[docs]
def fourth_lower_partial_moment(
returns: ArrayLike,
min_acceptable_return: float | FloatArray | None = None,
sample_weight: FloatArray | None = None,
) -> float | FloatArray:
"""Compute the fourth lower partial moment.
The Fourth Lower Partial Moment is a measure of the heaviness of the downside tail
of the returns below a minimum acceptable return.
Higher Fourth Lower Partial Moment corresponds to greater extremity of downside
deviations (downside fat tail).
Parameters
----------
returns : ndarray of shape (n_observations,) or (n_observations, n_assets)
Array of return values.
min_acceptable_return : float or ndarray of shape (n_assets,), optional
Minimum acceptable return. It is the return target to distinguish "downside" and
"upside" returns.
The default (`None`) is to use the returns mean.
sample_weight : ndarray of shape (n_observations,), optional
Relative observation weights. Must be finite and nonnegative. If None,
equal weights are assumed.
Returns
-------
value : float or ndarray of shape (n_assets,)
Fourth lower partial moment.
If `returns` is a 1D-array, the result is a float.
If `returns` is a 2D-array, the result is a ndarray of shape (n_assets,).
Notes
-----
NaN handling:
NaN returns are excluded from each column's calculation. Remaining sample
weights are rescaled to sum to one. The result is NaN if no observations
or no positive weight remain.
"""
deviations, sample_weight, _ = _prepare_deviations(
returns, sample_weight=sample_weight, target=min_acceptable_return
)
return _mean(np.minimum(deviations, 0) ** 4, sample_weight=sample_weight)
[docs]
def worst_realization(returns: ArrayLike) -> float | FloatArray:
"""Compute the worst realization (worst return).
Parameters
----------
returns : ndarray of shape (n_observations,) or (n_observations, n_assets)
Array of return values.
Returns
-------
value : float or ndarray of shape (n_assets,)
Worst realization.
If `returns` is a 1D-array, the result is a float.
If `returns` is a 2D-array, the result is a ndarray of shape (n_assets,).
Notes
-----
NaN handling:
NaN returns are excluded from each column's calculation. The result is
NaN if no observations remain.
"""
returns = np.asarray(returns)
with warnings.catch_warnings():
# all-NaN slice warning
warnings.simplefilter("ignore", category=RuntimeWarning)
return -np.nanmin(returns, axis=0)
[docs]
def value_at_risk(
returns: ArrayLike, beta: float = 0.95, sample_weight: FloatArray | None = None
) -> float | FloatArray:
r"""Compute the value at risk (VaR).
The VaR is the smallest loss exceeded with probability at most
:math:`1 - \beta`. It is the lower :math:`\beta`-quantile of the empirical loss
distribution :math:`F_L`:
.. math:: \mathrm{VaR}_{\beta} = \inf\{\ell \in \mathbb{R} : F_L(\ell) \geq \beta\}
With `sample_weight`, :math:`F_L` is the weighted empirical distribution
function.
Parameters
----------
returns : ndarray of shape (n_observations,) or (n_observations, n_assets)
Array of return values.
beta : float, default=0.95
The VaR confidence level. Must be between 0 and 1. The endpoints select the
best and worst usable returns, respectively; zero-weight observations are
excluded.
sample_weight : ndarray of shape (n_observations,), optional
Relative observation weights. Must be finite and nonnegative. If None,
equal weights are assumed.
Returns
-------
value : float or ndarray of shape (n_assets,)
Value at Risk.
If `returns` is a 1D-array, the result is a float.
If `returns` is a 2D-array, the result is a ndarray of shape (n_assets,).
Notes
-----
With :math:`n` equally weighted observations and an integer
:math:`k = (1 - \beta) n`, the VaR is the :math:`(k+1)`-th largest loss and the
CVaR is the mean of the :math:`k` largest losses. For example, with 100
observations and `beta=0.95`, the VaR is the sixth largest loss.
At a boundary between two loss values, VaR selects the lower loss. Rounding
differences in the confidence level and in probabilities are treated as ties
at that boundary.
NaN handling:
NaN returns are excluded from each column's calculation. Remaining sample
weights are rescaled to sum to one. The result is NaN if no observations
or no positive weight remain.
References
----------
.. [1] "Conditional value-at-risk for general loss distributions",
Journal of Banking & Finance, Rockafellar & Uryasev (2002)
.. [2] "Quantitative Risk Management: Concepts, Techniques and Tools",
Princeton University Press, McNeil, Frey & Embrechts (2015)
"""
return _tail_risk(
returns, beta=beta, sample_weight=sample_weight, conditional=False
)
[docs]
def cvar(
returns: ArrayLike, beta: float = 0.95, sample_weight: FloatArray | None = None
) -> float | FloatArray:
"""Compute the CVaR (conditional value at risk).
The CVaR (or Tail VaR) represents the mean shortfall at a specified confidence
level (beta).
Parameters
----------
returns : ndarray of shape (n_observations,) or (n_observations, n_assets)
Array of return values.
beta : float, default=0.95
Confidence level between 0 and 1, inclusive. CVaR averages the worst
`1 - beta` probability mass. At 0, CVaR is the negative mean
of the usable returns. At 1, it is the largest loss with positive weight.
sample_weight : ndarray of shape (n_observations,), optional
Relative observation weights. Must be finite and nonnegative. If None,
equal weights are assumed.
Returns
-------
value : float or ndarray of shape (n_assets,)
CVaR.
If `returns` is a 1D-array, the result is a float.
If `returns` is a 2D-array, the result is a ndarray of shape (n_assets,).
Notes
-----
NaN handling:
NaN returns are excluded from each column's calculation. Remaining sample
weights are rescaled to sum to one. The result is NaN if no observations
or no positive weight remain.
"""
return _tail_risk(returns, beta=beta, sample_weight=sample_weight, conditional=True)
[docs]
def entropic_risk_measure(
returns: ArrayLike,
theta: float = 1,
beta: float = 0.95,
sample_weight: FloatArray | None = None,
) -> float | FloatArray:
"""Compute the entropic risk measure.
The entropic risk measure is a risk measure which depends on the risk aversion
defined by the investor (theta) through the exponential utility function at a given
confidence level (beta).
Parameters
----------
returns : ndarray of shape (n_observations,) or (n_observations, n_assets)
Array of return values.
theta : float, default=1.0
Risk aversion.
beta : float, default=0.95
Confidence level.
sample_weight : ndarray of shape (n_observations,), optional
Relative observation weights. Must be finite and nonnegative. If None,
equal weights are assumed.
Returns
-------
value : float or ndarray of shape (n_assets,)
Entropic risk measure.
If `returns` is a 1D-array, the result is a float.
If `returns` is a 2D-array, the result is a ndarray of shape (n_assets,).
Notes
-----
NaN handling:
NaN returns are excluded from each column's calculation. Remaining sample
weights are rescaled to sum to one. The result is NaN if no observations
or no positive weight remain.
"""
returns = np.asarray(returns, dtype=float)
if sample_weight is None:
sample_weight = np.ones(returns.shape[0])
else:
sample_weight = _validate_sample_weight(
sample_weight, n_observations=returns.shape[0]
)
returns, sample_weight = _prepare_weighted_returns(
returns, sample_weight=sample_weight
)
if returns.shape[0] == 0:
return np.full(returns.shape[1:], np.nan)[()]
if returns.ndim == 2 and sample_weight.ndim == 1:
sample_weight = sample_weight[:, None]
log_sample_weight = np.log(
sample_weight,
out=np.full_like(sample_weight, -np.inf),
where=sample_weight != 0,
)
log_mean = scs.logsumexp(-returns / theta + log_sample_weight, axis=0)
return theta * (log_mean - np.log1p(-beta))
[docs]
def evar(
returns: ArrayLike, beta: float = 0.95, sample_weight: FloatArray | None = None
) -> float:
r"""Compute the EVaR (entropic value at risk).
The EVaR is a coherent risk measure which is an upper bound for the VaR and the
CVaR, obtained from the Chernoff inequality. The EVaR can be represented by using
the concept of relative entropy.
Parameters
----------
returns : ndarray of shape (n_observations,)
Vector of returns.
beta : float, default=0.95
The EVaR confidence level. Must be between 0 and 1.
sample_weight : ndarray of shape (n_observations,), optional
Relative observation weights. Must be finite and nonnegative. If None,
equal weights are assumed.
Returns
-------
value : float
EVaR.
Notes
-----
The EVaR of the returns :math:`X` at confidence level :math:`\beta` is
.. math::
\text{EVaR}_{\beta}(X) = \inf_{\theta > 0} \theta \log \left(
\frac{\mathbb{E}\left[e^{-X / \theta}\right]}{1 - \beta} \right)
It lies between the CVaR and the largest loss with positive weight. It equals
that largest loss when its total probability is at least :math:`1-\beta`,
and the weighted mean loss when `beta=0`. With equal weights, this condition
is :math:`n (1-\beta) \le k`, where :math:`k` observations share the largest loss.
NaN handling:
NaN returns and their matching weights are excluded. The remaining weights
are normalized. The result is NaN if no positive weight remains.
References
----------
.. [1] "Entropic Value-at-Risk: A New Coherent Risk Measure",
Journal of Optimization Theory and Applications, Ahmadi-Javid (2012)
"""
returns = np.asarray(returns, dtype=float)
valid = ~np.isnan(returns)
log_probabilities = None
if sample_weight is not None:
sample_weight = _validate_sample_weight(
sample_weight, n_observations=returns.shape[0]
)
valid &= sample_weight > 0
sample_weight = sample_weight[valid]
# Equal weights use the unweighted calculation. For unequal weights,
# normalize in log space so tiny positive probabilities do not become zero.
if sample_weight.size and not np.all(sample_weight == sample_weight[0]):
log_probabilities = np.log(sample_weight)
log_probabilities -= scs.logsumexp(log_probabilities)
losses = -returns[valid]
if losses.size == 0:
return np.nan
max_loss = losses.max()
spread = max_loss - losses.min()
if beta == 0:
value = (
losses.mean()
if sample_weight is None
else _normalize_sample_weight(sample_weight) @ losses
)
elif beta == 1 or spread == 0:
value = max_loss
else:
# The EVaR is translation equivariant and positively homogeneous.
scaled_losses = (losses - max_loss) / spread
if log_probabilities is None:
scaled_evar = _unweighted_evar(scaled_losses, beta)
else:
scaled_evar = _weighted_evar(scaled_losses, beta, log_probabilities)
value = max_loss + spread * scaled_evar
# Adding 0.0 maps a negative zero to 0.0.
return value + 0.0
[docs]
def get_cumulative_returns(
returns: ArrayLike, compounded: bool = False, base: float = 1.0
) -> FloatArray:
"""Compute the cumulative returns from a series of returns.
Parameters
----------
returns : ndarray of shape (n_observations,) or (n_observations, n_assets)
Array of return values.
compounded : bool, default=False
If True, compute compounded (geometric) cumulative returns as a wealth index
starting at `base`. If False, compute non-compounded (arithmetic) cumulative
returns starting at 0. Default is False.
base : float, default=1.0
Starting value for compounded cumulative returns, expressed as a wealth index.
For example, use 1.0 for a "wealth index" representing $1 invested, or 100.0
for index-style rebasing.
Returns
-------
values: ndarray of shape (n_observations,) or (n_observations, n_assets)
Cumulative returns.
Notes
-----
NaN handling:
Missing values (NaNs) remain at their original locations in the output and are
treated as neutral elements during accumulation, so they do not propagate to
subsequent values.
"""
returns = np.asarray(returns, dtype=float)
if np.isnan(returns).all():
return np.full(returns.shape, np.nan, dtype=float)
if np.isnan(returns).any():
mask = np.isnan(returns)
returns_clean = np.nan_to_num(returns, nan=0.0)
else:
mask = None
returns_clean = returns
if compounded:
cumulative_returns = base * np.cumprod(1 + returns_clean, axis=0)
else:
cumulative_returns = np.cumsum(returns_clean, axis=0)
if mask is not None:
cumulative_returns[mask] = np.nan
return cumulative_returns
[docs]
def get_drawdowns(returns: ArrayLike, compounded: bool = False) -> FloatArray:
"""Compute the drawdowns' series from the returns.
Parameters
----------
returns : ndarray of shape (n_observations,) or (n_observations, n_assets)
Array of return values.
compounded : bool, default=False
If this is set to True, the cumulative returns are compounded otherwise they
are uncompounded.
Returns
-------
values: ndarray of shape (n_observations,) or (n_observations, n_assets)
Drawdowns.
Notes
-----
The running peak starts at the initial wealth (0 for uncompounded and 1 for
compounded cumulative returns), so a loss on the first observation is a drawdown.
NaN handling:
Missing values (NaNs) remain at their original locations in the output and are
treated as neutral elements during accumulation, so they do not propagate to
subsequent values.
"""
returns = np.asarray(returns)
if np.isnan(returns).all():
return np.full(returns.shape, np.nan, dtype=float)
cumulative_returns = get_cumulative_returns(returns=returns, compounded=compounded)
if np.isnan(cumulative_returns).any():
mask = np.isnan(cumulative_returns)
cum_clean = np.nan_to_num(cumulative_returns, nan=-np.inf)
else:
mask = None
cum_clean = cumulative_returns
# The starting wealth (0 uncompounded, 1.0 compounded) is the first peak, so a loss
# on the first observation is a drawdown. It also replaces the -Inf left by leading
# NaNs.
peak = np.maximum.accumulate(cum_clean, axis=0)
np.maximum(peak, 1.0 if compounded else 0.0, out=peak)
if compounded:
drawdowns = cum_clean / peak - 1
else:
drawdowns = cum_clean - peak
if mask is not None:
drawdowns[mask] = np.nan
return drawdowns
[docs]
def drawdown_at_risk(
drawdowns: FloatArray, beta: float = 0.95, sample_weight: FloatArray | None = None
) -> float | FloatArray:
r"""Compute the Drawdown at risk.
The Drawdown at risk (DaR) is the smallest drawdown exceeded with probability at
most :math:`1 - \beta`. It is the value at risk of the drawdowns, see
:func:`value_at_risk`.
Parameters
----------
drawdowns : ndarray of shape (n_observations,) or (n_observations, n_assets)
Nonpositive drawdowns computed from the full return path, including
dates with zero sample weight.
beta : float, default = 0.95
The DaR confidence level, between 0 and 1, inclusive.
sample_weight : ndarray of shape (n_observations,), optional
Relative observation weights. Must be finite and nonnegative. If None,
equal weights are assumed. NaN drawdowns and their weights are
excluded, and the remaining weights are rescaled to sum to one.
The result is NaN if no observations or no positive weight remain.
Returns
-------
value : float or ndarray of shape (n_assets,)
Drawdown at risk.
If `drawdowns` is a 1D-array, the result is a float.
If `drawdowns` is a 2D-array, the result is a ndarray of shape (n_assets,).
"""
return value_at_risk(returns=drawdowns, beta=beta, sample_weight=sample_weight)
[docs]
def max_drawdown(drawdowns: FloatArray) -> float | FloatArray:
"""Compute the maximum drawdown.
Parameters
----------
drawdowns : ndarray of shape (n_observations,) or (n_observations, n_assets)
Vector of drawdowns.
Returns
-------
value : float or ndarray of shape (n_assets,)
Maximum drawdown.
If `returns` is a 1D-array, the result is a float.
If `returns` is a 2D-array, the result is a ndarray of shape (n_assets,).
"""
return drawdown_at_risk(drawdowns=drawdowns, beta=1)
[docs]
def average_drawdown(
drawdowns: FloatArray, sample_weight: FloatArray | None = None
) -> float | FloatArray:
"""Compute the average drawdown.
Parameters
----------
drawdowns : ndarray of shape (n_observations,) or (n_observations, n_assets)
Nonpositive drawdowns computed from the full return path, including
dates with zero sample weight.
sample_weight : ndarray of shape (n_observations,), optional
Relative observation weights. Must be finite and nonnegative. If None,
equal weights are assumed. NaN drawdowns and their weights are
excluded, and the remaining weights are rescaled to sum to one.
The result is NaN if no observations or no positive weight remain.
Returns
-------
value : float or ndarray of shape (n_assets,)
Average drawdown.
If `drawdowns` is a 1D-array, the result is a float.
If `drawdowns` is a 2D-array, the result is a ndarray of shape (n_assets,).
"""
return -mean(drawdowns, sample_weight=sample_weight)
[docs]
def cdar(
drawdowns: FloatArray, beta: float = 0.95, sample_weight: FloatArray | None = None
) -> float | FloatArray:
"""Compute the CDaR (conditional drawdown at risk).
Parameters
----------
drawdowns : ndarray of shape (n_observations,) or (n_observations, n_assets)
Nonpositive drawdowns computed from the full return path, including
dates with zero sample weight.
beta : float, default = 0.95
The CDaR confidence level, between 0 and 1, inclusive.
The result averages the worst drawdowns over probability `1 - beta`.
At 1, it is the largest drawdown magnitude with positive weight.
sample_weight : ndarray of shape (n_observations,), optional
Relative observation weights. Must be finite and nonnegative. If None,
equal weights are assumed. NaN drawdowns and their weights are
excluded, and the remaining weights are rescaled to sum to one.
The result is NaN if no observations or no positive weight remain.
Returns
-------
value : float or ndarray of shape (n_assets,)
CDaR.
If `drawdowns` is a 1D-array, the result is a float.
If `drawdowns` is a 2D-array, the result is a ndarray of shape (n_assets,).
"""
return cvar(returns=drawdowns, beta=beta, sample_weight=sample_weight)
[docs]
def edar(
drawdowns: FloatArray, beta: float = 0.95, sample_weight: FloatArray | None = None
) -> float:
"""Compute the EDaR (entropic drawdown at risk).
The EDaR is a coherent risk measure which is an upper bound for the DaR and the
CDaR, obtained from the Chernoff inequality. The EDaR can be represented by using
the concept of relative entropy.
Parameters
----------
drawdowns : ndarray of shape (n_observations,)
Nonpositive drawdowns computed from the full return path, including
dates with zero sample weight.
beta : float, default=0.95
The EDaR confidence level, between 0 and 1, inclusive.
sample_weight : ndarray of shape (n_observations,), optional
Relative observation weights. Must be finite and nonnegative. If None,
equal weights are assumed. NaN drawdowns and their weights are
excluded, and the remaining weights are rescaled to sum to one.
The result is NaN if no observations or no positive weight remain.
Returns
-------
value : float
EDaR.
"""
return evar(returns=drawdowns, beta=beta, sample_weight=sample_weight)
[docs]
def ulcer_index(
drawdowns: FloatArray, sample_weight: FloatArray | None = None
) -> float | FloatArray:
"""Compute the Ulcer index.
Parameters
----------
drawdowns : ndarray of shape (n_observations,) or (n_observations, n_assets)
Nonpositive drawdowns computed from the full return path, including
dates with zero sample weight.
sample_weight : ndarray of shape (n_observations,), optional
Relative observation weights. Must be finite and nonnegative. If None,
equal weights are assumed. NaN drawdowns and their weights are
excluded, and the remaining weights are rescaled to sum to one.
The result is NaN if no observations or no positive weight remain.
Returns
-------
value : float or ndarray of shape (n_assets,)
Ulcer Index.
If `drawdowns` is a 1D-array, the result is a float.
If `drawdowns` is a 2D-array, the result is a ndarray of shape (n_assets,).
"""
return np.sqrt(mean(np.square(drawdowns), sample_weight=sample_weight))
[docs]
def owa_gmd_weights(n_observations: int) -> FloatArray:
"""Compute the OWA weights used for the Gini mean difference (GMD) computation.
Parameters
----------
n_observations : int
Number of observations.
Returns
-------
value : float
OWA GMD weights.
"""
return (4 * np.arange(1, n_observations + 1) - 2 * (n_observations + 1)) / (
n_observations * (n_observations - 1)
)
[docs]
def gini_mean_difference(returns: ArrayLike) -> float | FloatArray:
"""Compute the Gini mean difference (GMD).
The GMD is the expected absolute difference between two realisations.
The GMD is a superior measure of variability for non-normal distribution than the
variance.
It can be used to form necessary conditions for second-degree stochastic dominance,
while the variance cannot.
Parameters
----------
returns : ndarray of shape (n_observations,) or (n_observations, n_assets)
Array of return values.
Returns
-------
value : float or ndarray of shape (n_assets,)
Gini mean difference.
If `returns` is a 1D-array, the result is a float.
If `returns` is a 2D-array, the result is a ndarray of shape (n_assets,).
"""
returns = np.asarray(returns, dtype=float)
# No NaNs
if not np.isnan(returns).any():
w = owa_gmd_weights(returns.shape[0])
return w @ np.sort(returns, axis=0)
# 1D with NaN
if returns.ndim == 1:
v = returns[~np.isnan(returns)]
if v.size == 0:
return np.nan
w = owa_gmd_weights(v.size)
return w @ np.sort(v)
# 2D with NaNs
n_assets = returns.shape[1]
out = np.full(n_assets, np.nan, dtype=float)
isnan = np.isnan(returns)
for j in range(n_assets):
col = returns[:, j]
v = col[~isnan[:, j]]
if v.size == 0:
continue # leave NaN
w = owa_gmd_weights(v.size)
out[j] = w @ np.sort(v)
return out
[docs]
def effective_number_assets(weights: FloatArray) -> float:
r"""Compute the effective number of assets, defined as the inverse of the
Herfindahl index.
.. math:: N_{eff} = \frac{1}{\Vert w \Vert_{2}^{2}}
It quantifies portfolio concentration, with a higher value indicating a more
diversified portfolio.
Parameters
----------
weights : ndarray of shape (n_assets,)
Weights of the assets.
Returns
-------
value : float
Effective number of assets.
References
----------
.. [1] "Banking and Financial Institutions Law in a Nutshell".
Lovett, William Anthony (1988)
"""
return 1.0 / (np.power(weights, 2).sum())
[docs]
def correlation(X: ArrayLike, sample_weight: FloatArray | None = None) -> FloatArray:
"""Compute the correlation matrix.
Parameters
----------
X : ndarray of shape (n_observations, n_assets)
Array of values.
sample_weight : ndarray of shape (n_observations,), optional
Relative observation weights. Must be finite and nonnegative. If None,
equal weights are assumed.
Returns
-------
corr : ndarray of shape (n_assets, n_assets)
The correlation matrix.
"""
X = np.asarray(X)
n_observations, n_assets = X.shape
if sample_weight is not None:
sample_weight = _validate_sample_weight(
sample_weight, n_observations=n_observations
)
if not sample_weight.any():
return np.full((n_assets, n_assets), np.nan)
sample_weight = _normalize_sample_weight(sample_weight)
cov = np.cov(X, rowvar=False, aweights=sample_weight)
std = np.sqrt(np.diag(cov))
return cov / np.outer(std, std)
[docs]
def expected_max_sharpe_ratio(
n_trials: float,
trial_sharpe_ratio_std: float,
trial_sharpe_ratio_mean: float = 0.0,
) -> float:
r"""Approximate the expected maximum Sharpe ratio across independent trials.
When a strategy is selected as the best of several trials, its Sharpe ratio is
the maximum of a sample and is therefore inflated by the selection itself.
The formula below approximates this expected maximum. It assumes that the
trial Sharpe ratios are independent and follow the same normal distribution:
.. math::
SR_0 = \mu + \sigma \left[(1 - \gamma) \, Z^{-1}\left(1 -
\frac{1}{N}\right) + \gamma \, Z^{-1}\left(1 - \frac{1}{N e}\right)\right]
with :math:`N` the number of trials, :math:`\mu` and :math:`\sigma` the mean
and standard deviation of the trial Sharpe ratios, :math:`\gamma` the
Euler-Mascheroni constant, :math:`e` Euler's number and :math:`Z^{-1}` the
inverse standard normal CDF.
The Deflated Sharpe Ratio uses this benchmark with :math:`\mu = 0`,
representing trials whose true Sharpe ratios are zero.
Parameters
----------
n_trials : float
Number of independent trials :math:`N`, which can be fractional when
estimated as an effective trial count. Must be finite and at least 1.
trial_sharpe_ratio_std : float
Standard deviation :math:`\sigma` of the trial Sharpe ratios, measuring
how much they vary from one trial to another. Must be finite and
non-negative.
trial_sharpe_ratio_mean : float, default=0.0
Mean :math:`\mu` of the Sharpe ratios across the trials.
Returns
-------
value : float
Approximate expected maximum Sharpe ratio, in the same per-period terms
as `trial_sharpe_ratio_std` and `trial_sharpe_ratio_mean`.
Raises
------
ValueError
If `n_trials` is not a finite real number greater than or equal to 1, or
`trial_sharpe_ratio_std` is not a finite, non-negative real number.
Notes
-----
With a single trial or without dispersion across trials, there is no selection
to correct for and `trial_sharpe_ratio_mean` is returned.
The calculation treats all `n_trials` trials as independent. Similar parameter
settings can produce correlated Sharpe ratios, so counting every setting as an
independent trial can overestimate the expected maximum. An estimated effective
number of independent trials can be passed as `n_trials`.
See Also
--------
probabilistic_sharpe_ratio
deflated_sharpe_ratio
References
----------
.. [1] "The Deflated Sharpe Ratio: Correcting for Selection Bias, Backtest
Overfitting, and Non-Normality".
Bailey, D. and Lopez de Prado, M. (2014)
"""
_validate_positive_real(n_trials, "n_trials")
if n_trials < 1:
raise ValueError(f"n_trials must be at least 1, got {n_trials!r}")
_validate_non_negative_real(trial_sharpe_ratio_std, "trial_sharpe_ratio_std")
if n_trials == 1 or trial_sharpe_ratio_std == 0:
return trial_sharpe_ratio_mean
return trial_sharpe_ratio_mean + trial_sharpe_ratio_std * (
(1 - np.euler_gamma) * st.norm.ppf(1 - 1 / n_trials)
+ np.euler_gamma * st.norm.ppf(1 - 1 / (n_trials * np.e))
)
[docs]
def probabilistic_sharpe_ratio(
returns: ArrayLike,
benchmark_sharpe_ratio: float = 0.0,
risk_free_rate: float = 0.0,
) -> float | FloatArray:
r"""Compute the Probabilistic Sharpe Ratio (PSR).
The PSR estimates the probability that the true Sharpe ratio of a return series
exceeds a benchmark, given the length of the series and the non-normality of
its returns:
.. math::
PSR(SR^*) = Z\left[\frac{\left(\hat{SR} - SR^*\right)\sqrt{T - 1}}
{\sqrt{1 - \gamma_3 \hat{SR} + \frac{\gamma_4 - 1}{4} \hat{SR}^2}}\right]
with :math:`\hat{SR}` the observed Sharpe ratio, :math:`SR^*` the benchmark,
:math:`T` the number of observations, :math:`\gamma_3` the skew,
:math:`\gamma_4` the kurtosis and :math:`Z` the standard normal CDF.
Parameters
----------
returns : ndarray of shape (n_observations,) or (n_observations, n_assets)
Array of return values.
benchmark_sharpe_ratio : float, default=0.0
Benchmark Sharpe ratio :math:`SR^*`.
risk_free_rate : float, default=0.0
Risk-free rate used to compute the excess mean of `returns`.
Returns
-------
value : float or ndarray of shape (n_assets,)
Estimated probability that the true Sharpe ratio exceeds
`benchmark_sharpe_ratio`.
If `returns` is a 1D-array, the result is a float.
If `returns` is a 2D-array, the result is a ndarray of shape (n_assets,).
Notes
-----
All quantities are per-period: `returns`, `risk_free_rate` and
`benchmark_sharpe_ratio` must share the same frequency, and none of them is
annualized.
NaN handling: NaNs are ignored. A series with fewer than three observations,
with no dispersion, or with a non-positive variance adjustment yields NaN.
See Also
--------
expected_max_sharpe_ratio
deflated_sharpe_ratio
References
----------
.. [1] "The Sharpe Ratio Efficient Frontier".
Bailey, D. and Lopez de Prado, M. (2012)
"""
returns = np.asarray(returns, dtype=float)
n_observations = np.sum(~np.isnan(returns), axis=0)
with np.errstate(divide="ignore", invalid="ignore"):
sr = safe_divide(
mean(returns) - risk_free_rate,
standard_deviation(returns),
fill_value=np.nan,
)
variance_adj = 1 - skew(returns) * sr + (kurtosis(returns) - 1) / 4 * sr**2
value = st.norm.cdf(
(sr - benchmark_sharpe_ratio)
* np.sqrt(n_observations - 1)
/ np.sqrt(variance_adj)
)
value = np.where(
(n_observations < 3) | np.logical_not(variance_adj > 0),
np.nan,
value,
)
return value if returns.ndim > 1 else float(value)
[docs]
def deflated_sharpe_ratio(
returns: ArrayLike,
trial_sharpe_ratios: ArrayLike,
risk_free_rate: float = 0.0,
) -> float | FloatArray:
r"""Compute the Deflated Sharpe Ratio (DSR).
The DSR estimates the probability that the true Sharpe ratio of a strategy
selected as the best of several trials is greater than zero, once the number and
dispersion of those trials and the non-normality of the selected returns are
accounted for. It is the Probabilistic Sharpe Ratio benchmarked against the
expected maximum Sharpe ratio of the search:
.. math::
DSR = PSR\left(SR_0\right)
with :math:`SR_0` computed by :func:`expected_max_sharpe_ratio` using
`trial_sharpe_ratio_mean=0.0`. The selected strategy meets a 95% confidence
requirement when :math:`DSR \geq 0.95`.
Parameters
----------
returns : ndarray of shape (n_observations,) or (n_observations, n_assets)
Return series of the selected trial. For a 2D-array, each column is
assessed against the same benchmark derived from `trial_sharpe_ratios`.
trial_sharpe_ratios : ndarray of shape (n_trials,)
Sharpe ratios of every trial evaluated by the search, not only the
selected one, since their count and dispersion set the benchmark. They
must use the same risk-free rate as `returns`.
risk_free_rate : float, default=0.0
Risk-free rate used to compute the excess mean of `returns`.
Returns
-------
value : float or ndarray of shape (n_assets,)
Estimated probability that the true Sharpe ratio of the selected trial
is greater than zero after accounting for selection across trials.
If `returns` is a 1D-array, the result is a float.
If `returns` is a 2D-array, the result is a ndarray of shape (n_assets,).
Raises
------
ValueError
If `trial_sharpe_ratios` is not a 1D-array, is empty after dropping NaNs,
or contains infinite values.
Notes
-----
All quantities are per-period, not annualized, and `trial_sharpe_ratios` must
share the frequency of `returns`.
The benchmark assumes independent, normally distributed trial Sharpe ratios
and uses the number of non-NaN trials as the independent trial count. Positively
correlated trials, such as a dense grid of similar parameter combinations,
may therefore lead to an overly conservative adjustment.
See Also
--------
expected_max_sharpe_ratio
probabilistic_sharpe_ratio
References
----------
.. [1] "The Deflated Sharpe Ratio: Correcting for Selection Bias, Backtest
Overfitting, and Non-Normality".
Bailey, D. and Lopez de Prado, M. (2014)
"""
trial_sharpe_ratios = np.asarray(trial_sharpe_ratios, dtype=float)
if trial_sharpe_ratios.ndim != 1:
raise ValueError("trial_sharpe_ratios must be a 1D array")
if np.isinf(trial_sharpe_ratios).any():
raise ValueError("trial_sharpe_ratios must not contain infinite values")
trial_sharpe_ratios = trial_sharpe_ratios[~np.isnan(trial_sharpe_ratios)]
n_trials = len(trial_sharpe_ratios)
if n_trials == 0:
raise ValueError("trial_sharpe_ratios must contain at least one finite value")
benchmark_sharpe_ratio = expected_max_sharpe_ratio(
n_trials=n_trials,
trial_sharpe_ratio_std=(
float(standard_deviation(trial_sharpe_ratios)) if n_trials > 1 else 0.0
),
)
return probabilistic_sharpe_ratio(
returns,
benchmark_sharpe_ratio=benchmark_sharpe_ratio,
risk_free_rate=risk_free_rate,
)
def _prepare_weighted_returns(
returns: FloatArray, *, sample_weight: FloatArray
) -> tuple[FloatArray, FloatArray]:
"""Prepare returns and weights for calculations that exclude NaNs.
Inputs must be NumPy arrays of floats and are not modified.
Parameters
----------
returns : ndarray of shape (n_observations,) or (n_observations, n_assets)
Return values, possibly containing NaNs.
sample_weight : ndarray of shape (n_observations,)
Observation weights already checked to be finite and nonnegative.
Returns
-------
returns : ndarray
Returns after removing zero-weight rows and replacing NaNs with zero.
Each replacement also receives zero weight, so it does not contribute
to the calculation. If all input weights are zero, no rows remain.
sample_weight : ndarray of shape (n_observations,) or (n_observations, n_assets)
Missing returns receive zero weight, and the remaining weights are
rescaled to sum to one. A nonempty column with no remaining positive
weight produces NaN weights.
Weights stay 1D for 1D returns or inputs without NaNs. For 2D returns
containing NaNs, each column gets its own normalized weight vector.
"""
positive = sample_weight > 0
if not positive.all():
returns, sample_weight = returns[positive], sample_weight[positive]
missing = np.isnan(returns)
if missing.any():
sample_weight = np.where(
missing, 0.0, sample_weight[:, None] if returns.ndim == 2 else sample_weight
)
returns = np.where(missing, 0.0, returns)
return returns, _normalize_sample_weight(sample_weight)
def _prepare_deviations(
returns: ArrayLike,
*,
sample_weight: FloatArray | None,
target: float | FloatArray | None = None,
) -> tuple[FloatArray, FloatArray | None, float | FloatArray]:
"""Prepare returns and weights, then subtract a target return.
Parameters
----------
returns : array-like of shape (n_observations,) or (n_observations, n_assets)
Return values, possibly containing NaNs.
sample_weight : ndarray of shape (n_observations,) or None
Relative observation weights. Must be finite and nonnegative. If None,
equal weights are assumed.
target : float or ndarray of shape (n_assets,), optional
Return to subtract. If None, use the mean of each column with the
supplied weights, excluding NaNs.
Returns
-------
deviations : ndarray
Prepared returns minus the target. With weights, zero-weight rows are
removed and missing returns are replaced before subtraction. Their
deviations must be excluded using the returned weights. Without
weights, NaNs remain in place.
sample_weight : ndarray or None
Weights aligned with the deviations and normalized after excluding NaN
returns, separately for each column when needed. None if no weights
were supplied.
target : float or ndarray of shape (n_assets,)
Target subtracted from the returns. When no target was supplied, this is
the mean computed using the prepared returns and weights.
"""
returns = np.asarray(returns, dtype=float)
if sample_weight is not None:
sample_weight = _validate_sample_weight(
sample_weight, n_observations=returns.shape[0]
)
returns, sample_weight = _prepare_weighted_returns(
returns, sample_weight=sample_weight
)
if target is None:
target = _mean(returns, sample_weight=sample_weight)
return returns - target, sample_weight, target
def _standardized_moment(
returns: ArrayLike,
*,
order: int,
sample_weight: FloatArray | None,
) -> float | FloatArray:
"""Compute skew or kurtosis from centered returns.
Parameters
----------
returns : array-like of shape (n_observations,) or (n_observations, n_assets)
Return values, possibly containing NaNs.
order : {3, 4}
Moment order. Three gives skew and four gives kurtosis.
sample_weight : ndarray of shape (n_observations,) or None
Relative observation weights. None gives equal weights.
Returns
-------
value : float or ndarray of shape (n_assets,)
Standardized moment. NaN if no usable observations remain, the second
moment is at most `(eps * mean)**2`, or the quotient is non-finite.
"""
deviations, sample_weight, mean_return = _prepare_deviations(
returns, sample_weight=sample_weight
)
# Remove the residual offset left by rounding the mean.
deviations -= _mean(deviations, sample_weight=sample_weight)
moment = _mean(deviations**order, sample_weight=sample_weight)
second_moment = _mean(deviations**2, sample_weight=sample_weight)
undefined = second_moment <= (np.finfo(float).eps * mean_return) ** 2
denominator = np.where(undefined, np.nan, second_moment ** (order / 2))
return safe_divide(moment, denominator, fill_value=np.nan)
def _mean(
values: FloatArray, *, sample_weight: FloatArray | None = None
) -> float | FloatArray:
"""Compute the mean from prepared values and optional normalized weights.
Parameters
----------
values : ndarray of shape (n_observations,) or (n_observations, n_assets)
Values to average. Without weights, NaNs are excluded. With weights,
missing values must have been handled by `_prepare_weighted_returns`
before computing these values.
sample_weight : ndarray of shape (n_observations,) or (n_observations, n_assets), optional
Normalized weights from `_prepare_weighted_returns`, or None for an
unweighted mean. This function does not validate or normalize weights.
Returns
-------
value : float or ndarray of shape (n_assets,)
Mean, or one mean per column. Empty inputs and columns with no usable
observations produce NaN.
"""
if values.shape[0] == 0:
return np.full(values.shape[1:], np.nan)[()]
if sample_weight is None:
if np.isfinite(values).all():
return values.mean(axis=0)
with warnings.catch_warnings():
warnings.simplefilter("ignore", category=RuntimeWarning)
return np.nanmean(values, axis=0)
return _weighted_sum(values, sample_weight=sample_weight)
def _weighted_sum(
values: FloatArray, *, sample_weight: FloatArray
) -> float | FloatArray:
"""Sum over observations, using column-specific weights when needed.
Parameters
----------
values : ndarray of shape (n_observations,) or (n_observations, n_assets)
Values to sum along the observation axis.
sample_weight : ndarray of shape (n_observations,) or (n_observations, n_assets)
A 1D weight vector shared by all columns, or a 2D array matching
`values` that assigns weights separately for each column.
Returns
-------
value : float or ndarray of shape (n_assets,)
Weighted sum. A scalar for 1D values, or one result per column for
2D values.
"""
if sample_weight.ndim == 1:
return sample_weight @ values
# Avoid allocating a full product array for column-specific weights.
return np.einsum("ij,ij->j", sample_weight, values)
def _weighted_variance(
returns: FloatArray,
*,
sample_weight: FloatArray,
biased: bool,
min_acceptable_return: float | FloatArray | None = None,
downside: bool = False,
) -> float | FloatArray:
r"""Compute weighted variance or semi-variance, excluding NaN returns.
The remaining weights are rescaled to sum to one separately for each column.
Parameters
----------
returns : ndarray of shape (n_observations,) or (n_observations, n_assets)
Return values, possibly containing NaNs.
sample_weight : ndarray of shape (n_observations,)
Non-negative observation weights.
biased : bool
If True, return the population second moment. If False, divide it by
:math:`1 - \sum_i w_i^2`, where :math:`w_i` are the weights after excluding
NaN returns and rescaling.
min_acceptable_return : float or ndarray of shape (n_assets,), optional
Reference return for computing deviations. If None, use each column's
weighted mean. When `downside` is True, this is the minimum acceptable
return.
downside : bool, default=False
If True, use only deviations below the reference return to compute
semi-variance. All remaining observations contribute to the weight
normalization and unbiased correction.
Returns
-------
value : float or ndarray of shape (n_assets,)
Weighted variance or semi-variance. A scalar for 1D returns, or one
result per column for 2D returns. Empty inputs and columns with no
remaining positive weight produce NaN. The unbiased result is also
NaN when its correction is zero.
"""
returns = np.asarray(returns, dtype=float)
n_observations = returns.shape[0]
sample_weight = _validate_sample_weight(
sample_weight, n_observations=n_observations
)
returns, sample_weight = _prepare_weighted_returns(
returns, sample_weight=sample_weight
)
if len(returns) == 0:
return np.full(returns.shape[1:], np.nan)[()]
if min_acceptable_return is None:
min_acceptable_return = _weighted_sum(returns, sample_weight=sample_weight)
deviations = returns - min_acceptable_return
if downside:
np.minimum(deviations, 0.0, out=deviations)
np.square(deviations, out=deviations)
result = _weighted_sum(deviations, sample_weight=sample_weight)
if biased:
return result
correction = 1.0 - _weighted_sum(sample_weight, sample_weight=sample_weight)
return result / np.where(correction == 0, np.nan, correction)
def _tail_risk(
returns: ArrayLike,
*,
beta: float,
sample_weight: FloatArray | None,
conditional: bool,
) -> float | FloatArray:
"""Compute VaR or CVaR over usable observations, optionally weighted.
NaN returns are excluded separately for each column. When weights are
supplied, zero-weight observations are also excluded and the remaining
weights are rescaled to sum to one.
Parameters
----------
returns : array-like of shape (n_observations,) or (n_observations, n_assets)
Return values, possibly containing NaNs.
beta : float
Confidence level in [0, 1].
sample_weight : ndarray of shape (n_observations,) or None
Non-negative observation weights. If None, the remaining observations
have equal weight.
conditional : bool
If True, return CVaR, the average loss in the lower return tail.
If False, return VaR, the lower `beta`-quantile of the loss.
Returns
-------
value : float or ndarray of shape (n_assets,)
VaR or CVaR. A scalar for 1D returns, or one result per column for
2D returns. The result is NaN if no observations or no positive
weight remain.
"""
returns = np.asarray(returns, dtype=float)
eps = np.finfo(float).eps
def _unweighted(values: FloatArray) -> float | FloatArray:
"""Compute the unweighted tail measure using the enclosing settings."""
size = values.shape[0]
if size == 0:
return np.nan
if beta == 1:
return -values.min(axis=0)
k = (1.0 - beta) * size
if conditional:
i = max(0, int(np.ceil(k) - 1))
else:
# The tolerance absorbs the floating-point error of `k`, so that an
# integer tail size such as `(1 - 0.9) * 10` is not rounded down.
i = min(size - 1, int(np.floor(k + 4 * eps * size)))
# Partition keeps the unweighted calculation linear in the sample size.
part = np.partition(values, i, axis=0)
if conditional:
return -np.sum(part[:i], axis=0) / k + part[i] * (i / k - 1.0)
return -part[i]
if sample_weight is None:
missing = np.isnan(returns)
if missing.all():
return np.full(returns.shape[1:], np.nan)[()]
if not missing.any():
return _unweighted(returns)
if returns.ndim == 1:
return _unweighted(returns[~missing])
return np.array(
[_unweighted(returns[~missing[:, j], j]) for j in range(returns.shape[1])]
)
sample_weight = _validate_sample_weight(
sample_weight, n_observations=returns.shape[0]
)
positive = sample_weight > 0
def _weighted(column: FloatArray) -> float:
"""Compute the weighted tail measure for one return column."""
valid = ~np.isnan(column) & positive
values, probs = column[valid], sample_weight[valid]
if len(probs) == 0:
return np.nan
probs = _normalize_sample_weight(probs)
if beta == 0:
return -probs @ values if conditional else -values.max()
if beta == 1:
return -values.min()
if np.all(probs == probs[0]):
# Preserve the unweighted empirical rank convention exactly.
return float(_unweighted(values))
order = np.argsort(values)
values, probs = values[order], probs[order]
cumulative = np.cumsum(probs)
tail_mass = (1.0 - beta) * cumulative[-1]
if not conditional:
# Allow for rounding in beta and in the accumulated weights.
# Scale the weight tolerance by the tail mass: even a tiny probability
# can cover the whole tail when beta is close to one.
beta_tolerance = 0.5 * np.spacing(beta) * cumulative[-1]
weight_tolerance = 4 * eps * len(values) * tail_mass
i = np.searchsorted(
cumulative,
tail_mass + (beta_tolerance + weight_tolerance),
side="right",
)
return -values[min(i, len(values) - 1)]
i = np.searchsorted(cumulative, tail_mass)
if i == 0:
return -values[i]
return (
-(probs[:i] @ values[:i] + values[i] * (tail_mass - cumulative[i - 1]))
/ tail_mass
)
if returns.ndim == 1:
return _weighted(returns)
return np.array([_weighted(column) for column in returns.T])
def _unweighted_evar(losses: FloatArray, beta: float) -> float:
r"""Compute the EVaR of losses standardized to [-1, 0] with a maximum of 0.
With :math:`t = 1 / \theta` and :math:`c = \log(n (1 - \beta))`, the EVaR is the
infimum over :math:`t > 0` of :math:`f(t) = (\log \sum_i e^{t x_i} - c) / t`. The
exponents are non-positive and the largest is 0, so the sum cannot overflow or
underflow. The derivative of :math:`f` has the sign of
:math:`g(t) = t \, \mathbb{E}_w[x] - \log \sum_i e^{t x_i} + c`, with
:math:`w_i \propto e^{t x_i}`, which increases from :math:`\log(1 - \beta)` at
:math:`t = 0`. The minimizer is the root of :math:`g`.
Parameters
----------
losses : ndarray of shape (n_observations,)
Standardized losses.
beta : float
Confidence level in (0, 1).
Returns
-------
value : float
EVaR of the standardized losses, between -1 and 0.
"""
c = np.log(losses.size) + np.log1p(-beta)
def objective(log_t: float) -> float:
"""Compute :math:`f(t)`, where `log_t` is the log of :math:`t`."""
t = np.exp(log_t)
return (np.log(np.exp(t * losses).sum()) - c) / t
def gradient_sign(log_t: float) -> float:
"""Compute :math:`g(t)`, where `log_t` is the log of :math:`t`."""
t = np.exp(log_t)
exp_losses = np.exp(t * losses)
total = exp_losses.sum()
return t * (exp_losses @ losses) / total - np.log(total) + c
# The variance of the losses under w is at most 1/4, so
# g(t) <= log(1 - beta) + t**2 / 8, which is negative at `lower`.
lower = 0.5 * np.log(-2.0 * np.log1p(-beta))
upper = 20.0
if gradient_sign(upper) <= 0:
# The infimum is reached as theta tends to 0, and f is within 1e-7 of its
# limit 0 past `upper`.
return 0.0
return objective(sco.brentq(gradient_sign, lower, upper))
def _weighted_evar(
losses: FloatArray, beta: float, log_probabilities: FloatArray
) -> float:
"""Compute weighted EVaR for losses scaled to [-1, 0], with maximum 0.
Parameters
----------
losses : ndarray of shape (n_observations,)
Finite losses scaled so the minimum is -1 and the maximum is 0.
Observations with zero probability must already have been removed.
beta : float
Confidence level strictly between 0 and 1.
log_probabilities : ndarray of shape (n_observations,)
Natural logarithms of the positive observation probabilities, aligned
with `losses`. These probabilities must sum to one. Keeping them in
logarithmic form preserves very small probabilities in the calculation.
Returns
-------
value : float
EVaR of the scaled losses, between their weighted mean and zero.
Notes
-----
Find the minimum of the EVaR objective by locating where its derivative
changes sign. The search uses the logarithm of the inverse temperature,
where temperature is the positive scale parameter in the EVaR formula.
Near confidence zero, use the weighted mean if Hoeffding's bound puts it
within 1e-12 of EVaR. At a large inverse temperature, use the maximum loss
if the objective is within 1e-12 of it. This second error is bounded by
`log((1 - beta) / probability_at_maximum) / inverse_temperature`.
Both bounds apply on the scaled loss range [-1, 0].
"""
log_tail = np.log1p(-beta)
log_maximum_mass = scs.logsumexp(log_probabilities[losses == 0])
if log_maximum_mass >= log_tail:
return 0.0
probabilities = _normalize_sample_weight(np.exp(log_probabilities))
risk_tolerance = 1e-12
if np.sqrt(-0.5 * log_tail) <= risk_tolerance:
return float(probabilities @ losses)
def evaluate(log_inverse_temperature: float) -> tuple[float, float]:
"""Return the EVaR objective and a value with its derivative's sign."""
inverse_temperature = np.exp(log_inverse_temperature)
scaled_losses = inverse_temperature * losses
if inverse_temperature < 1:
# At small confidence levels the exponential average is close to one.
# Preserve its small change instead of subtracting rounded logarithms.
log_average = np.log1p(probabilities @ np.expm1(scaled_losses))
tilted_weights = probabilities * np.exp(scaled_losses - log_average)
else:
log_average = scs.logsumexp(log_probabilities + scaled_losses)
tilted_weights = np.exp(log_probabilities + scaled_losses - log_average)
objective = (log_average - log_tail) / inverse_temperature
gradient = tilted_weights @ scaled_losses - log_average + log_tail
return objective, gradient
def gradient_sign(log_inverse_temperature: float) -> float:
"""Return a value with the same sign as the objective's derivative."""
return evaluate(log_inverse_temperature)[1]
lower = 0.5 * np.log(-2.0 * log_tail)
upper = 20.0
while gradient_sign(upper) <= 0:
if (log_tail - log_maximum_mass) * np.exp(-upper) <= risk_tolerance:
return 0.0
upper += 4.0
root = sco.brentq(gradient_sign, lower, upper)
return evaluate(root)[0]