Structural Credit Risk — The Merton Model, Distance-to-Default, and the KMV Extension

A Python module that prices credit risk with option theory. In Merton's (1974) framework a firm's equity is a European call option on the firm's asset value, struck at the face value of its debt: shareholders own the right, not the obligation, to pay off creditors at maturity and keep the residual. This single observation connects credit risk directly to Black-Scholes — but the option's underlying, the market value of the firm's assets, is unobservable, and so is its volatility. The module implements the iterative fixed-point solver that backs out both quantities simultaneously from observed market capitalisation and equity volatility, then computes distance-to-default, risk-neutral default probability, and model-implied credit spreads across the maturity curve. Equity data comes from Yahoo Finance, debt face values from SEC EDGAR XBRL filings, and risk-free rates from FRED. A cross-sectional engine ranks firms by distance-to-default and tests whether low-DD firms subsequently suffer wider CDS moves; a KMV-style mapping translates DD into empirical default frequencies that account for the fat tails the Gaussian misses. Built on Python 3.11+ using numpy, scipy, pandas, yfinance, plotly, streamlit, duckdb, and pydantic v2; packaged with hatchling and tested with pytest against deterministic round-trip fixtures.

I. Interactive Dashboard:

The dashboard below runs entirely in the browser via stlite (Streamlit on WebAssembly — no server required). Three firm presets span the credit spectrum — an investment-grade technology firm, a levered cyclical with a large captive finance arm, and a distressed retailer — with sliders for equity value, equity volatility, debt face value, horizon, risk-free rate, and asset drift. The four tabs show the fixed-point solver converging on implied asset value and volatility, the evolution of DD and PD over a synthetic two-regime equity path, the model-implied credit spread term structure against preset market CDS quotes, and a 20-firm cross-sectional ranking test. First load downloads Pyodide and may take 20–40 seconds; subsequent loads are cached.

II. Project Layout:

merton-credit/
├── pyproject.toml                              # Build config, deps, ruff + pytest settings
├── .env.example                                # DB_PATH, SEC_USER_AGENT
├── data/                                       # Populated by scripts/download_data.py (git-ignored)
│   └── merton.duckdb                           # DuckDB: equity + debt + rates + merton_outputs
├── scripts/
│   └── download_data.py                        # yfinance + EDGAR + FRED → DuckDB
├── src/merton_credit/
│   ├── data/
│   │   ├── schemas.py                          # Pydantic v2: EquityRecord, DebtRecord, RateRecord, MertonRecord
│   │   ├── fetchers.py                         # yfinance market cap, EDGAR XBRL debt, FRED rates
│   │   └── store.py                            # DuckDB init, upsert, read for all four tables
│   ├── merton/
│   │   ├── solver.py                           # Fixed-point solver for (V_A, sigma_A); time-series variant
│   │   └── metrics.py                          # DD, risk-neutral PD, risky debt, credit spread, recovery
│   ├── spreads/
│   │   └── term_structure.py                   # Spread/PD term structure; model-vs-market benchmark
│   ├── ranking/
│   │   └── cross_section.py                    # DD buckets, forward performance test, IC time series
│   ├── kmv/
│   │   └── edf.py                              # Stylised DD → EDF mapping (log-linear interpolation)
│   ├── report/
│   │   └── plots.py                            # Plotly: solver path, DD/PD history, term structure, scatter
│   ├── cli.py                                  # Typer CLI: fetch | solve | spreads | rank | dashboard
│   └── app.py                                  # Streamlit: 4 tabs (Solver, History, Spreads, Cross-Section)
└── tests/
    ├── conftest.py                             # Known-firm round-trip fixture + 20-firm universe (seed 42)
    ├── test_solver.py                          # Round-trip recovery, leverage grid, limiting cases
    ├── test_metrics.py                         # DD/PD monotonicity, debt+equity=assets, EDF shape
    ├── test_spreads.py                         # Hump shape, short-end collapse, rank benchmark
    └── test_ranking.py                         # Bucket assignment, forward IC sign, noise control
  
III. Data Sources:

Three observable inputs feed the model. Equity value: daily market capitalisation from yfinance (adjusted close × shares outstanding), with equity volatility estimated as the annualised rolling standard deviation of log returns. Debt face value: short- and long-term debt from SEC EDGAR's XBRL company-facts API — the same EDGAR plumbing used in the signals engine project — combined into the KMV default point \(F = \mathrm{STD} + 0.5 \cdot \mathrm{LTD}\), reflecting the empirical observation that firms default when asset value falls between current liabilities and total debt, not at total debt itself. Risk-free rate: the one-year constant-maturity Treasury yield from FRED for option discounting. All three land in a DuckDB database with explicit column lists on every insert.


