Hull-White: swaptions from a root-find, not a tape
Jamshidian decomposition needs a 1-D root-find for the critical short rate and a cumulative normal for every zero-bond option — a third, structurally different reason a model can end up off the tape.
7.2's Heston price was noisy but still recordable — an adjoint sweep just
had to survive the Monte-Carlo estimator. 7.3's Heston characteristic
function was deterministic but needed sin/cos/a complex logarithm the
tape doesn't have. Hull-White's term-structure-consistent swaption price
breaks a third way: pricing it means running an actual iterative root-find
inside the pricer, plus a cumulative normal distribution function on the
result. A tape is a fixed sequence of arithmetic, recorded once; a root-find
doesn't know how many iterations it needs until it's run. How do you
calibrate a model whose own pricing formula contains a solver?
The whole story
HullWhiteCalibration's own doc comment states the reason plainly: "the
analytic swaption is not recordable (a root-find and N(x)), so this is
the numerical rather than the adjoint calibration route; the adjoint route
runs against the HullWhite1F Monte-Carlo swaption instead." HullWhite1F
is a real, separate "Seam 5" step block — the same composable pattern 7.2's
HestonModel used — built specifically to be tape-friendly by trading the
full term structure for a flat mean-reversion level b, so dV/da and
dV/dsigma come from one adjoint sweep when that simplification is
acceptable. This page's model doesn't make that trade, so it doesn't get
that sweep.
A curve fit with no solve, a swaption price with one
HullWhiteAnalytic.bondReconstitution reprices today's curve exactly by
construction — A(t,T) is built directly from the input curve, so there's
no separate theta(t) calibration step at all. Pricing a European swaption
is a different story. Jamshidian's trick turns a swaption into a portfolio
of zero-bond options, but only once you know the short rate r* at which
the underlying coupon bond is worth exactly par — and that has no closed
form:
private double solveCriticalRate(double expiry, double[] payTimes, double[] coupon) {
java.util.function.DoubleUnaryOperator couponBond = r -> {
double v = 0.0;
for (int i = 0; i < payTimes.length; i++) {
v += coupon[i] * bondReconstitution(expiry, payTimes[i], r);
}
return v - 1.0;
};
// bisection: widen the bracket if needed, then up to 200 halvings
A bisection search — widen the bracket while the sign doesn't change, then
up to 200 halvings — is a loop whose length depends on the data, not
something a tape (a fixed sequence of nodes, replayed identically every
time) can express. Every zero-bond call and put on top of that root also
needs Normal.cdf, a cumulative normal with no elementary closed form
either. Both are ordinary host-side double code here, and neither one
could be recorded even if the engine wanted to try.
A second Nelder-Mead, hand-built for two parameters
HullWhiteCalibration doesn't reuse 7.3's general transform.NelderMead.
It nests its own private, two-parameter-specialized simplex — three points
instead of n+1, (a, sigma) hardcoded instead of a double[]:
static Result minimise(java.util.function.DoubleBinaryOperator f, double s0, double s1,
double step0, double step1, int maxIter, double tol) {
double[][] p = { {s0, s1}, {s0 + step0, s1}, {s0, s1 + step1} };
// reflect / expand / contract / shrink, same four moves as 7.3 — written twice
Each quoted ATM normal vol becomes a target price through Bachelier
(feature F2), and the objective is the sum of squared residuals against
HullWhiteAnalytic.payerSwaption. Feasibility is enforced differently here
than in 7.3, though: the objective itself returns a sentinel for an invalid
point —
if (a <= 0.0 || sigma <= 0.0) {
return 1e18;
}
7.3's NelderMead.java class doc claims out-of-range points get "a large
penalty," but its actual code just clamps them onto the box boundary — a
real doc/code mismatch, confirmed by reading NelderMead.minimise directly.
This file is the other half of that story: HullWhiteCalibration's own
private simplex has no clamping logic anywhere, and instead does exactly
what 7.3's docstring described — a large sentinel value (1e18) returned
from inside the objective function itself whenever a or sigma strays
non-positive. Two independent Nelder-Mead implementations in the same
codebase, solving the same shape of problem, enforcing feasibility two
different ways — and the one whose own doc comment promises a penalty is
the one that doesn't deliver it.
The real run
HullWhiteCalibrationShowcase fits (a, sigma) to 6 co-terminal ATM
swaptions (expiries 1y–7y, tenors 9y down to 3y) on an upward
curve (zeros 2.6% → 3.5% over 1y–12y), where each quote carries a
deliberate idiosyncratic bump so the grid isn't perfectly one-factor
consistent:
| expiry | tenor | target price | model price |
|---|---|---|---|
| 1 | 9 | 0.019235 | 0.018993 |
| 2 | 8 | 0.022715 | 0.023027 |
| 3 | 7 | 0.024322 | 0.023985 |
| 4 | 6 | 0.023390 | 0.023254 |
| 5 | 5 | 0.021004 | 0.021388 |
| 7 | 3 | 0.015108 | 0.015135 |
131 iterations, converged=true, 57 ms, fitted a = 0.1846,
sigma = 133.7 bp/yr, price RMSE 2.695×10⁻⁴. The fitted curve reprices
the input exactly (P(0,10) model 0.71631421 against curve
0.71631421, to 8 decimals) — that part is never approximate, it's the
swaption fit that absorbs the grid's inconsistency. Run the same code
against a clean grid generated from a known (a*, sigma*) = (0.09, 0.0095)
with no idiosyncratic bump instead (the pinned test's own scenario) and it
recovers both parameters to the printed digit, RMSE 3.8×10⁻¹⁷,
converged=true — worth contrasting directly with 7.3's near-machine-
precision Nelder-Mead fit, which hit its iteration cap without ever being
certified: this simplex is two-dimensional, not five, and collapses far
enough to satisfy its own tolerance well before running out of iterations.
Try it yourself
The showcase's reference model uses sigma = 0.0095 (95bp/yr) to generate
the idiosyncratically-bumped quotes. Change it to 0.019 (190bp/yr) and
predict the fitted (a, sigma) before you run it. (a barely moves —
0.1847 against 0.1846 — but sigma comes back at 267.5 bp/yr,
almost exactly double, tracking the doubled input. The fit costs more to
find, though: iters=400 (the hard cap) and converged=false, RMSE
roughly doubling too, to 5.386×10⁻⁴.)
▶️ Run it
mvn -o -q -pl nablatensor-examples exec:java \
-Dexec.mainClass=com.nablatensor.examples.HullWhiteCalibrationShowcase
No engine string anywhere in this command — nothing on this page ever
touches AadRecorder, cpu, or cpu-jit.
⚠️ What this doesn't do
The 6-quote grid this page's headline numbers come from is deliberately
not fully one-factor consistent (per-node bumps of up to 3%), so the
fitted (a, sigma) is a best compromise across the grid, not a "true"
recovery the way the clean-grid contrast above is — read the RMSE, not
just the parameter values, when judging a real fit. A single-factor
short-rate model can't fit a volatility skew across strikes at one expiry
(everything here is ATM); Jamshidian's bisection implicitly assumes the
coupon bond's value is monotonic in r, true for Hull-White but not a
universal short-rate-model property; and nothing here touches a
shifted or negative-rate variant, though bFactor's and theta's own
a → 0 handling shows the codebase already cares about numerically
delicate limits elsewhere in this same class.
What's next
→ Deeper: Term-structure Hull-White: caplets, Jamshidian swaptions, and calibration
has the full API (caplet, cap, receiverSwaption) and the pinned test
tolerances this page's numbers are checked against.
→ Next: GARCH and maximum likelihood: a tape too big to JIT —
Module 7.6 closes out the calibration module with the one example that
isn't an option-pricing model at all.