Source code for merton.portfolio.vasicek_factor

r"""Vasicek (1987, 2002) single-factor portfolio model.

Used by Basel II/III IRB regulations to set capital requirements without
running a Monte Carlo. Each obligor's default indicator is generated from
a latent variable

.. math::

    Y_i = \sqrt{\rho}\, M + \sqrt{1 - \rho}\, \varepsilon_i,

with ``M, ε_i ~ N(0, 1)`` independent. Obligor ``i`` defaults when
``Y_i < \Phi^{-1}(\mathrm{PD}_i)``.

In the homogeneous limit (a portfolio of infinitely many infinitesimally
small loans with identical ``PD``, ``LGD``, ``ρ``), the loss fraction has
the closed-form CDF

.. math::

    F_L(x) = \Phi\!\left(\frac{\sqrt{1-\rho}\, \Phi^{-1}(x) - \Phi^{-1}(\mathrm{PD})}{\sqrt{\rho}}\right),

and the ``α``-quantile (Basel IRB confidence level: ``α = 0.999``) is

.. math::

    L_\alpha = \Phi\!\left(\frac{\Phi^{-1}(\mathrm{PD}) + \sqrt{\rho}\, \Phi^{-1}(\alpha)}{\sqrt{1-\rho}}\right).

References
----------
Vasicek, O. (2002). Loan Portfolio Value. *Risk* 15 (12), 160-162.

Basel Committee on Banking Supervision (2005). *An Explanatory Note on the
Basel II IRB Risk Weight Functions.*
"""

from __future__ import annotations

from dataclasses import dataclass

import numpy as np
from scipy.stats import norm

from .._typing import ArrayLike, FloatArray
from ..exceptions import MertonInputError


def _validate_unit(name: str, x: ArrayLike) -> FloatArray:
    arr = np.asarray(x, dtype=np.float64)
    if np.any(arr < 0) or np.any(arr > 1):
        raise MertonInputError(f"{name} must lie in [0, 1]")
    return arr