# fetchers.py
def fetch_debt(ticker: str) -> pd.DataFrame:
    """Fetch short- and long-term debt from SEC EDGAR XBRL company facts.

    The KMV default point is short-term debt plus half of long-term debt:
    default_point = STD + 0.5 * LTD.
    """
    cik = lookup_cik(ticker)
    st_end, st_debt = first_available(["DebtCurrent", "LiabilitiesCurrent"])
    lt_end, lt_debt = first_available(["LongTermDebtNoncurrent", "LongTermDebt"])
    return pd.DataFrame([{
        "ticker": ticker,
        "period_end": max(st_end, lt_end).date(),
        "short_term_debt": st_debt,
        "long_term_debt": lt_debt,
        "default_point": st_debt + 0.5 * lt_debt,
    }])
  
IV. Equity as a Call Option on Firm Assets:

Merton's insight starts from the balance sheet at debt maturity \(T\). The firm owes creditors a face value \(F\). If the asset value \(V_T\) exceeds \(F\), shareholders pay the debt and keep \(V_T - F\); if not, they exercise limited liability, hand the firm to creditors, and walk away with nothing. The equity payoff is therefore:

\[ E_T = \max(V_T - F,\, 0) \]

— exactly a European call on \(V\) struck at \(F\). Assuming assets follow a geometric Brownian motion \(dV_t = \mu V_t\,dt + \sigma_A V_t\,dW_t\), today's equity value is the Black-Scholes call price with the asset value as underlying:

\[ E = V_A\,N(d_1) - F e^{-rT} N(d_2) \] \[ d_1 = \frac{\ln(V_A/F) + (r + \tfrac{1}{2}\sigma_A^2)\,T}{\sigma_A \sqrt{T}}, \qquad d_2 = d_1 - \sigma_A\sqrt{T} \]

Every Black-Scholes object has a credit interpretation: \(N(d_2)\) is the risk-neutral probability the firm survives (the call finishes in the money), \(N(-d_2)\) the probability of default, and the bondholders hold the complementary position — a risk-free bond minus a put on the firm's assets, which is why credit risk is often described as being short a put on the firm. Anyone who understands Black-Scholes already understands structural credit; the only new difficulty is that the underlying \(V_A\) and its volatility \(\sigma_A\) cannot be read off a screen.

Black-Scholes objectCredit interpretation
Underlying \(S\)Firm asset value \(V_A\) (unobservable)
Strike \(K\)Debt face value / default point \(F\)
Call priceEquity market capitalisation \(E\)
\(N(d_2)\)Risk-neutral survival probability
\(N(-d_2)\)Risk-neutral probability of default
Put option (via parity)Credit risk borne by bondholders
V. The Two-Equation System and the Fixed-Point Solver (merton/solver.py):

Two unknowns require two equations. The first is the call pricing equation above. The second comes from Itô's lemma: since equity is a function of the asset value, its instantaneous volatility is the asset volatility amplified by the option delta and leverage:

\[ \sigma_E\,E = \frac{\partial E}{\partial V}\,\sigma_A\,V_A = N(d_1)\,\sigma_A\,V_A \]

Observed equity value \(E\) and equity volatility \(\sigma_E\) pin down the pair \((V_A, \sigma_A)\). Rearranging both equations gives a natural fixed-point iteration map, started from the no-optionality guess \(V_0 = E + Fe^{-rT}\):

\[ V^{(k+1)} = \frac{E + F e^{-rT} N(d_2^{(k)})}{N(d_1^{(k)})}, \qquad \sigma_A^{(k+1)} = \frac{\sigma_E\,E}{V^{(k+1)}\,N(d_1^{(k)})} \]

Convergence is fast — typically 10–40 iterations to \(10^{-10}\) relative tolerance — because for realistic leverage the map is a strong contraction: \(N(d_1)\) is close to 1 for investment-grade firms and the dependence of \(d_1\) on \(V\) is logarithmic. The solver records the full iteration path for diagnostics, and a vectorised time-series variant re-solves the system on every trading day to produce a DD/PD history.


