Prerequisites

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.

Notation (following Brigo and Mercurio [1], Ch. 1)

$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

The 14 instruments laid on one axis, each solving one new pillar Fourteen horizontal bars on one axis whose columns are the 14 curve pillars, evenly spaced. Row 1, the SOFR 1W deposit, runs from today to the 7-day pillar. Rows 2 to 4 are FRAs running 7d to 30d, 30d to 90d and 180d to 270d. Rows 5 to 14 are par swaps, each running from today to its own maturity with a tick at every annual coupon date from 1Y onward. A crimson dot at the right end of every bar marks the single new pillar that quote solves, so the dots form a staircase down the diagonal. A hatched band covers the stretch between the 90-day and 270-day pillars: no instrument is quoted there. A gold dashed line at 180 days shows where the fourth FRA starts, inside that gap, so its start price is itself interpolated. 1 SOFR 1W 2 FRA 7×30 3 FRA 30×90 4 FRA 180×270 5 6 7 8 9 10 11 12 13 14 7d 30d 90d 270d 1Y 2Y 3Y 4Y 5Y 6Y 7Y 8Y 9Y 10Y # 10 par swaps, each spanning 0 to its own maturity. 180d Nothing is quoted between 90d and 270d. FRA 4 starts at 180d, inside that gap. Columns are the 14 pillars, evenly spaced.
Figure 1 · IR-01 One quote pins one new pillar, left to right, until 180 days breaks the chain. Ticks are coupon dates, crimson dots the pillar each quote solves; columns are the 14 pillars evenly spaced, so time is monotone but not linear. Source: xvafoundations.data.sofr.INSTRUMENTS. Transcribed from website/data/ir-01-….js, xvafoundations git 6a5e731.
SOFR OIS Market Data (14 instruments)
#TypeInstrumentTenorRateDay Count
1DepositSOFR 1W7 days4.33%ACT/360
2FRA7d × 30d23 days4.35%ACT/360
3FRA30d × 90d60 days4.38%ACT/360
4FRA180d × 270d90 days4.25%ACT/360
5SwapSOFR OIS1Y4.20%ACT/360
6SwapSOFR OIS2Y3.95%ACT/360
7SwapSOFR OIS3Y3.85%ACT/360
8SwapSOFR OIS4Y3.88%ACT/360
9SwapSOFR OIS5Y3.92%ACT/360
10SwapSOFR OIS6Y3.97%ACT/360
11SwapSOFR OIS7Y4.02%ACT/360
12SwapSOFR OIS8Y4.06%ACT/360
13SwapSOFR OIS9Y4.09%ACT/360
14SwapSOFR OIS10Y4.12%ACT/360

Step 1: Cash deposit

Cash deposit cashflow timeline A horizontal time axis with two marked dates, today and T. At today, a crimson arrow points down out of the axis, labelled lend 1. At T, a navy arrow points up into the axis, labelled receive 1 plus r times tau. Between them, printed above the axis: P(0,T) equals 1 divided by (1 plus r times tau). 0 T Lend 1 Receive 1 + r · τ P(0,T) = 1 / (1 + r · τ)
Figure 2 · IR-01 One quote, one division, one pillar: the deposit is the only instrument that needs nothing but itself. The bond price is the reciprocal of the growth factor. Illustrative schematic; the worked number below it is library output.

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:

Bond price from a cash deposit $$P(0,T) = \frac{1}{1 + r \cdot \tau(0,T)}$$

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

FRA timeline A time axis marked at today, at T alpha and at T beta. A bracket above the axis spans T alpha to T beta and is labelled with the forward rate F(0; T alpha, T beta). A navy dot at T alpha is labelled P(0, T alpha) known; a crimson dot at T beta is labelled P(0, T beta) solved. A bracket below the same span is labelled tau(T alpha, T beta) equals days over 360. The FRA reaches exactly one accrual period beyond the pillar it starts from. F(0; Tα, Tβ) P(0, Tα) known P(0, Tβ) solved 0 Tα Tβ τ(Tα, Tβ) = days / 360
Figure 3 · IR-01 A FRA prices a stretch, not a date, so it can only extend a curve that already reaches its start. Navy is known, crimson is the unknown, and the bracket is the accrual the quote locks. Illustrative schematic; not library output.

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$:

No-arbitrage relation for a FRA $$\frac{P(0, T_\alpha)}{P(0, T_\beta)} = 1 + F(0;\, T_\alpha, T_\beta) \cdot \tau(T_\alpha, T_\beta)$$

If $P(0, T_\alpha)$ is already known, we rearrange to solve for $P(0, T_\beta)$:

