Source code for merton.portfolio.loss_distribution
"""LossDistribution container for portfolio Monte Carlo / analytic outputs."""
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
from .._typing import FloatArray
from ..exceptions import MertonInputError
@dataclass(slots=True)
[docs]
class LossDistribution:
"""Histogram of simulated portfolio losses (in currency units or fraction).
Parameters
----------
losses
1-D array of simulated losses (one per Monte Carlo draw).
weights
Optional importance-sampling weights; uniform by default.
contributions
Optional ``(n_sims, n_firms)`` matrix of per-firm losses, used for
marginal-contribution analysis.
"""
[docs]
weights: FloatArray | None = None
[docs]
contributions: FloatArray | None = None
def __post_init__(self) -> None:
if np.asarray(self.losses).ndim != 1:
raise MertonInputError("losses must be a 1-D array")
# --- summary statistics -------------------------------------------------
[docs]
def mean(self) -> float:
if self.weights is None:
return float(np.mean(self.losses))
return float(np.average(self.losses, weights=self.weights))
[docs]
def std(self) -> float:
if self.weights is None:
return float(np.std(self.losses, ddof=1))
m = self.mean()
return float(np.sqrt(np.average((self.losses - m) ** 2, weights=self.weights)))
[docs]
def var(self, level: float = 0.99) -> float:
"""Quantile-based VaR (``α``-percentile of the loss distribution)."""
if not 0 < level < 1:
raise MertonInputError("level must lie in (0, 1)")
return float(np.quantile(self.losses, level))
[docs]
def expected_shortfall(self, level: float = 0.99) -> float:
"""Average loss conditional on exceeding the ``α``-VaR."""
threshold = self.var(level)
tail = self.losses[self.losses >= threshold]
if tail.size == 0:
return threshold
return float(np.mean(tail))
[docs]
def economic_capital(
self,
confidence: float = 0.999,
*,
capital_basis: str = "el",
) -> float:
"""Capital sufficient to cover unexpected losses at ``confidence``.
- ``capital_basis="el"`` (default): ``VaR(α) − E[L]`` (Basel-style
unexpected-loss capital).
- ``capital_basis="zero"``: ``VaR(α)``; treat the full quantile as
capital (used by some IFRS-9 implementations).
"""
v = self.var(confidence)
if capital_basis == "el":
return float(v - self.mean())
if capital_basis == "zero":
return float(v)
raise MertonInputError(
f"unknown capital_basis {capital_basis!r}",
suggested_fix="Choose 'el' or 'zero'.",
)
[docs]
def quantile(self, q: float | FloatArray) -> float | FloatArray:
"""Empirical quantile of the loss distribution."""
return np.quantile(self.losses, q)
[docs]
def histogram(self, bins: int = 100) -> tuple[FloatArray, FloatArray]:
return np.histogram(self.losses, bins=bins)
# --- contribution / decomposition --------------------------------------
[docs]
def firm_contributions(
self,
level: float = 0.99,
method: str = "es",
) -> FloatArray:
"""Per-firm expected-loss contribution at the given quantile.
``method='es'``: mean per-firm loss conditional on portfolio
losses exceeding the ``α``-VaR. ``method='var'``: per-firm loss at
the worst Monte Carlo draw that defines the VaR quantile.
Requires ``contributions`` to be set.
"""
if self.contributions is None:
raise MertonInputError(
"firm_contributions requires the contributions matrix",
suggested_fix="Pass `record_contributions=True` to Portfolio.simulate.",
)
if method == "es":
threshold = self.var(level)
mask = self.losses >= threshold
if mask.sum() == 0:
return np.zeros(self.contributions.shape[1])
return self.contributions[mask].mean(axis=0)
if method == "var":
idx = int(np.argmin(np.abs(self.losses - self.var(level))))
return self.contributions[idx]
raise MertonInputError(
f"unknown method {method!r}",
suggested_fix="Choose 'es' or 'var'.",
)
[docs]
def to_pandas(self): # type: ignore[no-untyped-def]
import pandas as pd
return pd.DataFrame({"loss": self.losses})
# --- repr --------------------------------------------------------------
[docs]
def summary(self, level: float = 0.99) -> str:
return (
"LossDistribution\n"
f" n_samples : {len(self.losses):,}\n"
f" mean (EL) : {self.mean():,.4f}\n"
f" std (UL) : {self.std():,.4f}\n"
f" VaR({level:.3f}) : {self.var(level):,.4f}\n"
f" ES ({level:.3f}) : {self.expected_shortfall(level):,.4f}\n"
f" EC (99.9%) : {self.economic_capital(0.999):,.4f}"
)
def __repr__(self) -> str:
return self.summary()
__all__ = ["LossDistribution"]