# solver.py
def solve_merton(E, sigma_E, F, r, T=1.0, tol=1e-10, max_iter=500) -> MertonResult:
    V = E + F * np.exp(-r * T)
    sigma_V = sigma_E * E / V

    for iterations in range(1, max_iter + 1):
        d1, d2 = _d1_d2(V, sigma_V, F, r, T)
        Nd1 = norm.cdf(d1)
        V_new = (E + F * np.exp(-r * T) * norm.cdf(d2)) / Nd1
        sigma_new = sigma_E * E / (V_new * Nd1)

        if abs(V_new - V) < tol * V and abs(sigma_new - sigma_V) < tol:
            V, sigma_V = V_new, sigma_new
            converged = True
            break
        V, sigma_V = V_new, sigma_new
  

An alternative estimation route — the iterated MLE of Duan (1994) or the KMV practice of inferring a whole year of daily asset values, recomputing asset volatility from their returns, and repeating until the volatility estimate stabilises — produces nearly identical results for liquid firms but behaves better when equity volatility is itself noisy. The two-equation solver is the standard textbook treatment and the right baseline.

VI. Distance to Default and Default Probability (merton/metrics.py):

With \((V_A, \sigma_A)\) in hand, the distance to default is the number of asset-volatility standard deviations between the expected log asset value at horizon and the default point, under the physical drift \(\mu\):

\[ DD = \frac{\ln(V_A/F) + (\mu - \tfrac{1}{2}\sigma_A^2)\,T}{\sigma_A\sqrt{T}} \]

Under the lognormal dynamics the physical default probability is \(N(-DD)\), and the risk-neutral default probability replaces \(\mu\) with \(r\): \(PD^{\mathbb{Q}} = N(-d_2)\). The distinction matters and runs in one direction: since investors demand \(\mu > r\) for holding risky assets, the risk-neutral measure shifts the asset drift down, making default more likely under \(\mathbb{Q}\) — risk-neutral PDs always exceed physical PDs, which is precisely the default risk premium embedded in credit spreads. Pricing uses \(N(-d_2)\); risk management and rating-style applications use \(DD\).

QuantityDriftFormulaUse
Distance to default\(\mu\) (physical)\(\bigl(\ln(V/F) + (\mu - \sigma_A^2/2)T\bigr)/\sigma_A\sqrt{T}\)Ranking, EDF mapping
Physical PD\(\mu\)\(N(-DD)\)Expected loss, capital
Risk-neutral PD\(r\)\(N(-d_2)\)Pricing, spreads

Both quantities inherit the model's key nonlinearity: because \(N(\cdot)\) is convex in the tail, a one-third drop in equity value or a spike in equity volatility moves PD far more than proportionally — credit deteriorates slowly, then suddenly. The dashboard's two-regime synthetic path makes this visible.

VII. Model-Implied Credit Spreads (merton/metrics.py):

Bondholders receive \(\min(V_T, F)\) at maturity: full face value if the firm survives, the residual assets if it defaults. Today's risky debt value is the asset value minus the equity call — or equivalently, by put-call parity, a risk-free bond minus a put:

\[ D = V_A\,N(-d_1) + F e^{-rT} N(d_2) = F e^{-rT} - \underbrace{P(V_A, F)}_{\text{default put}} \]

The continuously compounded yield is \(y = -\ln(D/F)/T\) and the model-implied credit spread follows directly:

\[ s(T) = y - r = -\frac{1}{T}\,\ln\!\Bigl( N(d_2) + \frac{V_A}{F e^{-rT}}\,N(-d_1) \Bigr) \]

The spread decomposes into probability of default times loss given default, both risk-neutral and both produced by the same five inputs \((V_A, \sigma_A, F, r, T)\). The model also yields the expected recovery rate conditional on default, \(\mathbb{E}^{\mathbb{Q}}[V_T \mid V_T < F]/F = V_A e^{rT} N(-d_1) / (F\,N(-d_2))\) — recovery is endogenous, an output of the asset distribution rather than an assumed constant, which distinguishes structural models from reduced-form ones.


# metrics.py
def credit_spread(V, sigma_V, F, r, T=1.0) -> float:
    """Model-implied credit spread in decimal: s = -ln(D/F)/T - r."""
    D = risky_debt_value(V, sigma_V, F, r, T)
    y = -np.log(D / F) / T
    return float(y - r)
  
VIII. The Spread Term Structure and the Credit Spread Puzzle (spreads/term_structure.py):

Evaluating \(s(T)\) across maturities produces the model's signature hump-shaped term structure. At the short end, spreads collapse to zero: a diffusion cannot reach a distant barrier in a week, so the model assigns essentially no default risk to short-dated debt of any solvent firm. Spreads peak at intermediate horizons where uncertainty about \(V_T\) is large relative to the drift, then decay at long maturities as the \((\mu - \sigma^2/2)T\) term dominates the log-leverage.

