← learnModule 7 · Calibration6 min read

Bootstrapping a curve: zero-rate risk for free

A rates desk hedges by instrument PV01, but a risk system reports by zero-rate bucket. Recording the bootstrap recursion once turns the map between them into one adjoint sweep instead of a curve re-solved per quote.

Every calibration so far in this module has fit a model's own parameters. Bootstrapping a curve is a different problem wearing similar clothes: there is no model, just a stack of swap quotes that a discount curve and one or more forecast curves have to reprice exactly. A rates desk hedges each swap by its own PV01, but a risk system reports exposure by zero-rate bucket instead — someone has to compute the map between the two, d(zero rate) / d(quote), for every pillar against every quote. Re-bootstrapping the whole stack once per quote just to bump it would work, but it's the same one-Greek-at-a-time cost 2.1 already showed is avoidable.

The whole story

The two-stage bootstrap recursion — OIS discount curve first, then each forecast curve discounted on it — is recorded once, every quote a named input and every zero rate a named output. One MultiOutput evaluation on the plain cpu engine, not cpu-jit, reads back the whole d(zero)/d(quote) Jacobian as one reverse sweep per output. The real run: five OIS and five 3M-forecast par swaps bootstrap to a 25–29bp tenor basis; the 5y forecast zero's Jacobian row is near zero against distant OIS pillars and +1.0322 against its own quote; the adjoint matches a central bump to a relative error of 2.1×10⁻⁶.

Did you know?

MultiCurveBootstrap's own doc comment states the point in one line: the recorded recursion returns "the bucket-delta transformation a rates desk applies to turn instrument PV01s into zero-rate risk." A PV01 says how much one swap's value moves; a zero-rate bucket says how much one curve pillar moves — they're related by exactly this Jacobian, and it comes from the same recording that produced the curve, not a second calculation. (That same doc comment calls the recorded type SDouble, not ADouble — the same stale rename 7.1 traced through three other source files in this engine, now a fourth.)

Two closed-form stages, no solver anywhere

MultiCurveBootstrap never iterates to find a discount factor. Because the annual grid gives each swap exactly one new pillar, every stage's par-swap equation solves in closed form for that one unknown:

// annual par swap: P(0,T) = (1 - q * prefix_{<T}) / (1 + q)
df = rec.constant(1.0).sub(q.mul(prefix)).div(rec.constant(1.0).add(q));

Stage 1 builds the OIS discount curve this way from OIS deposits and par swaps. Stage 2 repeats the same closed-form move once per forecast tenor, except the float leg and annuity of those swaps discount on Stage 1's own oisDfByYear map — read directly in source, not assumed from the module's name, this is where a forecast zero rate's dependence on OIS quotes actually enters the tape, pillar by pillar, as the recursion runs. Every quote becomes a named rec.input(inst.label(), inst.quote()), and every zero rate becomes a named output (out.put("z:" + inst.label(), ...)) — one recording, both curves, all ten quotes and ten zero rates on the same tape.

One Jacobian row per reverse sweep

MultiCurveBootstrap.Kernel wraps that recording in a MultiOutput and keeps the compiled tape open, so the whole stack can be re-evaluated at shifted quotes — a bump cross-check, a scenario — without re-recording:

this.mo = MultiOutput.of(rec -> record(rec)).on("cpu").build();
// ...
public Result evaluate(Map<String, Double> quoteOverrides) {
  MultiOutput.Result r = mo.run(quoteOverrides, 1L, 0L);
  // one r.gradient(zeroLabel) per zero rate -> one Jacobian row each

Each of the ten zero rates gets one reverse sweep, and each sweep's gradient is that zero rate's full row against all ten quotes — read back by name, the same mechanism 2.2 first traced. Because an OIS zero rate's recording never touches a forecast quote at all, its row is exactly zero there by construction, not by a later filter: the Jacobian is block lower-triangular because the tape itself only has edges running one way.

Did you know?

Look at that .on("cpu") and it's easy to assume a typo — every other Monte-Carlo showcase in this section defaults to cpu-jit. It isn't one: evaluate() always calls mo.run(quoteOverrides, 1L, 0L) — exactly one scenario, every time, because there's nothing stochastic to replay. 4.1 already established that cpu-jit's bytecode-generation cost only pays for itself across many replays of the same tape; with a scenario count of 1, there's no "many" to amortize it against, so the plain scalar interpreter is the faster choice here, not the default one. This is the first module in this Learn section where cpu-jit genuinely isn't the right call.

The real run

MultiCurveBootstrapShowcase bootstraps five OIS par swaps against five 3M forecast par swaps, 1y through 5y, at a real ~25bp starting tenor basis:

yrOIS zero3M zerobasis (bp)
12.9559%3.2080%25.2
23.1037%3.3653%26.2
33.2031%3.4743%27.1
43.2732%3.5543%28.1
53.3340%3.6251%29.1

The basis widens from 25.2 to 29.1 bp across the curve — a real, computed number, not an assumption from the ~25bp input quotes, since the zero rate is a nonlinear transform of the bootstrapped discount factors. The 5y 3M forecast zero's Jacobian row is near zero against every OIS pillar except the closest ones, +0.0031 against OIS:swap:5, and +1.0322 against its own quote 3M:swap:5 — a diagonal entry just over 1.0, because a swap's own quote moves its own zero rate slightly more than one-for-one once the annuity effect is included. Wrote and ran a standalone probe (javac+java against the built classes, same approach as every prior module) taking an independent central bump (h=1e-6) of that same row: relative error 3.8×10⁻¹³ against the OIS entry, 2.1×10⁻⁶ against the diagonal one — both comfortably inside the 1e-5 the deeper doc claims, measured directly rather than taken on faith. Every one of the ten calibrating instruments reprices to its own input quote to 8 decimal places.

Try it yourself

The showcase reprices a 4y receive-fixed swap struck at 3.30% (par 3.6100%, annuity 3.6963, PV -0.01146). Change the maturity from 4 to 5 and predict the new PV before you run it — both the par rate and the annuity move. (par 3.6800%, annuity 4.5428, PV -0.01726: further underwater, because the 5y par rate has risen further above the fixed 3.30% strike than the 4y par rate had.)

▶️ Run it

mvn -o -q -pl nablatensor-examples exec:java \
  -Dexec.mainClass=com.nablatensor.examples.MultiCurveBootstrapShowcase

No engine string to pass — MultiCurveBootstrap.Kernel hardcodes "cpu" internally, for the reason this page's second sidenote traces.

⚠️ What this doesn't do

This is the stylised annual construction CurveSet's own doc comment names directly: fixed and floating legs share one annual grid, so every swap adds exactly one pillar and every stage solves in closed form. Real desks need sub-annual float frequencies, interpolation inside the solve rather than only on the finished curve, and cross-currency basis, none of which are here. The deeper doc also flags that an analytic block Jacobian is a possible faster alternative to the recorded-and-replayed one this page walks through — YieldCurve.java's own doc comment even points at CurveBootstrap (the single-curve version) as the place that already uses one; MultiCurveBootstrap deliberately doesn't, favoring one general mechanism over a bespoke analytic derivation per curve shape.

What's next

→ Deeper: Multi-curve (OIS) bootstrap with an adjoint Jacobian has the full builder API and the pinned test tolerances this page's numbers are checked against. → Next: Hull-White: swaptions from a root-find, not a tape — Module 7.5 prices a short-rate model whose own formula contains a solver, so it comes off the tape entirely rather than staying on it.


Questions or corrections? open an issue