A credit risk toolkit from first principles in pure Python + NumPy/SciPy. The Merton structural model (equity as a call on the firm's assets), reduced-form hazard/intensity machinery, CDS legs with the par-spread bootstrap, and defaultable bond pricing — every formula derived from its definition, no credit or pricing library underneath.
56/56 tests verifying algebraic identities — three independent debt constructions forced to agree, the credit triangle emerging from annuity cancellation, round-trip bootstraps — never a comparison to another library.
$ pytest tests/
=========================== 56 passed in 0.46s ============================
Every identity in this repo was adversarially verified by a multi-agent workflow before a line of code was written — 39 claims were independently checked and 9 had subtle errors corrected (e.g. the t→0 branch-consistency check must run at t=1e-12, not 1e-8, where the discounting term K·r·t still dwarfs rtol 1e-12; the K→0 debt bound needs a derived half-ulp slack ε·V when debt is built off the balance sheet; a 0<R<1 risky bond is non-monotone in hazard — decreasing only below λ*≈1.05 — while R=1 is globally increasing; the hazard curve's behavior past its last knot is a convention that must be pinned by its own test, not assumed). See "How this was built".
Credit is where option theory and actuarial math meet, and almost nobody implements it honestly — practitioners call a CDS pricer, students memorize "spread ≈ λ(1−R)" without knowing when it's exact. This repo is the opposite: equity really is priced as a Black-Scholes call on assets, the protection leg really is summed period by period, and every test pins an algebraic identity — two or three independent constructions of the same number forced to agree.
The structural side is option algebra (the BS block mirrors
monte-carlo-lab's
options.py); the discounting spine is
tvm-lab; the binomial-tree bond
side of fixed income lives in
fixedalt-lab. This lab fills
the missing pillar: default.
| Module | Topic | Headline functions |
|---|---|---|
bs.py |
Black-Scholes building block | call_price, put_price, d1, d2, norm_cdf |
merton.py |
Structural model (Merton 1974) | equity_value, debt_value, default_probability, distance_to_default, credit_spread, equivalent_hazard, analyze, mc_default_probability |
hazard.py |
Reduced-form survival | HazardCurve (piecewise-flat), survival, forward_survival, default_density, expected_loss |
cds.py |
CDS legs + bootstrap | rpv01, protection_leg_pv, par_spread, par_spread_flat_continuous, price_cds, bootstrap_hazard_curve |
riskybond.py |
Defaultable bonds | risky_bond_price, risky_zcb_price_flat, zcb_credit_spread |
from credit import (
analyze, equity_value, default_probability, # structural
HazardCurve, expected_loss, # reduced form
par_spread, bootstrap_hazard_curve, # CDS
risky_bond_price, zcb_credit_spread, # bonds
)
# Merton: a firm with V=100, debt face K=80 due in 1y, asset vol 20%
res = analyze(100, 80, r=0.05, sigma_v=0.2, t=1)
res.equity # 24.589 — a BS call on the assets
res.debt # 75.411 — = V - E = K*e^(-rT) - put (parity!)
res.default_probability # 10.28% — Phi(-d2)
res.credit_spread # 90.7 bps
# Reduced form: bootstrap a hazard curve from CDS quotes...
curve = bootstrap_hazard_curve(
tenors=(1.0, 3.0, 5.0), spreads=(0.0060, 0.0125, 0.0210),
recovery=0.4, r=0.03, freq=4)
curve.survival(5.0) # P(no default by 5y)
# ...and price a different instrument off the same curve
risky_bond_price(0.06, curve, recovery=0.4, r=0.03, maturity=5.0, freq=4)
# CFA credit framing
expected_loss(pd=0.04, recovery=0.6, ead=10_000_000) # 160,000 = PD x LGD x EAD56 tests, every one an identity the formula must satisfy. Highlights:
- Put-call parity
call − put + K·e^(−rT) == S— emerges via Φ(x)+Φ(−x)=1, never built in - Gamma symmetry
S·φ(d1) == K·e^(−rT)·φ(d2)— catches the ±σ²/2 sign swap parity can't see d1 − d2 == σ√Tas a real identity (d2 implemented from its own formula)- ATM-forward closed form
call == S·(2Φ(σ√T/2)−1)+ the Brenner-Subrahmanyam bound
- Debt three ways:
V − call == K·e^(−rT) − put == K·e^(−rT)·Φ(d2) + V·Φ(−d1)— balance sheet, parity route, and survival+recovery decomposition, all independent - Homogeneity: everything depends on (V, K) only through leverage (GBM scale invariance)
- σ→0 trichotomy: PD → 0/1 deterministically, with the knife edge V == K·e^(−rT) landing on 1/2
- Asset substitution is exact: E(σ)+D(σ) == V for every σ — vol is a pure wealth transfer
- The distressed counterexample: PD is not monotone in σ for an underwater firm (pinned, so nobody "generalizes" the test)
- spread ≥ 0 with equality iff PD == 0, at the floating-point level it actually holds
equivalent_hazard == −ln(1−PD)/Tis a strict upper bound on the spread (Merton debt embeds recovery); the zero-recovery piece maps exactly:e^(−(r+λeq)T) == e^(−rT)(1−PD)- One MC band: simulated GBM default frequency lands within 4σ of Φ(−d2)
- Axioms (S(0)=1, non-increasing) + multiplicativity
S(t₂) == S(t₁)·S(t₁,t₂)with the forward survival accumulated by its own loop (never the ratio) - Knot-refinement invariance: splitting segments without changing λ never changes S
- PD via
−expm1(−H): full relative accuracy at λ=1e-9 where1−exploses 8 digits ∫λ(t)S(t)dt == PD(T)by quadrature — calculus the implementation never does
- Both legs vs geometric closed forms derived on paper
- The credit triangle:
par_spread_flat_continuous == λ(1−R)EXACTLY, invariant in r and T — coded as the ratio of two closed-form legs so the cancellation is emergent, not an echo - The discrete par spread has its own r- and T-invariant closed form
(1−R)(e^(λΔ)−1)·freq, converging to the triangle from above at rate(1−R)λ²/(2·freq) - Protection telescopes at r=0:
(1−R)(1−S(T))for any curve - Bootstrap round trip: λ → spreads → bootstrap → λ at rtol 1e-9 (honestly brentq-limited), plus a single-tenor analytic inverse the root-finder cannot fake
- The cleanest identity in credit: zero-coupon, zero-recovery, flat λ ⇒
P == e^(−(r+λ)T)for any payment frequency, so the yield spread IS the hazard rate - Bond–CDS decomposition:
bond == c·RPV01 + Z(T)S(T) + R·(zero-recovery protection leg)— the bond's standalone loop vs CDS primitives, exact because both share one payment-timing convention - R=1 is NOT riskless (timing trichotomy): equality only when recovery and redemption coincide (n=1); for n≥2, r>0 the bond is worth more — face paid early at default is discounted less
- λ→∞ pins where recovery lands on the grid:
P → R·e^(−rΔ)(period end, not 0) - End-to-end pipeline: CDS quotes → bootstrap → bond price, cross-checked against the generating curve
PYTHONPATH=. python examples/merton_structure.py # leverage ladder + asset substitution
PYTHONPATH=. python examples/credit_triangle.py # the triangle, exact + convergence
PYTHONPATH=. python examples/bootstrap_pipeline.py # quotes -> hazards -> bondcredit_triangle.py output (the invariance is the point):
Continuous premium: EXACT, invariant in r and T
r= 0% T= 1y s* = 1.2000000000%
r= 3% T= 5y s* = 1.2000000000%
r= 10% T= 30y s* = 1.2000000000%
Discrete premium: converges from ABOVE at O(1/freq)
freq par spread error vs triangle
1 1.21208040% 1.208e-04
4 1.20300501% 3.005e-05
252 1.20004762% 4.762e-07
bootstrap_pipeline.py reprices the input quotes to the basis point:
t S(t) PD(0,t) repriced spread
1 0.990062 0.993781% 60.0000 bps
5 0.832768 16.723195% 210.0000 bps
This repo was built with an adversarial multi-agent workflow before implementation:
- Design panel — three independent agents proposed the modules and identities from different angles (structural-model-first, intensity/CDS-first, testing-first).
- Synthesis — one canonical spec: 39 identities, precise signatures, 16 analytically-derived golden vectors (mpmath, 50 digits).
- Adversarial verification — one agent per identity tried to refute it. 9 of 39 were corrected before any code: the t→0 consistency scale, the K→0 half-ulp debt-bound slack, the non-monotone 0<R<1 bond, the hazard-curve extension convention, the bootstrap tolerance honestly tied to brentq's xtol, the corrected Merton golden literals (the originally proposed PD was 1 ulp off the correctly-rounded double).
- Implementation against the verified spec — debt routed through the put
(the small correction, never the difference of large numbers), spreads via
log1p, PD viaexpm1. - Adversarial code review — reviewers per dimension (math, numerical, API, tests), findings verified before applying.
The result: identities that are right because they were proven right, not because they happened to pass.
- v0.2.0 — accrual-on-default premium leg (half-period convention) + upfront/running quoting
- v0.2.0 alt — KMV-style calibration: solve (V, σ_V) from observed equity value and equity vol
- v0.3.0 — CDS index (portfolio) pricing; Gaussian copula default correlation
- v0.3.0 alt — stochastic hazard (CIR intensity) with closed-form survival
- v0.4.0 — counterparty CVA on the CDS itself (wrong-way risk demo)
monte-carlo-lab— the BS block here mirrors itsoptions.py; its GBM engine is the MC cross-checktvm-lab— the discounting spine under every legfixedalt-lab— term structure + binomial-tree bonds; this lab adds defaultconvexity-lab,pde-lab— the option-pricing siblingsvar-lab,port-lab— market risk and allocation; credit completes the risk triad
- Merton, R. C. (1974). On the Pricing of Corporate Debt: The Risk Structure of Interest Rates. Journal of Finance.
- O'Kane, D. (2008). Modelling Single-name and Multi-name Credit Derivatives. Wiley.
- Hull, J. (2018). Options, Futures, and Other Derivatives. Pearson. (Credit risk chapters.)
- Duffie, D. & Singleton, K. (2003). Credit Risk: Pricing, Measurement, and Management. Princeton.
MIT.