Bond price from a FRA $$P(0, T_\beta) = \frac{P(0, T_\alpha)}{1 + F(0;\, T_\alpha, T_\beta) \cdot \tau(T_\alpha, 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 180d × 270d FRA, written out $$\exp\!\bigl(0.375\,R(0,0.75) - 0.125\,R(0,0.25)\bigr) = 1 + F \cdot \tau$$
$F = 4.25\%$ and $\tau = 90/360 = 0.25$. Solving for the one unknown gives $R(0,0.75) = 4.2704\%$, row 4 of the results table.

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

Five-year SOFR OIS par swap, payer perspective A single time axis from today to five years with ticks at 1Y, 2Y, 3Y, 4Y and 5Y. At every one of those five dates a navy arrow points up out of the axis, labelled plus N times F sub i times delta sub i, the floating payment received, and a crimson arrow points down, labelled minus N times K times tau sub j, the fixed payment made. Both legs pay on the same five annual dates. At par the two sets of arrows have equal present value. 5Y OIS PAR SWAP · BOTH LEGS ANNUAL +N · Fi · δi 0 1Y 2Y 3Y 4Y 5Y −N · K · τj
Figure 4 · IR-01 Both legs land on the same five dates, so the swap is one equation per maturity and not two schedules to reconcile. Navy up is received, crimson down is paid; at par the two sets have equal present value. Illustrative schematic of row 9 of the table above; not library output.

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)$.

Fixed leg PV $$\text{PV}_{\text{fixed}} = N \cdot K \cdot \sum_{j=\alpha+1}^{\beta} \tau_j \cdot P(0, T_j) = N \cdot K \cdot A_{\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:

Floating leg PV (single-curve, telescoped) $$\text{PV}_{\text{float}} = N \sum_{i=1}^{m} \delta_i \, P(0, t_i) \cdot \frac{1}{\delta_i}\!\left(\frac{P(0, t_{i-1})}{P(0, t_i)} - 1\right) = N \sum_{i=1}^{m} \bigl(P(0, t_{i-1}) - P(0, t_i)\bigr) = N\bigl(P(0, T_\alpha) - P(0, T_\beta)\bigr)$$

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:

Par swap condition $$S_{\alpha,\beta}(0) \cdot A_{\alpha,\beta}(0) = 1 - P(0, T_\beta) \qquad\Longleftrightarrow\qquad S_{\alpha,\beta}(0) = \frac{1 - P(0, T_\beta)}{A_{\alpha,\beta}(0)}$$

Swaps are processed in order of increasing tenor, so $P(0, T_\beta)$ is the only unknown, and it sits on both sides. Rearranging:

Bootstrap formula for the swap maturity $$P(0, T_\beta) = \frac{1 - S_{\alpha,\beta}(0) \cdot \displaystyle\sum_{j=\alpha+1}^{\beta-1} \tau_j \cdot P(0, T_j)}{1 + S_{\alpha,\beta}(0) \cdot \tau_\beta}$$

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:

Forward swap rate $$S_{\alpha,\beta}(t) = \frac{P(t, T_\alpha) - P(t, T_\beta)}{A_{\alpha,\beta}(t)}, \qquad A_{\alpha,\beta}(t) = \sum_{j=\alpha+1}^{\beta} \tau_j \, P(t, T_j)$$
At $t = 0$ on a spot-starting swap $P(0,T_\alpha) = 1$ and this collapses to the par rate above. Under the annuity measure it is a martingale.
The discount curve built pillar by pillar, in four frames Four frames of the same plot of P(0,T) against pillar, each with one more pillar solved than the last: 1 pillar, out to 0.99915876 at 7d; 4 pillars, out to 0.96847918 at 270d; 9 pillars, out to 0.82283597 at 5Y; 14 pillars, out to 0.66241281 at 10Y. Everything to the right of the newest pillar is hatched as not yet determined. In every frame the pillars already solved sit at exactly the same heights as in the frame before, and only the right edge advances. P(0,T): 1.00 at each panel top, 0.65 at its foot AFTER THE DEPOSIT: 1 PILLAR KNOWN 7d: 0.99915876 not yet determined AFTER THE THREE FRAS: 4 PILLARS 270d: 0.96847918 AFTER THE 1Y TO 5Y SWAPS: 9 PILLARS 5Y: 0.82283597 AFTER THE 6Y TO 10Y SWAPS: ALL 14 10Y: 0.66241281 7d 1Y 5Y 10Y
Figure 5 · IR-01 The right edge moves; nothing to its left ever changes. Navy dots are pillars already solved, the crimson dot is the new one, and the hatch is curve that does not exist yet. Source: xvafoundations.calibration.Stripper, all 14 discount factors. Transcribed from website/data/ir-01-….js, xvafoundations git 6a5e731.
Day count: the accruals are not 1.0

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:

Log-linear interpolation $$P(0, T) = P(0, T_L)^{1-w} \cdot P(0, T_R)^{w}, \qquad w = \frac{T - T_L}{T_R - T_L}$$
$T_L$ and $T_R$ are the pillars bracketing $T$. The weight is written $w$, not $\alpha$, because $\alpha$ is already the swap start index above.

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:

Instrument residual functions $$\begin{aligned} g_{\text{dep}}(\mathbf{R}) &= P(0,T)\bigl(1 + r\,\tau\bigr) - 1 \\[6pt] g_{\text{FRA}}(\mathbf{R}) &= \frac{P(0, T_\alpha)}{P(0, T_\beta)} - \bigl(1 + F\,\tau(T_\alpha, T_\beta)\bigr) \\[6pt] g_{\text{swap}}(\mathbf{R}) &= \bigl(1 - P(0, T_\beta)\bigr) \;-\; S_{\alpha,\beta}(0)\, \sum_{j=\alpha+1}^{\beta} \tau_j\, P(0, T_j) \end{aligned}$$
$g_{\text{dep}}$: the deposit residual. $P(0,T)$ is the model bond price; $r$ and $\tau$ are the quoted rate and ACT/360 day count fraction. $g_{\text{FRA}}$: the FRA residual. $T_\alpha, T_\beta$ are the FRA start and end dates; $F$ is the quoted FRA rate. $g_{\text{swap}}$: the spot-starting swap residual. The first term is the telescoped floating-leg PV (single-curve only); the second is the fixed-leg PV $S_{\alpha,\beta}(0) \cdot A_{\alpha,\beta}(0)$ with $\tau_j$ the fixed-leg day count fraction. $S_{\alpha,\beta}(0)$ is the quoted par rate.

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:

Newton-Raphson iteration $$\mathbf{R}^{(k+1)} = \mathbf{R}^{(k)} - J(\mathbf{R}^{(k)})^{-1} \cdot \mathbf{g}(\mathbf{R}^{(k)})$$

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.

The Jacobian sparsity staircase, and the four-step convergence it produces Two panels. Left, the 14 by 14 Jacobian of instrument residuals against pillar zero rates. Every filled cell lies on or below the diagonal, forming a staircase: row 1, the deposit, fills one cell; rows 2 to 4, the FRAs, fill two each; rows 5 to 14, the swaps, fill from the 1Y column out to their own maturity. The whole region above the diagonal is empty, and the library reports its largest absolute entry as 0 exactly. Row 4 is outlined in gold because its 180-day start date is not a pillar, so it touches both the 90-day and 270-day columns through interpolation. Right, the L2 norm of the 14 residuals on a log scale entering each of the four Newton iterates: 1.19e-2 before any step, then 2.85e-5, 2.03e-10 and 6.07e-16. Three steps carry it past the 1e-12 tolerance down to float64 noise, and the fourth iterate is the check that stops the loop. ROWS: INSTRUMENTS. COLUMNS: PILLARS. 1 2 3 4 5 6 7 8 9 10 11 12 13 14 7d 30d 90d 270d 1Y 2Y 3Y 4Y 5Y 6Y 7Y 8Y 9Y 10Y Above the staircase, every entry is exactly 0.0. Nothing depends on a longer pillar. Row 4 is the exception. Shading: |J| against the biggest entry in that row. RESIDUAL NORM ENTERING EACH ITERATE L2 norm of the 14 residuals, log scale 1 10−4 10−8 10−12 10−16 tolerance 1.19 × 10−2 iterate 1 2.85 × 10−5 iterate 2 2.03 × 10−10 iterate 3 6.07 × 10−16 iterate 4 Exponent roughly doubles, then float64 runs out.
Figure 6 · IR-01 The staircase is why four steps are enough: no instrument looks past the diagonal, so the error squares each step. Above, shading is $|J|$ within its own row; below, the residual norm on a log scale. Source: 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.

Python · xvafoundations.instruments
# 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.

Python · xvafoundations.calibration
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})."
    )
