|
| 1 | +""" |
| 2 | +Example 08: Credit Risk Analysis |
| 3 | +
|
| 4 | +Demonstrates MeridianAlgo's credit risk module: |
| 5 | +- Merton structural model: equity as call option on firm assets |
| 6 | +- Default probability term structure |
| 7 | +- CDS pricing and hazard rate bootstrapping |
| 8 | +- Portfolio expected loss and credit VaR |
| 9 | +- Z-spread and DV01 computation |
| 10 | +""" |
| 11 | + |
| 12 | +import numpy as np |
| 13 | +import pandas as pd |
| 14 | + |
| 15 | +from meridianalgo.credit import ( |
| 16 | + CreditDefaultSwap, |
| 17 | + CreditRiskAnalyzer, |
| 18 | + MertonModel, |
| 19 | + ZSpreadCalculator, |
| 20 | +) |
| 21 | + |
| 22 | + |
| 23 | +# --------------------------------------------------------------------------- |
| 24 | +# 1. Merton Structural Model |
| 25 | +# --------------------------------------------------------------------------- |
| 26 | + |
| 27 | +print("=" * 60) |
| 28 | +print("1. MERTON STRUCTURAL MODEL") |
| 29 | +print("=" * 60) |
| 30 | + |
| 31 | +# A leveraged firm: $500M equity, $800M debt, 35% equity vol |
| 32 | +model = MertonModel( |
| 33 | + equity_value=500e6, |
| 34 | + equity_volatility=0.35, |
| 35 | + debt_face_value=800e6, |
| 36 | + time_to_maturity=1.0, |
| 37 | + risk_free_rate=0.05, |
| 38 | +) |
| 39 | +result = model.calibrate() |
| 40 | + |
| 41 | +print(f"Input equity value: ${500e6/1e6:.0f}M") |
| 42 | +print(f"Input debt face value: ${800e6/1e6:.0f}M") |
| 43 | +print(f"Input equity vol: 35.00%") |
| 44 | +print() |
| 45 | +print(f"Implied asset value: ${result['asset_value']/1e6:.1f}M") |
| 46 | +print(f"Implied asset vol: {result['asset_volatility']:.2%}") |
| 47 | +print(f"Leverage ratio: {result['leverage_ratio']:.2%}") |
| 48 | +print(f"Distance to default: {result['distance_to_default']:.4f} sigma") |
| 49 | +print(f"Default probability: {result['default_probability']:.4%}") |
| 50 | +print(f"Expected recovery rate: {result['expected_recovery_rate']:.4%}") |
| 51 | + |
| 52 | +# Compare three firms: low, medium, and high leverage |
| 53 | +print("\n--- Leverage Comparison ---") |
| 54 | +firms = [ |
| 55 | + ("Low leverage", 800e6, 0.25, 200e6), |
| 56 | + ("Medium leverage", 500e6, 0.35, 800e6), |
| 57 | + ("High leverage", 200e6, 0.50, 1500e6), |
| 58 | +] |
| 59 | +for name, equity, ev, debt in firms: |
| 60 | + m = MertonModel(equity, ev, debt, time_to_maturity=1.0, risk_free_rate=0.05) |
| 61 | + r = m.calibrate() |
| 62 | + print(f" {name:20s} DD={r['distance_to_default']:6.3f} PD={r['default_probability']:.4%}") |
| 63 | + |
| 64 | + |
| 65 | +# --------------------------------------------------------------------------- |
| 66 | +# 2. Default Probability Term Structure |
| 67 | +# --------------------------------------------------------------------------- |
| 68 | + |
| 69 | +print("\n" + "=" * 60) |
| 70 | +print("2. DEFAULT PROBABILITY TERM STRUCTURE") |
| 71 | +print("=" * 60) |
| 72 | + |
| 73 | +model_ts = MertonModel( |
| 74 | + equity_value=400e6, |
| 75 | + equity_volatility=0.40, |
| 76 | + debt_face_value=900e6, |
| 77 | + time_to_maturity=1.0, |
| 78 | + risk_free_rate=0.05, |
| 79 | +) |
| 80 | +horizons = [0.25, 0.5, 1.0, 2.0, 3.0, 5.0, 7.0, 10.0] |
| 81 | +term_struct = model_ts.default_probability_term_structure(horizons) |
| 82 | + |
| 83 | +print(f"{'Horizon':>10} {'PD':>10} {'Annualized':>12}") |
| 84 | +for t, pd_val in term_struct.items(): |
| 85 | + ann_pd = 1 - (1 - pd_val) ** (1 / t) if t > 0 else 0 |
| 86 | + print(f"{t:>10.2f} {pd_val:>10.4%} {ann_pd:>12.4%}") |
| 87 | + |
| 88 | + |
| 89 | +# --------------------------------------------------------------------------- |
| 90 | +# 3. CDS Pricing |
| 91 | +# --------------------------------------------------------------------------- |
| 92 | + |
| 93 | +print("\n" + "=" * 60) |
| 94 | +print("3. CDS PRICING") |
| 95 | +print("=" * 60) |
| 96 | + |
| 97 | +# Constant hazard rate CDS |
| 98 | +cds = CreditDefaultSwap( |
| 99 | + hazard_rate=0.02, |
| 100 | + recovery_rate=0.40, |
| 101 | + risk_free_rate=0.05, |
| 102 | + maturity=5.0, |
| 103 | + payment_frequency=4, |
| 104 | +) |
| 105 | +r = cds.price() |
| 106 | + |
| 107 | +print(f"Hazard rate: {cds.hazard_rate:.4f}") |
| 108 | +print(f"Recovery rate: {cds.recovery_rate:.2%}") |
| 109 | +print(f"Fair spread: {r.fair_spread * 10000:.2f} bps") |
| 110 | +print(f"Risky annuity (DV01): {r.risky_annuity:.6f}") |
| 111 | +print(f"5yr survival prob: {r.survival_probability:.6f}") |
| 112 | + |
| 113 | +# Spread sensitivity to hazard rate |
| 114 | +print("\n--- Spread vs Hazard Rate ---") |
| 115 | +print(f"{'Hazard Rate':>14} {'5yr Spread (bps)':>18} {'5yr Survival':>14}") |
| 116 | +for h in [0.005, 0.010, 0.020, 0.040, 0.080, 0.150]: |
| 117 | + c = CreditDefaultSwap(h, recovery_rate=0.40, maturity=5.0) |
| 118 | + r = c.price() |
| 119 | + print(f" {h:12.3%} {r.fair_spread * 10000:>16.2f} {r.survival_probability:>14.6f}") |
| 120 | + |
| 121 | +# Recover hazard rate from market spread |
| 122 | +print("\n--- Bootstrap Hazard Rate from Market Spread ---") |
| 123 | +market_spread = 0.0180 |
| 124 | +cds_from_spread = CreditDefaultSwap.from_spread( |
| 125 | + spread=market_spread, |
| 126 | + recovery_rate=0.40, |
| 127 | + risk_free_rate=0.05, |
| 128 | + maturity=5.0, |
| 129 | +) |
| 130 | +print(f"Market spread: {market_spread * 10000:.1f} bps") |
| 131 | +print(f"Implied hazard: {cds_from_spread.hazard_rate:.6f}") |
| 132 | +check = cds_from_spread.price().fair_spread |
| 133 | +print(f"Verified spread: {check * 10000:.2f} bps (roundtrip error: {abs(check - market_spread)*1e6:.1f} micro-bps)") |
| 134 | + |
| 135 | +# Bootstrap full hazard curve |
| 136 | +print("\n--- CDS Curve Bootstrap ---") |
| 137 | +maturities = [1.0, 3.0, 5.0, 7.0, 10.0] |
| 138 | +market_spreads = [0.0080, 0.0120, 0.0150, 0.0175, 0.0210] |
| 139 | +hazard_curve = CreditDefaultSwap.bootstrap_hazard_curve( |
| 140 | + maturities, market_spreads, recovery_rate=0.40, risk_free_rate=0.05 |
| 141 | +) |
| 142 | +print(f"{'Maturity':>10} {'Market Spread':>16} {'Hazard Rate':>14}") |
| 143 | +for mat, spread, hazard in zip(maturities, market_spreads, hazard_curve.values): |
| 144 | + print(f" {mat:8.1f}y {spread * 10000:>14.1f}bps {hazard:>14.6f}") |
| 145 | + |
| 146 | + |
| 147 | +# --------------------------------------------------------------------------- |
| 148 | +# 4. Portfolio Credit Risk: EL, UL, Credit VaR |
| 149 | +# --------------------------------------------------------------------------- |
| 150 | + |
| 151 | +print("\n" + "=" * 60) |
| 152 | +print("4. PORTFOLIO CREDIT RISK") |
| 153 | +print("=" * 60) |
| 154 | + |
| 155 | +analyzer = CreditRiskAnalyzer() |
| 156 | + |
| 157 | +# Loan portfolio |
| 158 | +portfolio = pd.DataFrame({ |
| 159 | + "obligor": ["Corp A", "Corp B", "Corp C", "Corp D", "Corp E", |
| 160 | + "Corp F", "Corp G", "Corp H"], |
| 161 | + "rating": ["BBB", "BB", "B", "BBB", "A", |
| 162 | + "BB", "CCC", "A"], |
| 163 | + "pd": [0.0020, 0.0080, 0.0250, 0.0020, 0.0005, |
| 164 | + 0.0080, 0.1000, 0.0005], |
| 165 | + "lgd": [0.45, 0.50, 0.60, 0.40, 0.35, |
| 166 | + 0.50, 0.70, 0.35], |
| 167 | + "ead": [5e6, 3e6, 1e6, 8e6, 10e6, |
| 168 | + 2e6, 0.5e6, 15e6], |
| 169 | +}) |
| 170 | + |
| 171 | +portfolio["el"] = portfolio.apply( |
| 172 | + lambda r: analyzer.expected_loss(r["pd"], r["lgd"], r["ead"]), axis=1 |
| 173 | +) |
| 174 | +portfolio["ul"] = portfolio.apply( |
| 175 | + lambda r: analyzer.unexpected_loss(r["pd"], r["lgd"], r["ead"]), axis=1 |
| 176 | +) |
| 177 | +portfolio["credit_var_99"] = portfolio.apply( |
| 178 | + lambda r: analyzer.credit_var(r["pd"], r["lgd"], r["ead"], confidence=0.999), axis=1 |
| 179 | +) |
| 180 | + |
| 181 | +print(portfolio[["obligor", "rating", "pd", "lgd", "ead", "el", "credit_var_99"]] |
| 182 | + .to_string(index=False, float_format=lambda x: f"{x:,.0f}" if x > 100 else f"{x:.4f}")) |
| 183 | + |
| 184 | +result = analyzer.portfolio_expected_loss(portfolio[["pd", "lgd", "ead"]]) |
| 185 | +print(f"\nPortfolio Summary:") |
| 186 | +print(f" Total EAD: ${portfolio['ead'].sum()/1e6:.1f}M") |
| 187 | +print(f" Total Expected Loss: ${result['total_el']/1e3:.1f}K ({result['el_rate']:.3%} of EAD)") |
| 188 | +print(f" Total Unexpected Loss: ${result['total_ul']/1e3:.1f}K") |
| 189 | +print(f" Herfindahl Index: {result['herfindahl_index']:.4f}") |
| 190 | +print(f" Top-10 Concentration: {result['top10_concentration']:.2%}") |
| 191 | + |
| 192 | + |
| 193 | +# --------------------------------------------------------------------------- |
| 194 | +# 5. Z-Spread and DV01 |
| 195 | +# --------------------------------------------------------------------------- |
| 196 | + |
| 197 | +print("\n" + "=" * 60) |
| 198 | +print("5. Z-SPREAD AND DV01") |
| 199 | +print("=" * 60) |
| 200 | + |
| 201 | +# 5% coupon bond, 5-year maturity |
| 202 | +cash_flows = [50, 50, 50, 50, 1050] |
| 203 | +times = [1.0, 2.0, 3.0, 4.0, 5.0] |
| 204 | +risk_free_rates = [0.035, 0.038, 0.040, 0.042, 0.044] |
| 205 | + |
| 206 | +calc = ZSpreadCalculator(cash_flows, times, risk_free_rates) |
| 207 | + |
| 208 | +par_price = calc.theoretical_price(0.0) |
| 209 | +print(f"Risk-free par price: ${par_price:.4f}") |
| 210 | + |
| 211 | +market_prices = [par_price - 5, par_price - 2, par_price, par_price + 2, par_price + 5] |
| 212 | +print(f"\n{'Market Price':>14} {'Z-Spread (bps)':>16} {'DV01':>10}") |
| 213 | +for price in market_prices: |
| 214 | + z = calc.z_spread(price) |
| 215 | + dv01 = calc.dv01(z) |
| 216 | + print(f" ${price:10.4f} {z * 10000:>14.2f} {dv01:>10.4f}") |
| 217 | + |
| 218 | +print(f"\nDV01 at z=0: {calc.dv01():.4f} (approx ${calc.dv01() * 1000:,.2f} per $1M notional per bp)") |
0 commit comments