Tutorial 1 — single firm, AAPL-style walk-through

This tutorial fits the Merton model to an Apple-like firm. We use a synthetic equity time series (so the docs build is hermetic), but the exact same code works on a real AAPL series fetched via Firm.from_yfinance("AAPL") once you pip install "merton[data]".

Set up

import numpy as np

import merton
from merton import Firm, MertonModel, fit

print("merton version:", merton.__version__)
merton version: 1.0.2

Build a synthetic AAPL series

We simulate one year of daily prices under geometric Brownian motion with a typical large-cap volatility of ~25 % and a small upward drift.

rng = np.random.default_rng(2026)

n_days = 252
sigma_E = 0.25
mu_E = 0.10
dt = 1.0 / 252.0
log_returns = rng.normal((mu_E - 0.5 * sigma_E**2) * dt, sigma_E * np.sqrt(dt), size=n_days)
equity_path = 3.0e12 * np.exp(np.cumsum(log_returns))  # starts ≈ $3T market cap

firm = Firm(
    equity=equity_path,
    debt_short=20e9,
    debt_long=90e9,
    rf=0.045,
    dividend_yield=0.006,
    horizon=1.0,
    ticker="AAPL_SYNTH",
)
print("equity range:", f"${equity_path.min()/1e12:.2f}T", "→", f"${equity_path.max()/1e12:.2f}T")
print("default point (KMV):", f"${float(firm.default_point_value())/1e9:.1f}B")
equity range: $2.89T → $4.48T
default point (KMV): $65.0B

Three calibration methods side-by-side

snapshot_firm = firm.replace(equity=float(equity_path[-1]), equity_vol=sigma_E)

methods = ["naive", "jmr_iterative", "vassalou_xing"]
print(f"{'method':<18}{'DD':>10}{'PD':>14}{'σ_A':>10}")
print("-" * 52)
for m in methods:
    result = fit(snapshot_firm, method=m)
    print(f"{m:<18}{result.dd:>10.4f}{result.pd:>14.6e}{result.asset_vol:>10.4f}")
method                    DD            PD       σ_A
----------------------------------------------------
naive                17.0505  1.733391e-65    0.2480
jmr_iterative        17.1771  1.971827e-66    0.2465
vassalou_xing        17.1771  1.971827e-66    0.2465

Full Duan MLE on the equity series

result = MertonModel(method="duan_mle").fit(firm)
print(result.summary())
MertonResult (AAPL_SYNTH, method=duan_mle)
  Distance-to-default     : 16.1658
  Probability of default  : 0.000000
  Implied spread (LGD=.6) : 0.00 bps
  Asset value             : 4,445,481,015,090.43
  Asset volatility (σ_A)  : 0.2617
  Asset drift (μ)         : 0.4210
  Default point           : 65,000,000,000.00
  Horizon (years)         : 1.0
  Solver converged        : True (3 iters)

The Duan MLE also returns asymptotic standard errors, which we can turn into confidence intervals on any quantity of interest:

ci = result.confidence_interval(level=0.95, method="asymptotic")
for name, interval in ci.items():
    print(f"  {name:<14} 95% CI = [{interval.lower:+.4g}, {interval.upper:+.4g}]")
  asset_vol      95% CI = [+0.2388, +0.2846]
  asset_drift    95% CI = [-0.09305, +0.935]
  dd             95% CI = [+14.73, +17.6]
  pd             95% CI = [-9.81e-58, +1.069e-57]

Bootstrapped confidence intervals

When the calibrator doesn’t return a Hessian (or we want a CI that doesn’t assume MLE asymptotics), the model can run a block bootstrap on the equity series:

bs_result = MertonModel(
    method="vassalou_xing",
    n_bootstrap=100,
    block_length=20,
    random_state=42,
).fit(firm)

bs_ci = bs_result.confidence_interval(level=0.95, method="bootstrap")
for name, interval in bs_ci.items():
    print(f"  {name:<14} bootstrap CI = [{interval.lower:+.4g}, {interval.upper:+.4g}]")
  asset_vol      bootstrap CI = [+0.2336, +0.2882]
  asset_drift    bootstrap CI = [-0.1142, +0.9556]
  dd             bootstrap CI = [+14.62, +18.4]
  pd             bootstrap CI = [+9.908e-76, +2.516e-48]

PD term structure

result.pd_term_structure(horizons=[1/12, 3/12, 6/12, 1, 3, 5])
horizon_years dd pd
0 0.083333 55.942197 0.000000e+00
1 0.250000 32.304314 3.042096e-229
2 0.500000 22.849039 7.469519e-116
3 1.000000 16.165817 4.393266e-59
4 3.000000 9.354370 4.204974e-21
5 5.000000 7.262154 1.904865e-13

Greeks at the calibrated operating point

g = result.greeks()
print(f"  equity Δ      : {g.equity_delta:.6f}")
print(f"  equity Vega   : {g.equity_vega:.6e}")
print(f"  equity Γ      : {g.equity_gamma:.6e}")
print(f"  ∂PD/∂L        : {g.pd_dleverage:.6e}")
print(f"  ∂PD/∂σ_A      : {g.pd_dvol:.6e}")
print(f"  ∂PD/∂r        : {g.pd_drate:.6e}")
  equity Δ      : 0.994018
  equity Vega   : 4.429977e-47
  equity Γ      : 8.566808e-72
  ∂PD/∂L        : 4.191535e-68
  ∂PD/∂σ_A      : 4.475663e-56
  ∂PD/∂r        : -2.724498e-57

What changes with real data?

Swap the synthetic series for a real AAPL pull:

firm = Firm.from_yfinance("AAPL", lookback="2y")  # needs merton[data]

…and re-run any of the cells above. The package interface stays identical.