from __future__ import annotations
from typing import Any
import numpy as np
from ._validation import as_1d_float_array, validate_q
__all__ = [
"mean",
"variance",
"std",
"var",
"tvar",
"prob_exceeding",
"exceedance_probability",
"summary",
]
[docs]
def mean(losses: np.ndarray | list[float]) -> float:
"""Empirical mean of a simulated loss vector."""
arr = as_1d_float_array(losses)
return float(np.mean(arr))
[docs]
def variance(losses: np.ndarray | list[float], ddof: int = 0) -> float:
"""Empirical variance of a simulated loss vector.
``ddof`` follows numpy's convention: ``0`` (default) is the population
estimator dividing by ``n``; ``1`` divides by ``n - 1``.
"""
arr = as_1d_float_array(losses)
return float(np.var(arr, ddof=ddof))
[docs]
def std(losses: np.ndarray | list[float], ddof: int = 0) -> float:
"""Empirical standard deviation of a simulated loss vector.
``ddof`` follows numpy's convention: ``0`` (default) is the population
estimator dividing by ``n``; ``1`` divides by ``n - 1``.
"""
arr = as_1d_float_array(losses)
return float(np.std(arr, ddof=ddof))
def _var_rank(n: int, q: np.ndarray) -> np.ndarray:
"""Rank k of the VaR order statistic: k = ceil(n*q), guarded against
floating-point error when n*q is an exact integer."""
nq = n * q
k = np.where(np.abs(nq - np.round(nq)) < 1e-8, np.round(nq), np.ceil(nq))
return np.clip(k.astype(np.intp), 1, n)
[docs]
def var(losses: np.ndarray | list[float], q: float | np.ndarray) -> float | np.ndarray:
"""Empirical Value-at-Risk.
Uses the actuarial (lower-quantile) definition
VaR_q(X) = inf{ x : F(x) >= q },
whose empirical plug-in is the order statistic ``x_(ceil(n*q))``.
Equivalent to ``np.quantile(losses, q, method="inverted_cdf")``.
``q`` may be a scalar (returns ``float``) or array-like
(returns ``np.ndarray`` of the same length).
"""
validate_q(q)
arr = np.sort(as_1d_float_array(losses))
q_arr = np.asarray(q, dtype=float)
k = _var_rank(arr.size, q_arr)
out = arr[k - 1]
return float(out) if q_arr.ndim == 0 else np.asarray(out, dtype=float)
[docs]
def tvar(losses: np.ndarray | list[float], q: float | np.ndarray) -> float | np.ndarray:
"""Empirical Tail Value-at-Risk (expected shortfall).
Uses the average-quantile (coherent) definition
TVaR_q(X) = (1 / (1 - q)) * integral_q^1 VaR_u(X) du,
whose empirical plug-in (Acerbi-Tasche) is, with sorted losses
``x_(1) <= ... <= x_(n)`` and ``k = ceil(n*q)``,
TVaR_q = [ sum_{i>k} x_(i) + x_(k) * (k - n*q) ] / (n * (1 - q)).
This is exact for the empirical distribution (correct with ties/atoms)
and reduces to the mean of the largest ``n*(1-q)`` observations when
``n*q`` is an integer. Always satisfies ``tvar >= var``.
``q`` may be a scalar (returns ``float``) or array-like
(returns ``np.ndarray`` of the same length).
"""
validate_q(q)
arr = np.sort(as_1d_float_array(losses))
n = arr.size
q_arr = np.asarray(q, dtype=float)
k = _var_rank(n, q_arr)
csum = np.concatenate(([0.0], np.cumsum(arr)))
tail_sum = csum[n] - csum[k]
var_vals = arr[k - 1]
nq = n * q_arr
weight = np.where(np.abs(nq - np.round(nq)) < 1e-8, 0.0, k - nq)
out = (tail_sum + var_vals * weight) / (n * (1.0 - q_arr))
# TVaR >= VaR holds as a theorem in exact arithmetic; enforce it so
# floating-point noise (e.g. a constant tail at a layer limit) can never
# produce tvar infinitesimally below var.
out = np.maximum(out, var_vals)
return float(out) if q_arr.ndim == 0 else np.asarray(out, dtype=float)
[docs]
def prob_exceeding(losses: np.ndarray | list[float], threshold: float) -> float:
"""Empirical exceedance probability ``P(X > threshold)``.
The inequality is strict, matching the survival-function convention
used across the ecosystem. Also available under the ecosystem-standard
spelling ``exceedance_probability``.
"""
arr = as_1d_float_array(losses)
return float(np.mean(arr > threshold))
# Ecosystem-standard name (lossmodels and extremeloss use this spelling).
exceedance_probability = prob_exceeding
def _quantile_label(q: float) -> str:
"""Stable, collision-free percentile label.
``0.95 -> "95"``, ``0.99 -> "99"``, ``0.995 -> "99.5"``, ``0.999 -> "99.9"``.
Integer percentiles are unchanged; sub-percent quantiles keep their
precision instead of rounding to a colliding integer.
"""
return f"{round(q * 100, 6):g}"
[docs]
def summary(
losses: np.ndarray | list[float],
quantiles: tuple[float, ...] = (0.95, 0.99),
) -> dict[str, Any]:
"""One-call risk summary of a simulated loss vector.
Returns a dict with ``n_sims``, ``mean``, ``std``, ``min``, ``max``,
and a ``var_{p}`` / ``tvar_{p}`` pair per requested quantile, where
``{p}`` is the percentile label (``0.99 -> "99"``, ``0.995 -> "99.5"``).
VaR and TVaR use the ecosystem-wide empirical estimators — see
:func:`var` and :func:`tvar`.
"""
arr = as_1d_float_array(losses)
out: dict[str, Any] = {
"n_sims": int(arr.size),
"mean": mean(arr),
"std": std(arr),
"min": float(np.min(arr)),
"max": float(np.max(arr)),
}
for q in quantiles:
validate_q(q)
label = _quantile_label(q)
out[f"var_{label}"] = var(arr, q)
out[f"tvar_{label}"] = tvar(arr, q)
return out