Calibrate the Black–Scholes–Merton (BSM) structural model for equity/credit, and numerically validate the core identities (pricing, delta–leverage, martingale discipline, PD vs simulation, and Breeden–Litzenberger density mass). Dependency-light, deterministic, and production-friendly.
- Why
- Features
- Math at a Glance
- Install
- Quick Start (CLI)
- Example Outputs
- Python API
- Numerical Validations
- Units, Assumptions, and Scaling
- Performance & Stability
- Security Posture
- Tests
- Project Layout
- Roadmap
Analytical derivations are necessary but not sufficient. Operational trust requires:
- Parameter recovery that actually meets both price and leverage constraints,
- Verified martingale behavior under risk-neutral discretization,
-
Default probability matches simulation under
$\mathbb{Q}$ , - Risk-neutral density integrates to 1 on a sufficiently broad strike grid,
- Transparent diagnostics: residuals, conditioning, and Jacobian sign.
This project provides all of that in a compact, auditable package.
-
Structural calibration of
$(V,\sigma_A)$ from observed$(E,\sigma_E)$ with robust bracketed solvers. -
Credit metrics:
$\mathrm{DD}=d_2$ ,$\mathrm{PD}=\Phi(-d_2)$ , risky debt$D_0$ , and spread. - RN martingale check with unbiased step discretization.
-
PD simulation vs analytic (Merton default
$V_T<K$ ). - Breeden–Litzenberger density recovery with 5-point stencil FD.
- Jacobian determinant diagnostic for local uniqueness/conditioning.
- Zero dynamic code exec, no I/O side effects; prints JSON.
Dependency: numpy only.
-
Equity as call on assets with carry
$\delta$ :$$ E = V e^{-\delta T}\Phi(d_1)-K e^{-rT}\Phi(d_2),\quad d_1=\frac{\ln(V/K)+(r-\delta+\tfrac12\sigma_A^2)T}{\sigma_A\sqrt{T}},\ d_2=d_1-\sigma_A\sqrt{T}. $$
-
Delta–leverage (equity vol mapping):
$$ \sigma_E=\frac{V}{E}e^{-\delta T}\Phi(d_1),\sigma_A. $$
-
Merton PD:
$\mathrm{PD}=\Phi(-d_2)$ ; DD$=d_2$ . -
Risky debt:
$D_0=V e^{-\delta T}\Phi(-d_1)+K e^{-rT}\Phi(d_2)$ . -
RN martingale (unbiased step):
$$ S_{n+1}=S_n\exp!\big((r-q)\Delta-\tfrac12\bar h\Delta+\sqrt{\bar h\Delta},Z\big),\quad Z\sim\mathcal{N}(0,1). $$
-
BL RND:
$f^{\mathbb{Q}}(K)=e^{rT},\partial^2 C/\partial K^2$ (finite-difference on a wide strike grid).
python -m pip install numpyRun from source (no install):
# Calibrate
python -m mertonlab.cli calibrate --E 5e9 --sigmaE 0.35 --K 3e9 --r 0.04 --T 1.0 --delta 0.00
# Validate numerically
python -m mertonlab.cli validate --E 5e9 --sigmaE 0.35 --K 3e9 --r 0.04 --T 1.0 --delta 0.00 --paths 200000usage: python -m mertonlab.cli {calibrate,validate} [options]
calibrate:
--E <float> Equity market cap (E_obs)
--sigmaE <float> Equity volatility (annualized)
--K <float> Debt face at horizon
--r <float> Risk-free rate (continuous)
--T <float> Horizon in years
--delta <float> Asset payout/carry
validate:
(same params) + --paths <int> # Monte Carlo paths (default 200000)
All outputs are JSON to stdout (easy to pipe into logs or dashboards).
Calibrate:
{
"V_hat": 7882367691.02606,
"sigma_A_hat": 0.2220148923936331,
"d1": 4.642307250584444,
"d2": 4.42029235819081,
"DD": 4.42029235819081,
"PD": 4.928372857315733e-06,
"D0": 2882367691.02606,
"spread": 2.1733203695367667e-07,
"pricing_residual": 0.0,
"vol_residual": -7.216449660063518e-16,
"detJ": 1.576407070515039
}Notes: residuals are numerically zero (model hits both price and leverage), and
$\det J>0$ indicates locally unique, well-conditioned solution.
Risk-neutral density mass (wide grid)
For BL density, use a sufficiently broad strike grid. With
∫ f^Q(K) dK ≈ 1.000000000179
(See RND guidance for details.)
from mertonlab.structural import MertonInputs
from mertonlab.calibration import calibrate
from mertonlab.validate import numerical_validation
inputs = MertonInputs(
E_obs=5e9, sigma_E_obs=0.35,
K=3e9, r=0.04, T=1.0, delta=0.0
)
res = calibrate(inputs)
print(res.state.V, res.state.sigma_A, res.DD, res.PD, res.D0, res.spread)
rep = numerical_validation(inputs, n_paths=200000)
print(rep)Key objects:
CalibrationResult:state(V, sigma_A, d1, d2, E_model, sigma_E_model),DD,PD,D0,spread, residuals,detJ.ValidationReport: absolute pricing/vol errors, RN martingale error, PD sim gap, BL density mass error,jacobian_det.
-
Pricing & leverage residuals Solve for
$(V,\sigma_A)$ so that$$ E_{\text{model}}=E_{\text{obs}},\qquad \sigma_{E,\text{model}}=\sigma_{E,\text{obs}}. $$
Report
$|E_{\text{model}}-E_{\text{obs}}|$ and$|\sigma_{E,\text{model}}-\sigma_{E,\text{obs}}|$ . -
RN martingale discipline With the unbiased step,
$$ \mathbb{E}^{\mathbb{Q}}!\big[e^{-rT}S_T\big]=S_0 e^{-qT}. $$
Report absolute difference of the Monte Carlo estimate and RHS.
-
PD simulation vs analytic Under
$\mathbb{Q}$ for assets, simulate$V_T$ and estimate$\mathbb{P}(V_T<K)$ . Compare to$\Phi(-d_2)$ . -
BL density mass Recover
$f^\mathbb{Q}(K)=e^{rT},\partial^2 C/\partial K^2$ via a 5-point stencil and integrate over a wide strike range. Tip: For production-grade mass≈1, set:-
$K_{\min}\approx 10^{-3},V$ (or lower),$K_{\max}\in[6V,10V]$ , - stencil step
$h\approx 10^{-3}V$ , - grid size ≥ 3000 points.
The CLI’s default RND check is conservative and uses a compact grid as a guardrail; for a formal mass-=1 verification, use the API with a wide grid as above.
-
-
Jacobian determinant Evaluate the analytic
$\det J$ at the solution; positivity implies local uniqueness and good conditioning.
- Rates
$r,\delta$ are continuously compounded (annualized). Vols are annualized. - Horizon
$T$ is in years. - Input levels are in currency units; the calibration internally scales and clamps to avoid log/div-by-zero.
- Small-PD regimes require wider strike ranges for stable BL density recovery.
- Root-finding uses bracketed bisection for the inner price solve and secant for the outer volatility solve (with bracket expansion), ensuring robustness.
- Monte Carlo is fully vectorized. For most workloads,
$10^5$ paths per check is <1s on a modern CPU; scale as needed. - When calibrating very levered names or ultra-short maturities, consider increasing solver tolerances and strike ranges for RND.
- No network calls, no dynamic code execution, no implicit file I/O.
- CLI reads args and writes JSON to stdout only.
- Inputs are sanitized; numerical guards prevent overflow/underflow and division by zero.
A smoke test ensures calibration residuals are ~0 and numerical validations are within reasonable tolerances:
python -m pip install pytest numpy
pytest -qmertonlab/
mertonlab/
structural.py # closed-form Merton/BSM building blocks
calibration.py # robust parameter recovery + detJ
black_cox.py # Black–Cox survival (exponential barrier)
bl_density.py # Breeden–Litzenberger finite-diff RND
montecarlo.py # RN unbiased step + martingale check
validate.py # one-shot validation suite (JSON)
cli.py # CLI for calibrate/validate
tests/
test_calibrate.py # smoke test
README.md
- User-configurable strike grids and stencils in CLI for BL density.
- Optional Black–Cox PD integration in the validation suite.
- Confidence intervals for PD via binomial/CLT and bootstrap.
- Multiperiod RN checks and time-gridded variance schedules.
- Hooks for real-world
$\mathbb{P}$ volatility models (e.g., GARCH/DCC) feeding into scenario analysis.
Tip: For audit logs, run both commands and store the JSON alongside the exact input set. This gives you a reproducible, model-complete snapshot (params, conditioning, and validation) for every calibration.