Fitting a smile: SABR in two seconds
How one recorded objective and an adjoint gradient turn overnight derivative-free smile calibration into a fit that finishes before you've let go of Enter.
Every page so far has priced a payoff against market parameters you already knew. A trading desk doesn't get that luxury — it sees today's quoted volatilities at a handful of strikes and has to find the SABR parameters that produced them, fast enough to matter before the market moves again. Derivative-free search treats every trial as a black box and needs a full extra valuation per parameter just to estimate a gradient by finite differences. What if the valuation itself could hand back its own gradient, for free, every time?
The whole story
Calibrator's own doc comment states its whole design in one line: "the
same machinery calibrates any recorded objective." Swap SabrHagan.blackVol
for a Heston characteristic function, a local-vol surface, or a curve, and
nothing about the L-BFGS driver changes — it only ever sees a named-input,
named-output tape. That reusability is also why Calibrator.java's own
class-doc example still writes SDouble, not ADouble — the same
leftover name from the SDouble → ADouble rename that
AadRecorder's own doc comment carried back in 1.1, and that
MultiOutput.java's class doc turned out to share too, found while
researching 6.2. Three source files now, with the identical stale example
— including, this time, the one file whose whole thesis is "swap the
model, keep the machinery": the machinery's own usage example is what's
out of date.
The objective, recorded once
HestonSabrCalibration records exactly one tape: three named parameters,
a fixed beta, and a sum of squared residuals against six market quotes:
Calibrator.Result r = Calibrator.of(rec -> {
ADouble alpha = rec.input("alpha", 0.20);
ADouble rho = rec.input("rho", 0.0);
ADouble nu = rec.input("nu", 0.30);
ADouble beta = rec.constant(BETA);
ADouble sse = rec.constant(0.0);
for (int i = 0; i < STRIKES.length; i++) {
ADouble d = SabrHagan.blackVol(rec, alpha, beta, rho, nu, F, STRIKES[i], T).sub(target[i]);
sse = sse.add(d.mul(d));
}
rec.output(sse);
})
.parameter("alpha", 0.20, 1e-4, 2.0)
.parameter("rho", 0.0, -0.999, 0.999)
.parameter("nu", 0.30, 1e-4, 5.0)
.solve();
SabrHagan.blackVol has two forms: a plain-double version that
generates the six target quotes (standing in for real market data here),
and this tape-level one, whose alpha, beta, rho and nu are
ADouble. Both implement the same 2002 Hagan et al. lognormal-vol
approximation; the tape version is the one Calibrator differentiates.
One adjoint sweep per iteration
Calibrator.solveLbfgs drives a box-projected L-BFGS: every iteration is
one setInput on the three parameters, one adjoint sweep for the SSE
value, and — from that same sweep — the full 3-wide gradient, backtracking
line search to keep every trial point inside its declared [lo, hi], and
a two-loop recursion over the last 7 (s, y) curvature pairs, a
number fixed in the code (private int history = 7) with no public
setter to change it. None of that cost scales with how many strikes are
in the smile or how many parameters are being fit — it's 2.1's
free-Greeks argument again, this time powering an optimizer instead of a
risk report: one extra reverse sweep buys the whole gradient, whether
there are 3 parameters or 30.
The real run
HestonSabrCalibration generates six target vols from a known
(alpha=0.284, rho=−0.31, nu=0.57), starts the fit from
(0.20, 0.0, 0.30), and recovers:
| parameter | target | recovered |
|---|---|---|
| alpha | 0.2840 | 0.2840 |
| rho | −0.3100 | −0.3100 |
| nu | 0.5700 | 0.5700 |
56 iterations, converged=true, residual SSE 6.355×10⁻²⁶, in 1.6
seconds on one CPU core — cpu-jit, no GPU anywhere in this page. Every
one of the six fitted vols matches its target to six printed decimals.
That precision comes with an asterisk worth stating plainly: the "market"
here is Hagan fitting Hagan — a smile generated by the exact formula being
calibrated back against it. A real, noisy market quote won't collapse the
residual to 1e-26; it'll stop wherever the model's own approximation
error and the market's own noise floor meet.
The plain-double host form of blackVol branches at the-money
(if (Math.abs(logFK) < 1e-12) return a * b;) to sidestep a 0/0 in the
z / xz term as strike → forward. The tape-level form doesn't attempt
that branch — its own doc comment says plainly "requires strike != forward
(use a small offset for the ATM point)," and the code backs it up: it
throws IllegalArgumentException immediately, during recording, rather
than trying to record a conditional path through the singularity. It's
the same shape of constraint 6.1's lattice ran into with Math.max — a
tape wants one fixed sequence of arithmetic, and a removable singularity
that needs a branch to remove doesn't fit that shape any more easily than
early exercise did.
Try it yourself
Add 0.05 (equal to F) as a seventh entry in STRIKES and rerun — predict
what happens before you do. It isn't a bad fit or a NaN; the tape-level
blackVol call inside the recording throws IllegalArgumentException: tape-level blackVol needs strike != forward the moment that strike is
recorded, before Calibrator ever gets to run a single L-BFGS iteration.
▶️ Run it
mvn -o -q -pl nablatensor-examples exec:java \
-Dexec.mainClass=com.nablatensor.examples.HestonSabrCalibration
Calibrator's engine defaults to cpu-jit and nothing in this example
overrides it — there's no engine string to pass.
⚠️ What this doesn't do
beta is fixed at 0.5 throughout, not calibrated — SABR's four
parameters are jointly under-identified from a single smile, and picking
beta by convention (or from a separate time-series estimate) rather than
fitting it alongside alpha, rho and nu is standard practice, not a
shortcut this engine invented. This page also doesn't calibrate to real,
noisy market quotes, doesn't fit a term structure of smiles (one T at a
time here), and doesn't touch the low-strike or negative-rate regime where
Hagan's lognormal approximation is known to misbehave and a shifted or
normal SABR variant is usually preferred instead.
What's next
→ Deeper: Calibrate SABR with an adjoint gradient
has the full recorded objective and output table this page walks through
— note its code sample still says SDouble, the same stale rename this
page's first sidenote traces.
→ Next: The Heston model and why calibration needs gradients —
same Calibrator, a model with no closed-form vol to fall back on.