[docs] def vasicek_loss_cdf(x: ArrayLike, pd: ArrayLike, rho: ArrayLike) -> FloatArray: r"""``F_L(x) = Φ((√(1-ρ) Φ⁻¹(x) - Φ⁻¹(PD)) / √ρ)``.""" x_arr = _validate_unit("x", x) pd_arr = _validate_unit("pd", pd) rho_arr = _validate_unit("rho", rho) # Clip for numerical safety. x_clip = np.clip(x_arr, 1e-15, 1.0 - 1e-15) pd_clip = np.clip(pd_arr, 1e-15, 1.0 - 1e-15) rho_clip = np.clip(rho_arr, 1e-15, 1.0 - 1e-15) return norm.cdf( (np.sqrt(1.0 - rho_clip) * norm.ppf(x_clip) - norm.ppf(pd_clip)) / np.sqrt(rho_clip) )
[docs] def vasicek_var(pd: ArrayLike, rho: ArrayLike, alpha: float = 0.999) -> FloatArray: r"""``α``-quantile of the asymptotic loss distribution.""" pd_arr = _validate_unit("pd", pd) rho_arr = _validate_unit("rho", rho) if not 0.5 < alpha < 1: raise MertonInputError("alpha must lie in (0.5, 1)") pd_clip = np.clip(pd_arr, 1e-15, 1.0 - 1e-15) rho_clip = np.clip(rho_arr, 1e-15, 1.0 - 1e-15) return norm.cdf( (norm.ppf(pd_clip) + np.sqrt(rho_clip) * norm.ppf(alpha)) / np.sqrt(1.0 - rho_clip) )
[docs] def basel_irb_capital( pd: ArrayLike, lgd: ArrayLike = 0.45, rho: ArrayLike | None = None, maturity: float = 1.0, *, alpha: float = 0.999, apply_maturity_adjustment: bool = True, asset_class: str = "corporate", ) -> FloatArray: r"""Basel III IRB unexpected-loss capital requirement. For corporate exposures, the BCBS prescribes .. math:: \rho(\mathrm{PD}) = 0.12\,\frac{1 - e^{-50\,\mathrm{PD}}}{1 - e^{-50}} + 0.24\,\left(1 - \frac{1 - e^{-50\,\mathrm{PD}}}{1 - e^{-50}}\right), with a maturity adjustment .. math:: b(\mathrm{PD}) = (0.11852 - 0.05478\,\ln(\mathrm{PD}))^2,\quad \mathrm{MA}(M) = \frac{1 + (M - 2.5)\,b(\mathrm{PD})}{1 - 1.5\,b(\mathrm{PD})}. The capital requirement per unit of exposure is .. math:: K = \mathrm{LGD}\,\left(L_\alpha(\mathrm{PD}, \rho) - \mathrm{PD}\right)\,\mathrm{MA}(M). """ pd_arr = _validate_unit("pd", pd) lgd_arr = _validate_unit("lgd", lgd) if rho is None: rho_arr = basel_irb_correlation(pd_arr, asset_class=asset_class) else: rho_arr = _validate_unit("rho", rho) var = vasicek_var(pd_arr, rho_arr, alpha=alpha) unexpected_loss = lgd_arr * (var - pd_arr) if apply_maturity_adjustment: b = (0.11852 - 0.05478 * np.log(np.clip(pd_arr, 1e-15, 1.0))) ** 2 ma = (1.0 + (maturity - 2.5) * b) / (1.0 - 1.5 * b) return unexpected_loss * ma return unexpected_loss
# Re-exported by the package; defined here to avoid circular import.
[docs] def basel_irb_correlation(pd: ArrayLike, *, asset_class: str = "corporate") -> FloatArray: """Basel III asset correlation as a function of PD and asset class.""" pd_arr = _validate_unit("pd", pd) if asset_class == "corporate": lo, hi = 0.12, 0.24 elif asset_class == "sme": # SME size-adjusted; assumes a $50M+ annual sales firm for the cap. lo, hi = 0.12, 0.24 elif asset_class == "retail": lo, hi = 0.03, 0.16 elif asset_class == "qrre": lo, hi = 0.04, 0.04 # qualifying revolving retail = constant 4%. else: raise MertonInputError( f"unknown asset_class {asset_class!r}", suggested_fix="Use one of: corporate, sme, retail, qrre.", ) w = (1.0 - np.exp(-50.0 * pd_arr)) / (1.0 - np.exp(-50.0)) return lo * w + hi * (1.0 - w)
@dataclass(slots=True, frozen=True)
[docs] class VasicekFactor: """Holder for a Vasicek single-factor parametrisation. Use either an explicit ``rho`` or a Basel-IRB-derived one (``rho=None`` triggers :func:`basel_irb_correlation`). """
[docs] pd: float
[docs] lgd: float = 0.45
[docs] rho: float | None = None
[docs] maturity: float = 1.0
[docs] asset_class: str = "corporate"
[docs] def effective_rho(self) -> float: if self.rho is not None: return float(self.rho) return float(basel_irb_correlation(self.pd, asset_class=self.asset_class))
[docs] def loss_cdf(self, x: ArrayLike) -> FloatArray: return vasicek_loss_cdf(x, self.pd, self.effective_rho())
[docs] def var(self, alpha: float = 0.999) -> float: return float(vasicek_var(self.pd, self.effective_rho(), alpha=alpha))
[docs] def capital(self, *, alpha: float = 0.999, apply_maturity_adjustment: bool = True) -> float: return float( basel_irb_capital( self.pd, self.lgd, self.effective_rho(), maturity=self.maturity, alpha=alpha, apply_maturity_adjustment=apply_maturity_adjustment, asset_class=self.asset_class, ) )
__all__ = [ "VasicekFactor", "basel_irb_capital", "basel_irb_correlation", "vasicek_loss_cdf", "vasicek_var", ]