Comparing model spreads against market CDS quotes for a cross-section of firms exposes the well-documented credit spread puzzle: Merton spreads are far too low, especially at short maturities and high credit quality — market one-year spreads for investment-grade names run 30–80 bps where the model predicts fractions of a basis point. The gap reflects everything the model omits: jump risk, stochastic volatility, liquidity premia, taxes, and risk premia on the default event itself. The practical conclusion, and the reason KMV succeeded commercially where naive Merton pricing failed, is that the model's levels are biased but its ordering is highly informative — so the benchmark report emphasises Spearman rank correlation over mean error.


# term_structure.py
def benchmark_vs_market(model_spreads: pd.Series, market_spreads: pd.Series) -> dict:
    common = model_spreads.index.intersection(market_spreads.index)
    m, mk = model_spreads.loc[common], market_spreads.loc[common]
    rho, pvalue = spearmanr(m.values, mk.values)
    return {
        "level_bias_bps": float((m - mk).mean()),   # systematically negative
        "mae_bps": float((m - mk).abs().mean()),
        "spearman_rho": float(rho),                  # the quantity that matters
        ...
    }
  
IX. Cross-Sectional DD Ranking (ranking/cross_section.py):

The operational test of a structural model is predictive ordering: sort the universe by distance-to-default, bucket into quintiles, and examine what happens next. The module computes per-bucket means of a forward outcome — subsequent CDS spread change, a default indicator, or forward equity return — together with the cross-sectional Spearman information coefficient between DD and the outcome. With outcomes oriented so that larger means worse (spread widening, default), a working model delivers a significantly negative IC: high-DD firms stay quiet, low-DD firms widen. An ic_time_series helper repeats the test at every monthly rebalance to show the signal's stability through the cycle; structural DD signals are strongest exactly when they matter, in stressed regimes where leverage and volatility disperse across firms.


# cross_section.py
def forward_performance_test(dd: pd.Series, forward_outcome: pd.Series, n_buckets=5) -> dict:
    buckets = rank_by_dd(dd, n_buckets)              # bucket 1 = lowest DD = riskiest
    buckets["outcome"] = forward_outcome.loc[buckets.index]
    by_bucket = buckets.groupby("bucket").agg(
        mean_dd=("dd", "mean"), mean_outcome=("outcome", "mean"), n=("outcome", "size"))
    ic, ic_pvalue = spearmanr(dd.values, forward_outcome.values)
    return {"by_bucket": by_bucket, "spearman_ic": float(ic),
            "low_minus_high": float(by_bucket.loc[1, "mean_outcome"]
                                    - by_bucket.loc[n_buckets, "mean_outcome"])}
  
X. The KMV Extension: From DD to Empirical Default Frequency (kmv/edf.py):

The Gaussian tail is the model's weakest link. Taken literally, \(N(-DD)\) says a firm at \(DD = 4\) defaults with probability 0.003%; Moody's KMV's default database — hundreds of thousands of firm-years — shows realised one-year default frequencies near 0.5% at that DD, two orders of magnitude higher. Asset values jump, balance sheets are gamed, and volatility clusters; none of that lives in a lognormal diffusion. KMV's fix is pragmatic: keep the Merton machinery to compute DD (it aggregates leverage and asset risk into one number with the right economics), but discard the Gaussian map and replace it with an empirical lookup, the expected default frequency (EDF), estimated by bucketing the historical database by DD and counting defaults. The module ships a stylised version of this mapping — log-linear interpolation over a DD grid with a floor near 1 bp and a cap at 50% — reproducing its qualitative shape.

DDGaussian \(N(-DD)\)Stylised EDFApprox. rating
1.015.9%17%CCC
2.02.28%6.0%B
3.00.13%1.8%BB
4.00.003%0.50%BBB
5.00.00003%0.14%A
6.0≈00.04%AA

The divergence grows explosively with credit quality: for safe firms the Gaussian understates default risk by factors of \(10^3\)–\(10^5\). This is the same fat-tail phenomenon as deep out-of-the-money equity options trading far above Black-Scholes value — unsurprising, since a default by a high-DD firm is a deep out-of-the-money put exercise on its assets.

XI. Where the Model Breaks Down:

Each simplifying assumption fails in a specific, instructive way. Unobservable assets: \(V_A\) and \(\sigma_A\) are estimated, not measured, so all outputs inherit estimation noise from the equity vol input — using a 60-day realised vol makes DD jumpy, a 252-day vol makes it stale; option-implied vol is the cleaner choice when available. Simplified debt structure: real firms have maturity ladders, covenants, secured and subordinated tranches, and the ability to roll debt; collapsing this into a single zero-coupon face value at one horizon is the model's crudest step, only partially patched by the KMV default point. First-passage extensions (Black-Cox) let default happen at any time the asset value touches a barrier, not just at maturity. Constant interest rates: credit and rates are correlated — defaults cluster in recessions when rates fall — and Longstaff-Schwartz-type extensions add stochastic rates. No jumps: the diffusion's continuous paths make short-horizon default impossible and short spreads vanish; Zhou-style jump-diffusion asset dynamics restore realistic short-dated spreads. Reduced-form alternative: Jarrow-Turnbull and Duffie-Singleton sidestep the balance sheet entirely, modelling default as the first jump of an exogenous intensity process \(\lambda_t\) calibrated to market spreads — perfect fit, no economics. The structural/reduced-form split is a genuine trade-off: structural models explain why a firm defaults and extrapolate to unpriced credits; reduced-form models fit what is traded and price consistently. Desks use reduced-form for marking and hedging, structural for relative value and early warning.

XII. CLI — cli.py:

Five subcommands cover the pipeline from data ingestion to cross-sectional ranking. All commands read from and write to the same DuckDB file.


# Install
pip install -e ".[dev]"

# Download equity (yfinance), debt (SEC EDGAR), risk-free rate (FRED)
merton fetch --tickers "AAPL,MSFT,F,GM,T,VZ,DAL,AAL,CCL,M" --start 2018-01-01

# Solve the Merton model through time for one firm; stores DD/PD/spread history
merton solve F --horizon 1.0 --vol-window 252

# Model-implied credit spread term structure
merton spreads F

# Cross-sectional ranking of all solved firms by latest DD
merton rank --n-buckets 5

# Launch Streamlit server-side dashboard
merton dashboard
  
CommandKey optionsOutput
merton fetch--tickers, --start, --dbEquity, EDGAR debt, FRED rates upserted to DuckDB
merton solveTICKER, --horizon, --vol-windowDaily \(V_A\), \(\sigma_A\), DD, PD, spread; Rich summary table
merton spreadsTICKERSpread + PD term structure across 8 maturities
merton rank--n-bucketsRich table: ticker, DD, EDF, spread, risk bucket
merton dashboardLaunches streamlit run src/merton_credit/app.py
XIII. Test Suite:

All 41 tests are fully offline and deterministic. The central fixture is a round-trip: fix a known firm (\(V_A = 100\), \(\sigma_A = 0.25\), \(F = 60\), \(r = 4\%\), \(T = 1\)), generate the observable pair \((E, \sigma_E)\) from the Merton equations, and verify the solver recovers the true asset value and volatility to relative tolerance \(10^{-6}\) — repeated across a leverage grid from \(F = 10\) to \(F = 90\). Limiting cases pin the boundaries: as \(F \to 0\), equity value converges to asset value and \(\sigma_E \to \sigma_A\). Metric tests assert the economics — DD decreasing in leverage and volatility, risk-neutral PD exceeding physical PD whenever \(\mu > r\), debt plus equity equal to assets to \(10^{-10}\), spreads positive and increasing in volatility, and the EDF table fatter-tailed than the Gaussian at every \(DD \geq 4\). Term-structure tests verify the hump shape and the short-end collapse; ranking tests construct a 20-firm universe (seed 42) with outcomes wired inversely to DD and assert the Spearman IC is below −0.5, with a pure-noise control asserting no spurious signal.


# test_solver.py — the core invariant
def test_roundtrip_across_leverage_grid(self):
    for F in [10.0, 40.0, 70.0, 90.0]:
        E = equity_value(100.0, 0.3, F, 0.03, 1.0)
        sigma_E = equity_vol(100.0, 0.3, F, 0.03, 1.0)
        res = solve_merton(E, sigma_E, F, 0.03, 1.0)
        assert res.asset_value == pytest.approx(100.0, rel=1e-5), f"F={F}"
        assert res.asset_vol == pytest.approx(0.3, rel=1e-5), f"F={F}"

# test_metrics.py — balance sheet identity
def test_debt_plus_equity_equals_assets(self):
    V, sigma, F, r, T = 100.0, 0.3, 70.0, 0.03, 1.0
    E = equity_value(V, sigma, F, r, T)
    D = risky_debt_value(V, sigma, F, r, T)
    assert E + D == pytest.approx(V, rel=1e-10)