"""Credit risk models and default probability estimation.
Credit risk is the risk that a borrower fails to meet its obligations.
This module provides tools spanning three major approaches:
1. **Structural models** -- model the firm's equity as a contingent
claim on its assets. Default occurs when asset value falls below
the debt barrier.
- ``merton_model``: the foundational structural model (Merton 1974).
Treats equity as a European call option on total firm assets.
Iteratively solves for implied asset value and volatility, then
computes distance-to-default and default probability.
2. **Credit scoring** -- statistical models that predict default from
accounting ratios or market data.
- ``altman_z_score``: the original 1968 Altman Z-Score for publicly
traded manufacturing firms. Combines five accounting ratios into
a single score that classifies firms as "safe" (Z > 2.99),
"grey zone" (1.81-2.99), or "distress" (Z < 1.81).
3. **Reduced-form / intensity models** -- model default as a random
event driven by a hazard rate (default intensity).
- ``default_probability``: cumulative default probability from a
rating transition matrix raised to the power of the horizon.
- ``credit_spread``: implied spread from PD and recovery rate.
- ``cds_spread``: fair CDS premium from a constant hazard rate,
integrating protection and premium legs.
- ``loss_given_default``: LGD = exposure * (1 - recovery rate).
- ``expected_loss``: EL = PD * LGD * EAD -- the central formula
of regulatory capital calculation (Basel II IRB).
How to choose:
- For public equities with observable stock prices: ``merton_model``
gives market-implied default probabilities that update daily.
- For quick screening of financial health: ``altman_z_score`` using
balance sheet data.
- For pricing CDS or credit-linked instruments: ``cds_spread``
with calibrated hazard rates.
- For portfolio credit risk (e.g., a loan book): ``default_probability``
from rating agency transition matrices + ``expected_loss``.
References:
- Merton (1974), "On the Pricing of Corporate Debt"
- Altman (1968), "Financial Ratios, Discriminant Analysis and the
Prediction of Corporate Bankruptcy"
- Lando (2004), "Credit Risk Modeling: Theory and Applications"
"""
from __future__ import annotations
import numpy as np
from scipy import stats as sp_stats
__all__ = [
"altman_z_score",
"cds_spread",
"credit_spread",
"default_probability",
"expected_loss",
"loss_given_default",
"merton_model",
]
[docs]
def merton_model(
equity: float,
debt: float,
vol: float,
rf_rate: float,
maturity: float,
) -> dict[str, float]:
"""Merton structural credit risk model.
Models firm equity as a European call option on total assets with
strike equal to the face value of debt. The key insight: equity
holders have a call option on the firm's assets -- they get the
upside above the debt level but can walk away (default) if assets
fall below debt.
The model iteratively solves for the unobservable asset value and
asset volatility from the observable equity value and equity
volatility, using the Black-Scholes option pricing relationship.
Interpretation:
- **distance_to_default** (DD): How many standard deviations
the firm's asset value is above the default barrier.
DD > 4: very safe. DD 2-4: investment grade. DD 1-2: high
yield. DD < 1: distress. Moody's KMV uses this as the
primary input to their EDF (Expected Default Frequency) model.
- **default_probability**: N(-DD), the probability that asset
value drifts below debt by maturity. This is a risk-neutral
probability -- real-world default rates are typically lower.
- **asset_vol**: Implied asset volatility. Higher = more
default risk. Asset vol is always lower than equity vol
because equity is a levered claim.
- **credit_spread**: The yield premium investors should demand
for holding risky debt. Compare to market CDS spreads to
detect mispricing.
When to use:
- Market-implied default probabilities from daily equity data.
- Screening for distressed firms (DD < 2).
- As a factor in credit scoring models.
- For relative value: compare Merton-implied spreads to market
CDS spreads.
Red flags:
- DD < 1: firm is in acute distress.
- Asset vol > 50%: inputs may be unreliable.
- Equity vol is stale or missing: model won't converge.
Parameters:
equity: Current market value of equity (market cap).
debt: Face value of outstanding debt (the "strike").
vol: Equity volatility (annualized, e.g., 0.30 for 30%).
rf_rate: Continuous risk-free rate (annualized).
maturity: Time to maturity of debt in years (typically 1).
Returns:
Dictionary with keys:
- **asset_value** (*float*) -- Implied total asset value.
- **asset_vol** (*float*) -- Implied asset volatility.
- **d1**, **d2** (*float*) -- Black-Scholes d1 and d2.
- **distance_to_default** (*float*) -- d2, number of std devs
above the default barrier.
- **default_probability** (*float*) -- N(-d2), risk-neutral
probability of default.
- **credit_spread** (*float*) -- Implied credit spread over
the risk-free rate (annualized).
Example:
>>> result = merton_model(equity=50e6, debt=40e6, vol=0.35,
... rf_rate=0.04, maturity=1.0)
>>> print(f"DD: {result['distance_to_default']:.2f}")
>>> print(f"PD: {result['default_probability']:.4f}")
>>> print(f"Spread: {result['credit_spread']*10000:.0f} bps")
See Also:
altman_z_score: Accounting-based bankruptcy predictor.
cds_spread: Reduced-form CDS pricing.
Notes:
Reference: Merton, R.C. (1974). "On the Pricing of Corporate
Debt: The Risk Structure of Interest Rates." *Journal of
Finance*, 29(2), 449-470.
"""
if maturity <= 0:
raise ValueError("maturity must be positive")
if equity <= 0:
raise ValueError("equity must be positive")
if debt <= 0:
raise ValueError("debt must be positive")
sqrt_t = np.sqrt(maturity)
# Initial guesses for iterative solver
asset_value = equity + debt
asset_vol = vol * equity / asset_value
# Iterative solution (fixed-point iteration)
for _ in range(200):
d1 = (
np.log(asset_value / debt) + (rf_rate + 0.5 * asset_vol**2) * maturity
) / (asset_vol * sqrt_t)
d2 = d1 - asset_vol * sqrt_t
# Equity = V * N(d1) - D * exp(-r*T) * N(d2)
asset_value * sp_stats.norm.cdf(d1) - debt * np.exp(
-rf_rate * maturity
) * sp_stats.norm.cdf(d2)
# Update asset volatility using the relationship:
# sigma_E * E = N(d1) * sigma_A * V
nd1 = sp_stats.norm.cdf(d1)
if nd1 > 1e-15:
asset_vol_new = vol * equity / (nd1 * asset_value)
else:
asset_vol_new = asset_vol
asset_value_new = equity + debt * np.exp(
-rf_rate * maturity
) * sp_stats.norm.cdf(d2)
if (
abs(asset_value_new - asset_value) < 1e-8
and abs(asset_vol_new - asset_vol) < 1e-8
):
asset_value = asset_value_new
asset_vol = asset_vol_new
break
asset_value = asset_value_new
asset_vol = asset_vol_new
# Final d1 and d2
d1 = (np.log(asset_value / debt) + (rf_rate + 0.5 * asset_vol**2) * maturity) / (
asset_vol * sqrt_t
)
d2 = d1 - asset_vol * sqrt_t
default_prob = float(sp_stats.norm.cdf(-d2))
# Credit spread: risky yield minus risk-free rate
# D_risky = V - E (market value of debt)
debt_market = asset_value - equity
if debt_market > 0 and debt > 0:
risky_yield = -np.log(debt_market / debt) / maturity
spread = risky_yield - rf_rate
else:
spread = 0.0
return {
"asset_value": float(asset_value),
"asset_vol": float(asset_vol),
"d1": float(d1),
"d2": float(d2),
"distance_to_default": float(d2),
"default_probability": default_prob,
"credit_spread": float(max(spread, 0.0)),
}
[docs]
def altman_z_score(
working_capital: float,
total_assets: float,
retained_earnings: float,
ebit: float,
market_cap: float,
total_liabilities: float,
sales: float,
) -> dict[str, float | str]:
"""Altman Z-Score for bankruptcy prediction.
The Z-Score combines five accounting ratios into a single
discriminant score that classifies firms by financial health.
Despite being from 1968, it remains one of the most widely used
credit screening tools.
Interpretation:
- **Z > 2.99** ("safe"): Firm is financially healthy.
Default probability is very low (< 1% over 2 years).
- **1.81 <= Z <= 2.99** ("grey zone"): Ambiguous.
Firm could go either way. Warrants deeper analysis.
- **Z < 1.81** ("distress"): High bankruptcy risk.
Historically, ~95% of firms that defaulted had Z < 1.81
one year prior.
Component interpretation:
- **x1** (WC/TA): Liquidity. Negative = current liabilities
exceed current assets.
- **x2** (RE/TA): Cumulative profitability and firm age.
Young firms have low retained earnings.
- **x3** (EBIT/TA): Operating profitability.
- **x4** (Market Cap/TL): Market leverage.
- **x5** (Sales/TA): Asset turnover efficiency.
When to use:
- Quick screening of a large universe of firms.
- As a factor in multi-factor credit models.
- For early warning systems.
Limitations:
- Designed for publicly traded manufacturing firms.
- Does not capture market-implied information (use
``merton_model`` for that).
- Accounting data can be manipulated.
Parameters:
working_capital: Current assets minus current liabilities.
total_assets: Total assets.
retained_earnings: Cumulative retained earnings.
ebit: Earnings before interest and taxes.
market_cap: Market capitalisation of equity.
total_liabilities: Total liabilities.
sales: Net sales / revenue.
Returns:
Dictionary with keys:
- ``z_score``: The computed Z-Score.
- ``zone``: One of ``"safe"`` (Z > 2.99), ``"grey"``
(1.81 <= Z <= 2.99), or ``"distress"`` (Z < 1.81).
- ``x1`` .. ``x5``: Individual component ratios.
"""
if total_assets <= 0:
raise ValueError("total_assets must be positive")
if total_liabilities <= 0:
raise ValueError("total_liabilities must be positive")
x1 = working_capital / total_assets
x2 = retained_earnings / total_assets
x3 = ebit / total_assets
x4 = market_cap / total_liabilities
x5 = sales / total_assets
z = 1.2 * x1 + 1.4 * x2 + 3.3 * x3 + 0.6 * x4 + 1.0 * x5
if z > 2.99:
zone = "safe"
elif z < 1.81:
zone = "distress"
else:
zone = "grey"
return {
"z_score": float(z),
"zone": zone,
"x1": float(x1),
"x2": float(x2),
"x3": float(x3),
"x4": float(x4),
"x5": float(x5),
}
[docs]
def default_probability(
rating_transitions: np.ndarray,
horizon: int,
) -> np.ndarray:
"""Cumulative default probability from a rating transition matrix.
Computes the probability of eventually defaulting within `horizon`
periods, starting from each non-default rating. This is done by
raising the one-period transition matrix to the `horizon`-th power
and reading off the default column.
Interpretation:
- The output is a vector where each element is the cumulative
default probability for a given starting rating.
- AAA will have the smallest PD; CCC the largest.
- Compare to historical default rates published by Moody's
or S&P to calibrate.
- Use with ``expected_loss`` for portfolio credit risk.
When to use:
- Converting a rating agency transition matrix into PDs for
capital calculations.
- Stress testing: modify the transition matrix (increase
downgrade probabilities) and re-compute PDs.
The last row/column of the transition matrix is assumed to represent
the *default* (absorbing) state.
Parameters:
rating_transitions: Square transition matrix of shape ``(n, n)``
where element ``[i, j]`` is the one-period probability of
migrating from rating *i* to rating *j*. Rows must sum to 1.
horizon: Number of periods to compound over (e.g., 5 for 5-year
cumulative PD).
Returns:
1-D array of cumulative default probabilities for each non-default
rating, length ``n - 1``.
Example:
>>> import numpy as np
>>> # Simple 3-state matrix: AAA, BBB, Default
>>> T = np.array([[0.95, 0.04, 0.01],
... [0.02, 0.90, 0.08],
... [0.00, 0.00, 1.00]])
>>> pd_5yr = default_probability(T, horizon=5)
>>> print(f"5yr PD from AAA: {pd_5yr[0]:.4f}")
>>> print(f"5yr PD from BBB: {pd_5yr[1]:.4f}")
"""
from wraquant.core._coerce import coerce_array
if horizon < 1:
raise ValueError("horizon must be >= 1")
mat = np.asarray(rating_transitions, dtype=float)
if mat.ndim != 2 or mat.shape[0] != mat.shape[1]:
raise ValueError("rating_transitions must be a square matrix")
# Compound the matrix over the horizon
compounded = np.linalg.matrix_power(mat, horizon)
# Default probabilities: last column, excluding the default state row
return compounded[:-1, -1].copy()
[docs]
def credit_spread(
default_prob: float,
recovery_rate: float,
rf_rate: float = 0.0,
) -> float:
"""Implied credit spread from a default probability.
Converts a default probability and recovery rate into the yield
spread that compensates investors for bearing credit risk. This
is the theoretical "fair value" spread -- compare to market spreads
to identify cheap or expensive credit.
The formula: spread = -ln(1 - PD * LGD), where LGD = 1 - R.
For small PD, this simplifies to spread ~ PD * LGD.
Interpretation:
- Output is annualized as a decimal (0.01 = 100 bps).
- Multiply by 10,000 for basis points.
- If market spread > model spread: bond is cheap (excess
compensation for credit risk).
- If market spread < model spread: bond is expensive or the
model PD is too high.
Parameters:
default_prob: Annualized probability of default (e.g., 0.02
for 2% annual PD).
recovery_rate: Recovery rate in [0, 1]. Investment grade
typically 0.40-0.50; high yield 0.25-0.40.
rf_rate: Risk-free rate (unused in simple model but accepted
for API consistency).
Returns:
Annualized credit spread as a decimal fraction. Multiply by
10,000 for basis points.
Example:
>>> spread = credit_spread(0.02, 0.40)
>>> print(f"Spread: {spread*10000:.0f} bps") # ~120 bps
"""
if not 0 <= default_prob <= 1:
raise ValueError("default_prob must be in [0, 1]")
if not 0 <= recovery_rate <= 1:
raise ValueError("recovery_rate must be in [0, 1]")
lgd = 1.0 - recovery_rate
spread = -np.log(1.0 - default_prob * lgd)
return float(spread)
[docs]
def loss_given_default(
exposure: float,
recovery_rate: float,
) -> float:
"""Expected loss given default.
LGD = EAD * (1 - Recovery Rate). This is the dollar amount you
expect to lose if the borrower defaults.
Interpretation:
- A recovery rate of 0.40 means you recover 40 cents on the
dollar; LGD is 60% of exposure.
- Recovery rates vary by seniority: secured senior ~65%,
unsecured senior ~45%, subordinated ~25%.
- Use with ``expected_loss`` for Basel II/III capital calculations.
Parameters:
exposure: Exposure at default (EAD) -- the amount at risk.
recovery_rate: Expected recovery rate in [0, 1].
Returns:
Loss given default = exposure * (1 - recovery_rate).
"""
if not 0 <= recovery_rate <= 1:
raise ValueError("recovery_rate must be in [0, 1]")
return float(exposure * (1.0 - recovery_rate))
[docs]
def expected_loss(
pd_val: float,
lgd: float,
ead: float,
) -> float:
"""Expected loss (EL = PD x LGD x EAD).
The expected loss is the central formula of credit risk management
and Basel II/III regulatory capital calculation. It represents the
average loss you expect from a credit exposure.
Interpretation:
- EL is the mean of the loss distribution. It should be
covered by pricing (loan margins, bond spreads) rather than
capital reserves.
- Capital reserves cover the unexpected loss (UL), which is
the tail beyond EL.
- For a portfolio, EL is additive: sum over all exposures.
Parameters:
pd_val: Probability of default (annualized, e.g., 0.02 for 2%).
lgd: Loss given default as a fraction of EAD (e.g., 0.45 for
45% loss rate).
ead: Exposure at default (dollar amount at risk).
Returns:
Expected loss in the same units as *ead*.
Example:
>>> el = expected_loss(pd_val=0.02, lgd=0.45, ead=1_000_000)
>>> print(f"Expected loss: ${el:,.0f}") # $9,000
"""
if not 0 <= pd_val <= 1:
raise ValueError("pd_val must be in [0, 1]")
if lgd < 0:
raise ValueError("lgd must be non-negative")
return float(pd_val * lgd * ead)
[docs]
def cds_spread(
default_intensity: float,
recovery_rate: float,
maturity: float,
) -> float:
"""Fair CDS spread from a constant hazard rate (default intensity).
Computes the breakeven CDS premium by equating the expected
protection leg (what the protection seller pays at default) with
the expected premium leg (what the protection buyer pays over time).
Under a constant hazard rate model, the fair spread is approximately
lambda * (1 - R), but this function uses the exact continuous-time
formula with quarterly premium payments for greater accuracy.
Interpretation:
- The output is the annualized spread (decimal). Multiply by
10,000 for basis points.
- Compare to market CDS spreads to detect relative value.
- If model spread > market spread: protection is cheap (market
underestimates default risk).
- If model spread < market spread: protection is expensive or
there is a risk premium.
- CDS spreads are approximately equal to bond spreads over
swaps (CDS-bond basis ~ 0 in normal markets).
When to use:
- Pricing CDS contracts given a calibrated hazard rate.
- Calibrating hazard rates from market CDS spreads (invert
numerically).
- Converting between PD and spread for credit analysis.
Parameters:
default_intensity: Constant hazard rate (lambda), annualized.
E.g., 0.02 means a 2% probability of default per year.
recovery_rate: Recovery rate in [0, 1]. Standard assumption
is 0.40 for senior unsecured corporate debt.
maturity: CDS maturity in years (standard: 1, 3, 5, 7, 10).
Returns:
Annualized CDS spread as a decimal fraction. Multiply by
10,000 for basis points.
Example:
>>> spread = cds_spread(0.02, 0.40, 5.0)
>>> print(f"5Y CDS: {spread*10000:.0f} bps") # ~120 bps
"""
if default_intensity < 0:
raise ValueError("default_intensity must be non-negative")
if not 0 <= recovery_rate <= 1:
raise ValueError("recovery_rate must be in [0, 1]")
if maturity <= 0:
raise ValueError("maturity must be positive")
# Simple approximation: spread = hazard_rate * (1 - R)
# More precise: integrate survival-weighted cashflows
n_steps = max(int(maturity * 4), 1) # quarterly steps
dt = maturity / n_steps
lam = default_intensity
protection_leg = 0.0
premium_leg = 0.0
for i in range(1, n_steps + 1):
t = i * dt
surv = np.exp(-lam * t)
surv_prev = np.exp(-lam * (t - dt))
# Protection leg: (1 - R) * prob of default in [t-dt, t]
protection_leg += (1.0 - recovery_rate) * (surv_prev - surv)
# Premium leg: spread * dt * survival probability at t
premium_leg += dt * surv
if premium_leg < 1e-15:
return float(lam * (1.0 - recovery_rate))
return float(protection_leg / premium_leg)