Python · bootstrap driver
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.

Bootstrapped Curve: All 14 Pillars
#PillarT (yrs)P(0,T)R(0,T)
17d0.0194440.999158764.3282%
230d0.0833330.996389634.3403%
390d0.2500000.989168704.3562%
4270d0.7500000.968479184.2704%
51Y1.0138890.959155944.1130%
62Y2.0277780.924559803.8682%
73Y3.0444440.891572063.7698%
84Y4.0583330.857013223.8021%
95Y5.0722220.822835973.8444%
106Y6.0861110.788824933.8976%
117Y7.1027780.755296333.9512%
128Y8.1166670.723096023.9944%
139Y9.1305560.692339954.0269%
1410Y10.1444440.662412814.0600%

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.

Bond price P(0,T)
P(0,T) falls smoothly from 0.99916 at 7 days to 0.66241 at 10 years.
Zero rate R(0,T)
R(0,T) is inverted at the short end, bottoms at 3.77% at 3Y, and rises to 4.06% at 10Y.
Figure 7 · IR-01 The dip to 3.7698% at 3Y is a market pricing near-term cuts and a gradual normalisation after them; the same curve looks featureless in bond prices. Markers are the 14 pillars; hover for exact values. Source: 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:

Par Rate Roundtrip: Selected Tenors
TenorInput RateComputed Rate|Error|
1Y4.2000%4.2000%1.3 × 10-16
2Y3.9500%3.9500%2.1 × 10-17
3Y3.8500%3.8500%4.2 × 10-17
5Y3.9200%3.9200%0 (exact)
10Y4.1200%4.1200%2.1 × 10-17

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

  1. 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.
  2. 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.
  3. 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.