Source code for financialkit.credit.systems.basel

"""The Basel II internal-ratings-based (IRB) capital formula.

Gordy (2003) showed that capital is portfolio-invariant (each loan's
charge depends only on its own parameters) exactly in Vasicek's
asymptotic single-risk-factor model. The Basel Committee (2004) built
the IRB formula on it: capital per unit exposure is the 99.9% Vasicek
quantile of the loss rate minus the expected loss, times a maturity
adjustment,

.. math::

   K = \\left[LGD \\cdot N\\left(\\frac{N^{-1}(PD) + \\sqrt{R}\\, N^{-1}(0.999)}{\\sqrt{1 - R}}\\right)
       - PD \\cdot LGD\\right] \\frac{1 + (M - 2.5)\\, b}{1 - 1.5\\, b},

with :math:`b = (0.11852 - 0.05478 \\ln PD)^2`, a supervisory asset
correlation :math:`R` that falls from 24% to 12% as PD rises, and risk
weighted assets :math:`12.5 \\cdot K \\cdot EAD`.

References: Basel Committee on Banking Supervision, *International
Convergence of Capital Measurement and Capital Standards: A Revised
Framework* (2004, comprehensive version 2006), paragraphs 272-330;
BCBS, *An Explanatory Note on the Basel II IRB Risk Weight Functions*
(2005); M. B. Gordy, "A Risk-Factor Model Foundation for Ratings-Based
Bank Capital Rules," *J. Financial Intermediation* 12 (2003) 199-232.
"""

from __future__ import annotations

import numpy as np
from numpy.typing import ArrayLike

from financialkit.credit.systems.vasicek import vasicek_quantile
from financialkit.options.utils.common import as_output

__all__ = ["basel_correlation", "basel_maturity_adjustment", "basel_irb_capital", "basel_risk_weight"]

_ASSET_CLASSES = ("corporate", "mortgage", "revolving", "retail")


def _weight(pd: np.ndarray, k: float) -> np.ndarray:
    return -np.expm1(-k * pd) / -np.expm1(-k)


[docs] def basel_correlation(pd: ArrayLike, asset_class: str = "corporate", sales: ArrayLike | None = None) -> np.ndarray | float: """Supervisory asset correlation :math:`R` for an asset class. - ``"corporate"`` (also sovereign and bank): 12% to 24%, interpolated with weight :math:`(1 - e^{-50 PD})/(1 - e^{-50})`, less the SME adjustment :math:`0.04 (1 - (S - 5)/45)` when annual ``sales`` :math:`S` (EUR millions, clipped to 5-50) are given; - ``"mortgage"``: 15%; ``"revolving"``: 4%; - ``"retail"`` (other retail): 3% to 16% with weight factor 35. Parameters ---------- pd : array_like asset_class : {"corporate", "mortgage", "revolving", "retail"} sales : array_like, optional Returns ------- float or ndarray """ pd = np.asarray(pd, dtype=np.float64) if asset_class == "corporate": w = _weight(pd, 50.0) R = 0.12 * w + 0.24 * (1.0 - w) if sales is not None: S = np.clip(np.asarray(sales, dtype=np.float64), 5.0, 50.0) R = R - 0.04 * (1.0 - (S - 5.0) / 45.0) elif asset_class == "mortgage": R = np.full(pd.shape, 0.15) elif asset_class == "revolving": R = np.full(pd.shape, 0.04) elif asset_class == "retail": w = _weight(pd, 35.0) R = 0.03 * w + 0.16 * (1.0 - w) else: raise ValueError(f"asset_class must be one of {_ASSET_CLASSES}, got {asset_class!r}") return as_output(R)
[docs] def basel_maturity_adjustment(pd: ArrayLike, maturity: ArrayLike = 2.5) -> np.ndarray | float: """Maturity adjustment :math:`(1 + (M - 2.5) b) / (1 - 1.5 b)`, with :math:`b = (0.11852 - 0.05478 \\ln PD)^2`. Parameters ---------- pd : array_like maturity : array_like Effective maturity :math:`M` in years (1 to 5 in the framework). Returns ------- float or ndarray """ pd, M = np.asarray(pd, dtype=np.float64), np.asarray(maturity, dtype=np.float64) b = (0.11852 - 0.05478 * np.log(pd)) ** 2 return as_output((1.0 + (M - 2.5) * b) / (1.0 - 1.5 * b))
[docs] def basel_irb_capital( pd: ArrayLike, lgd: ArrayLike = 0.45, maturity: ArrayLike = 2.5, asset_class: str = "corporate", sales: ArrayLike | None = None, alpha: float = 0.999, pd_floor: float = 0.0003, ) -> np.ndarray | float: """IRB capital requirement :math:`K` per unit of exposure at default. Retail classes have no maturity adjustment. A defaulted exposure (``pd = 1``) gets zero unexpected-loss capital. Parameters ---------- pd : array_like One-year default probability (floored at 0.03%). lgd : array_like Loss given default (45% is the foundation-IRB senior unsecured value). maturity : array_like Effective maturity in years. asset_class : {"corporate", "mortgage", "revolving", "retail"} sales : array_like, optional Annual sales in EUR millions for the SME correlation adjustment. alpha : float Confidence level, 99.9% in the framework. pd_floor : float Returns ------- float or ndarray Examples -------- A corporate loan with PD 1%, LGD 45% and M = 2.5: >>> round(12.5 * basel_irb_capital(0.01), 4) 0.9232 """ pd = np.maximum(np.asarray(pd, dtype=np.float64), pd_floor) lgd = np.asarray(lgd, dtype=np.float64) R = np.asarray(basel_correlation(np.minimum(pd, 1.0 - 1e-12), asset_class, sales)) with np.errstate(divide="ignore", invalid="ignore"): q = np.asarray(vasicek_quantile(alpha, np.minimum(pd, 1.0 - 1e-12), R)) K = lgd * q - pd * lgd if asset_class == "corporate": K = K * np.asarray(basel_maturity_adjustment(np.minimum(pd, 1.0 - 1e-12), maturity)) return as_output(np.where(pd >= 1.0, 0.0, np.maximum(K, 0.0)))
[docs] def basel_risk_weight( pd: ArrayLike, lgd: ArrayLike = 0.45, maturity: ArrayLike = 2.5, asset_class: str = "corporate", sales: ArrayLike | None = None, scaling: float = 1.0 ) -> np.ndarray | float: """Risk weight :math:`12.5 \\cdot K` (times the 1.06 scaling factor if passed). Parameters ---------- pd, lgd, maturity, asset_class, sales As in :func:`basel_irb_capital`. scaling : float The framework multiplies IRB risk-weighted assets by 1.06; the Committee's illustrative tables use 1. Returns ------- float or ndarray Examples -------- Basel II framework, illustrative IRB risk weights (corporate, LGD 45%, M = 2.5): >>> [round(100 * basel_risk_weight(p), 2) for p in (0.0003, 0.01)] [14.44, 92.32] """ return as_output(12.5 * scaling * np.asarray(basel_irb_capital(pd, lgd, maturity, asset_class, sales)))