Source code for merton.core.physical
r"""Risk-neutral ↔ physical probability-of-default conversion.
The Merton model under Black-Scholes assumptions produces *risk-neutral* PDs
(``Q``-measure). For credit-loss forecasting, banks typically need *physical*
PDs (``P``-measure). The two are related through the market price of risk:
.. math::
DD_P = DD_Q + \lambda\, \sigma_A\, \sqrt{T}
where :math:`\lambda = (\mu - r) / \sigma_M` is the (asset-class) Sharpe
ratio. The physical PD is then :math:`\Phi(-DD_P)`.
"""
from __future__ import annotations
import numpy as np
from .._backend._numpy import norm_cdf, norm_ppf
from .._typing import ArrayLike, FloatArray
from ..exceptions import MertonInputError
[docs]
def physical_pd(
rn_pd: ArrayLike,
asset_vol: ArrayLike,
sharpe_ratio: ArrayLike,
T: ArrayLike,
) -> FloatArray:
r"""Convert a risk-neutral PD into a physical PD.
Parameters
----------
rn_pd
Risk-neutral probability of default (decimal in [0, 1]).
asset_vol
Annualised asset volatility.
sharpe_ratio
Equity / asset-class Sharpe ratio ``λ = (μ - r) / σ``.
T
Horizon (years).
"""
rn = np.asarray(rn_pd, dtype=np.float64)
s = np.asarray(asset_vol, dtype=np.float64)
lam = np.asarray(sharpe_ratio, dtype=np.float64)
T_ = np.asarray(T, dtype=np.float64)
if np.any(rn < 0) or np.any(rn > 1):
raise MertonInputError("rn_pd must lie in [0, 1]")
if np.any(T_ <= 0):
raise MertonInputError("T must be strictly positive")
# Clip to keep ppf finite at the tails.
rn_clipped = np.clip(rn, 1e-15, 1.0 - 1e-15)
dd_q = -norm_ppf(rn_clipped)
dd_p = dd_q + lam * s * np.sqrt(T_)
return norm_cdf(-dd_p)
__all__ = ["physical_pd"]