Assumes IR-00 and knowing what a swap and a bond are. No bootstrapping or numerical analysis required.
By the end you have $P(0,T)$ at 14 pillars, bootstrapped from cash deposits, FRAs and par swaps.
The problem
Pricing anything with future cash flows requires a zero-coupon bond price $P(0,T)$: the price today of one unit of currency delivered at $T$. The market does not quote it. It quotes rates on traded instruments, overnight lending, forward rate agreements and par swaps. Bootstrapping extracts $\{P(0,T_j)\}_{j=1}^{n}$ from those quotes.
$P(t,T)$: zero-coupon bond price at $t$ for maturity $T$; today $t = 0$.
$R(t,T) = -\ln P(t,T) / (T - t)$: zero rate, continuously compounded throughout, so $P(t,T) = e^{-R(t,T)(T-t)}$.
$F(t;\,T_\alpha, T_\beta)$: simply compounded forward rate over $[T_\alpha, T_\beta]$; $\tau(T_\alpha, T_\beta)$ is the day count fraction between the two dates.
$S_{\alpha,\beta}(0)$: par swap rate for fixed-leg dates $T_{\alpha+1}, \ldots, T_\beta$. A spot-starting $n$-year swap has $\alpha = 0$, $\beta = n$.
Market data
xvafoundations.data.sofr.INSTRUMENTS. Transcribed from website/data/ir-01-….js, xvafoundations git 6a5e731.
Step 1: Cash deposit
Lend 1 at time $0$, receive $1 + r\,\tau(0,T)$ at $T$, where $r$ is the quoted rate and $\tau(0,T) = \text{days}/360$ under ACT/360. By no-arbitrage the price today of one unit at $T$ is the reciprocal of that growth factor:
For the 7-day deposit at 4.33%: $\tau = 7/360 = 0.01944444$, so $P = 1/(1 + 0.0433 \times 0.01944444) = 0.99915876$, the first pillar, and $R(0,T) = -\ln(0.99915876)/0.01944444 = 4.3282\%$. Round $\tau$ to 0.019444 and the eighth digit moves to 0.99915878, which no longer matches the table. Brigo and Mercurio [1], Section 1.2.
Step 2: Forward Rate Agreements
A FRA locks a simply compounded forward rate $F(0;\, T_\alpha, T_\beta)$ over $[T_\alpha, T_\beta]$. By no-arbitrage, lending from $0$ to $T_\beta$ must return the same as lending to $T_\alpha$ and rolling forward at $F$:
If $P(0, T_\alpha)$ is already known, we rearrange to solve for $P(0, T_\beta)$:
Each FRA extends the curve by one accrual period, which is what rows 2 and 3 of Figure 1 are: each starts on the pillar the row above solved. See Brigo and Mercurio [1], Section 1.3.
The third FRA does not chain. It is the hatched stretch in Figure 1: $P(0,0.5)$ is interpolated between the 90-day pillar and the 270-day pillar this same FRA is solving for. Log-linear interpolation (below) makes $\ln P(0,0.5)$ the midpoint of $\ln P(0,0.25)$ and $\ln P(0,0.75)$, so the no-arbitrage relation becomes one equation in the single unknown $R(0,0.75)$:
The chained formula cannot produce that number by division, because the unknown sits on both sides. This one instrument is why the chapter solves the whole curve at once rather than instrument by instrument.
Step 3: Interest rate swaps
Beyond the FRA maturities the liquid instruments are par swaps. A swap exchanges two streams of payments on a notional that is never itself exchanged: one leg fixed at a rate agreed today, one floating with a published rate, and the side paying fixed is the payer, the side receiving it the receiver. In a SOFR OIS swap the floating leg is the compounded overnight rate, and both legs pay annually on the same dates.
The two legs
On $[T_\alpha, T_\beta]$ with notional $N$, the fixed leg pays $N \cdot K \cdot \tau_j$ at $T_{\alpha+1}, \ldots, T_\beta$ and the floating leg pays $N \cdot F_i \cdot \delta_i$ at $t_1, \ldots, t_m$, where $F_i$ is the simply compounded forward rate for $[t_{i-1}, t_i]$. The two schedules can differ; on these OIS swaps they coincide, with $T_\alpha = 0$ and $m = n_{\text{fix}} = n$. The fixed rate that makes the swap worth zero at inception is the par swap rate $S_{\alpha,\beta}(0)$.
The sum $A_{\alpha,\beta}(0)$ is the annuity: the present value of one unit of rate per year over the life of the swap. A one basis point move (1bp = 0.01%) in the fixed rate is worth $A_{\alpha,\beta}(0) \times 10^{-4}$ per unit of notional, which is the PVBP.
The floating leg discounts each forward rate, with $F_i = \frac{1}{\delta_i}\bigl(P(0, t_{i-1})/P(0, t_i) - 1\bigr)$ and $t_0 = T_\alpha$, $t_m = T_\beta$. Substituting that definition, every $\delta_i$ cancels and the sum telescopes:
The cancellation holds for any $m$ and any dates $t_1, \ldots, t_m$, and $P(0, T_\alpha) = 1$ for a spot-starting swap. See Brigo and Mercurio [1], Section 1.4. It is a single-curve result: in the multi-curve framework of Chapter 02, discounting and projection use different curves, $\delta_i$ no longer cancels, and the floating leg must be summed forward rate by forward rate.
The par condition and bootstrap formula
At inception the swap is worth zero, so the two legs are equal and the notional cancels:
Swaps are processed in order of increasing tenor, so $P(0, T_\beta)$ is the only unknown, and it sits on both sides. Rearranging:
The 1Y swap has one payment, so the annuity has one term and the sum in the numerator is empty: $P(0,T_1) = 1/(1 + 0.042 \times 365/360) = 0.95915594$, row 5 of the results table, with no annuity sum to carry. The 2Y swap reuses that pillar in a two-term annuity and solves for one more. Each swap adds exactly one unknown, and solves it immediately.
The same condition at $t > 0$ defines the forward swap rate, the underlying of the swaption payoff in Chapter 03:
xvafoundations.calibration.Stripper, all 14 discount factors. Transcribed from website/data/ir-01-….js, xvafoundations git 6a5e731.
Everything here is ACT/360, the SOFR convention. Accruals come from the payment schedule rather than being set to 1: $\tau_j = T_j - T_{j-1}$, which for annual dates is $365/360 = 1.013889$, or $366/360$ across a leap year. That 1.39% is 139 basis points of accrual, not a rounding detail, and it is why the 1Y pillar sits at $T = 1.013889$. On this OIS swap $\tau_j$ and $\delta_i$ coincide; they are kept apart because they separate in Chapter 02.
Interpolation
The bootstrap produces $P(0,T)$ only at the pillars. Between them the standard choice is log-linear interpolation on bond prices:
In logs this is linear interpolation on $\ln P(0,T)$, equivalently on $R(0,T) \cdot T$, and it produces piecewise-constant instantaneous forward rates $f(0,t)$ between pillars. Provided the bootstrapped prices are strictly decreasing, $P(0, T_{j+1}) < P(0, T_j)$ at every pillar, those forwards are positive everywhere and the interpolant cannot manufacture a negative-rate arbitrage. Linear interpolation on zero rates carries no such guarantee: its forward curve jumps and slopes inside each interval and can go negative while the zero curve still looks smooth. See Hagan and West [2].
Outside the pillar range the two ends behave differently. Above the last pillar the product $R(0,T) \cdot T$ is held at $R(0,T_n) \cdot T_n$, so $R(0,T) = R(0,T_n) T_n / T$: flat in cumulative log-discount, not in the zero rate, and it decays fast. $R(0,10.14) = 4.06\%$ becomes $2.75\%$ at 15Y and $2.06\%$ at 20Y, so do not price a 20-year trade off this object; add the instruments instead. Below the first pillar there is no clamp at all: YieldCurve carries a virtual pillar at $T = 0$ with $R \cdot T = 0$, so the zero rate runs flat at $4.3282\%$ down to the origin.
The Newton-Raphson solver
Because the 180d × 270d FRA breaks the chain, the bootstrap is solved as one root-finding problem rather than instrument by instrument: find the vector of zero rates $\mathbf{R} = \bigl(R(0,T_1),\, \ldots,\, R(0,T_n)\bigr)$ at which every instrument reprices to zero residual simultaneously. Each contributes a residual $g_j(\mathbf{R})$ that vanishes when the curve matches its quote:
Stacked into $\mathbf{g}(\mathbf{R}) = (g_1, \ldots, g_n)^T$, the bootstrap is the root problem $\mathbf{g}(\mathbf{R}) = \mathbf{0}$, on which Newton-Raphson iterates:
where $J$ is the Jacobian matrix $J_{ij} = \partial g_i / \partial R(0,T_j)$. It is computed exactly by torch.autograd.functional.jacobian, with no finite-difference step to tune. This is an AAD-native solver: the same automatic differentiation graph that produces the Jacobian also provides curve sensitivities (DV01, key-rate durations) for free. It converges in 4 iterations.
Stripper.sensitivities() and Stripper.calibrate(), exact via torch.autograd. Transcribed from website/data/ir-01-….js, xvafoundations git 6a5e731.
No diagonal entry is zero, so $J$ is non-singular at every iterate and the convergence is quadratic. Row 4 carries $-0.1263$ on the 90-day column beside its own $+0.3790$: the log-linear weight $w = 0.5$ splits its 180-day start across two pillars, which is exactly why no chained formula produces $R(0,0.75)$ by division. The starting guess is each quoted simple rate read as a continuously compounded one, well inside the basin of attraction at these tenors.
Python implementation
The implementation lives in the xvafoundations package (PyTorch, float64 throughout). Each snippet below shows only the mathematical core.
Instrument residuals
The uniform residual() interface is what lets the solver treat every instrument identically.
# Deposit: P(0,T) = 1 / (1 + r * tau)
def residual(self): # Deposit
P = self.curve.zc_price(t0, self.maturity)
return P * (1.0 + self.rate * self.accrual) - 1.0
# FRA: P(0, T_alpha) / P(0, T_beta) = 1 + F * tau
def residual(self): # FRA
P_a = self.curve.zc_price(t0, self.time_t_alpha)
P_b = self.curve.zc_price(t0, self.time_t_beta)
return P_a / P_b - (1.0 + self.rate * self.accrual)
# OIS Swap: PV_float - PV_fixed = 0
def residual(self): # OISSwap
float_pv = torch.tensor(0.0)
annuity = torch.tensor(0.0)
for i, T_i in enumerate(self.payment_times):
T_prev = self.payment_times[i - 1] if i > 0 else 0.0
tau_i = T_i - T_prev # 365/360, not 1.0
P_D = self.disc_curve.zc_price(t0, torch.tensor(T_i))
F_i = (self.fwd_curve.zc_price(t0, torch.tensor(T_prev))
/ self.fwd_curve.zc_price(t0, torch.tensor(T_i)) - 1) / tau_i
float_pv = float_pv + tau_i * P_D * F_i
annuity = annuity + tau_i * P_D
return float_pv - self.par_rate * annuity
Solver implementation
The Stripper drives all residuals to zero at once. The instrument factory decouples the solver from the instrument set: in Chapter 02 the same Stripper calibrates a forward curve from a factory that captures the discount curve.
def _residual_vector(self, zero_rates):
"""zero_rates -> stacked residuals (fresh curve each call for autograd)."""
curve = YieldCurve(self.maturities, zero_rates, self.method)
return torch.stack([inst.residual()
for inst in self.instrument_factory(curve)])
def calibrate(self, max_iter=50, tol=1e-12):
rates = self.curve.zero_rates.clone()
for iteration in range(1, max_iter + 1):
g = self._residual_vector(rates)
if g.norm().item() < tol:
self.curve.update(self.maturities, rates)
return iteration
J = torch.autograd.functional.jacobian(
self._residual_vector, rates) # exact Jacobian via AAD
rates = rates - torch.linalg.solve(J, g)
# Final convergence check: raise rather than return silently.
g = self._residual_vector(rates)
if g.norm().item() < tol:
self.curve.update(self.maturities, rates)
return max_iter
raise RuntimeError(
f"Newton-Raphson did not converge after {max_iter} iterations "
f"(||g||_2 = {g.norm().item():.2e})."
)
import torch
torch.set_default_dtype(torch.float64) # PyTorch defaults to float32; the
# solver needs all 16 digits
from xvafoundations.calibration import Stripper
from xvafoundations.data.sofr import INSTRUMENTS, VALUATION_DATE
from xvafoundations.instruments import (build_instruments,
pillar_maturities, initial_rates)
pillars = torch.tensor(pillar_maturities(INSTRUMENTS, VALUATION_DATE))
rates = torch.tensor(initial_rates(INSTRUMENTS))
stripper = Stripper(pillars, rates, instrument_factory=lambda c:
build_instruments(INSTRUMENTS, VALUATION_DATE, c))
iterations = stripper.calibrate() # 4
curve = stripper.get_curve()
Results
Every bond price and zero rate below is library output, reproduced by the code above on xvafoundations.data.sofr.INSTRUMENTS.
The same curve twice. The line is the library's own log-linear interpolant sampled at 201 points, not a drawing spline; the markers are the 14 pillars from the table.
xvafoundations.calibration.Stripper, valuation date 15 January 2026, log-linear on $R(0,T)\cdot T$. Every plotted point is library output.
Par rate verification
A calibrated curve must reprice its own inputs. The par rate roundtrip recomputes $S_{\alpha,\beta}(0) = (1 - P(0, T_\beta)) / A_{\alpha,\beta}(0)$ from the bootstrapped curve and compares it with the quote that went in:
The tolerance asked for was $10^{-12}$; the final residual norm is $6.07 \times 10^{-16}$, smaller by a factor of about 1,600. The largest par-rate error across all 10 swaps is $1.3 \times 10^{-16}$, at the 1Y; the 5Y reprices to the last bit; the deposit and FRA residuals are identically zero, and the largest residual anywhere in the 14-instrument vector is $3.1 \times 10^{-16}$, at the 6Y swap. That is float64 rounding, not solver error.
What this curve is for
$\{P(0,T)\}$ is the input to everything downstream. Hull-White calibration needs the initial instantaneous forward $f(0,T) = -\partial \ln P(0,T)/\partial T$ read off these prices; a Monte Carlo engine must reproduce this curve exactly; XVA-03 discounts expected exposures on it. If the curve is wrong, every number after it is wrong.
This is the simplest correct version, one curve doing both discounting and projection. IR-02 separates them: after 2008 the discount curve (OIS/ESTR) and the projection curve (EURIBOR) are different objects, and that separation is what XVA is computed on.
References
- D. Brigo and F. Mercurio, Interest Rate Models: Theory and Practice, 2nd edition, Springer, 2006. Chapters 1 and 2 cover discount factors, swap pricing, and curve construction in detail.
- P. S. Hagan and G. West, "Interpolation Methods for Curve Construction," Applied Mathematical Finance, 13(2), 2006. The standard reference on interpolation methods and their no-arbitrage properties.
- L. B. G. Andersen and V. V. Piterbarg, Interest Rate Modeling, Atlantic Financial Press, 2010. Volume I, Part I covers single-curve and multi-curve bootstrapping in full generality.