Requires IR-01: extracting $P(0,T)$ from swap quotes. No prior knowledge of multi-curve frameworks is assumed.
By the end you have two curves, OIS for discounting and EURIBOR for projection, and the basis between them.
Why multi-curve?
Before 2008 one LIBOR curve did both jobs: it discounted cash flows and it projected floating-leg rates. That is the curve we built in Chapter 01.
The 2008 crisis broke it. The spread between overnight indexed swap (OIS) rates and 3-month LIBOR, a handful of basis points before 2007, passed 300 bp at the October 2008 peak and never returned to zero. So which rate discounts a collateralised trade? Cash collateral earns the overnight rate set in the credit support annex (CSA), so the discount rate is a property of the collateral agreement, not of LIBOR and not of your own credit (Henrard, 2009). If the cash I am posted pays ESTR, I should not discount at LIBOR.
xvafoundations.instruments.OISSwap priced on the two curves calibrated below, once with disc_curve = ESTR and once with disc_curve = EURIBOR.
Discount curve: OIS (SOFR in USD, ESTR in EUR), the rate cash collateral earns under the CSA, used for every cash flow on a cash-collateralised trade. Projection curve: the tenor you are projecting (EURIBOR 3M, SOFR 3M). The difference between them is the basis.
That basis is two things at once: the default risk of lending to a bank for three months rather than overnight, and the cost of tying up funding for a longer horizon. Which dominates is regime-dependent (Filipović and Trolle, 2013). It is not an arbitrage; it compensates for real risk. Ametrano and Bianchetti (2013) give the arbitrage-free construction, Crépey (2015) the CSA-consistent XVA version.
Multi-curve swap pricing
Subscript $D$ names the discount curve throughout and $F$ the projection curve, on bond prices and zero rates alike.
In the single-curve world, the floating leg telescopes to $P(0,0) - P(0,T_n)$ and no explicit forward rates are needed. In multi-curve, that simplification breaks down because discounting and projection use different curves.
Fixed leg paying on $T_{\alpha+1}, \ldots, T_\beta$, floating leg resetting on $t_0, t_1, \ldots, t_m$, with $\tau_j$ and $\delta_i$ the two accruals of Chapter 01:
where the forward rate from the projection curve is:
$S$ is the quoted par rate and $F_i$ abbreviates $F(0;\,t_{i-1},t_i)$, the Brigo-Mercurio simply-compounded forward. $P_D$ is already calibrated and frozen; $P_F$ is still unknown. At par, $\text{PV} = 0$:
where each $F_i$ depends on the unknowns through $P_F(0, T_k) = e^{-R_k \, T_k}$.
The system of equations and Jacobian
The unknowns are the projection curve's zero rates $R_k := R_F(0,T_k)$, entering through $P_F(0,T_k) = e^{-R_k T_k}$. On the common annual pillar grid the forward rate is:
The residual of swap $k$ is floating PV minus fixed PV at par, the matched annual schedule collapsing $\delta_i = \tau_j = T_i - T_{i-1}$ to a single accrual $\tau_i$:
The accruals cancel before you differentiate: $\tau_i F_i = e^{T_i R_i - T_{i-1} R_{i-1}} - 1$, so the day-count convention never reaches the Jacobian $J_{ki} = \partial g_k / \partial R_i$. Its diagonal is:
Below the diagonal, $R_i$ reaches $g_k$ twice, once raising period $i$ and once lowering period $i+1$:
No $k$ survives on the right. Pillar $i$ enters every swap longer than it in the same way, so under the diagonal a column of $J$ is one number repeated: column 1 reads 0.0239956 in all nine rows beneath it. Only $\mathbf{R}$ is differentiated; every $P_D$ is a frozen weight.
website/data/ir-02-multi-curve-bootstrapping.js, key projectionCurve. The sparsity pattern is exact, not illustrative.
$J$ is lower-triangular because $F_i$ involves only $P_F(0, T_{i-1})$ and $P_F(0, T_i)$, so $R_{k+1}$ never appears in residual $k$. Every $P_D$ and every exponential is positive, so the diagonal is positive, $J$ is non-singular at every iterate, and the Newton step always exists. Started at the quoted swap rates, convergence is locally quadratic (Ortega and Rheinboldt, 1970, Thm. 12.6.1), which is why three iterations clear both curves.
Newton-Raphson with autograd
The Newton step $\mathbf{R}^{(k+1)} = \mathbf{R}^{(k)} - J(\mathbf{R}^{(k)})^{-1} \mathbf{g}(\mathbf{R}^{(k)})$ is the one derived in Chapter 01. Only $J$ has changed.
Finite differences need $2n$ function evaluations per iteration and carry truncation error from the choice of $\varepsilon$. Reverse-mode automatic differentiation (Adjoint Algorithmic Differentiation, AAD) returns the full $n \times n$ Jacobian in one torch.autograd.functional.jacobian call, exact to machine precision, with nothing to tune. It is the machinery production XVA engines use for market sensitivities.
Market data
Two quote sets, one valuation date, meeting on a common annual swap grid.
Source: xvafoundations.data.estr, ESTR_INSTRUMENTS and EURIBOR_INSTRUMENTS, valuation date 15 Jan 2026, ACT/360 throughout. The ESTR curve also takes four short-end instruments: 1W deposit 2.37%, FRA 7x30 2.40%, FRA 30x90 2.42%, FRA 180x270 2.32%. A FRA fixes the curve at its end date, not its accrual length, so FRA 7x30 accrues 23 days and pins the 30-day pillar.
Step 1: ESTR OIS discount curve
Chapter 01's method, unchanged, on the ESTR quotes: $P_D(0,1) = 0.97749319$.
Step 2: EURIBOR 3M forward curve
The EURIBOR swaps carry annual fixed and float legs on the same pillar grid, and quote above OIS at every tenor: that gap is the premium on three-month unsecured lending.
The first pillar sits at $T_1 = 1.0138889$ (ACT/360 from 15 Jan 2026 to 15 Jan 2027) and it is the one point on either curve you can check on paper. The 1Y EURIBOR swap has a single payment, so $P_D(0,T_1)$ appears on both sides of the par condition and cancels, leaving $F_1 = S_1 = 2.75\%$ with no reference to the discount curve at all:
That is the calibrated value to eight decimals. The 2Y swap is where the difficulty starts: two payments, the discount factors no longer cancel, and the ESTR curve enters the answer for the first time. Snippet 3 below prints $P_F(0,1) = 0.97324094$, the curve read at the exact one-year point rather than at the pillar. Figure 5 uses that reading, not the pillar value.
Basis spread
The basis is the difference between the EURIBOR and ESTR forward rates for each annual period.
YieldCurve.forward_rate on both calibrated curves, via website/data/ir-02-multi-curve-bootstrapping.js, key basisTable. Simply-compounded annual forwards.
The first bar is one subtraction. Both curves are read at $T = 1$: $F_{\text{ESTR}} = 1/0.97749319 - 1 = 2.3025\%$ and $F_{\text{EUR}} = 1/0.97324094 - 1 = 2.7495\%$, so the spread is 44.7 bp. Substitute the EURIBOR pillar value $0.97287437$ for the one-year reading and you get 48.6 bp, which is two curves evaluated at two different dates rather than a basis.
Only $P_D$ is a price. $P_F$ is a bookkeeping device whose ratios carry the forward rates: multiply the whole $P_F$ curve by any constant and every $F_i$ is unchanged. Nobody trades at $P_F$.
Source: xvafoundations.calibration.Stripper, both curves read on the integer-year grid; website/data/ir-02-multi-curve-bootstrapping.js, key annualGrid.
Zero rates from both curves. The gap is not the same number as Figure 5: that differences simply-compounded forwards, this differences continuously compounded zeros, so at 1Y it reads 43.6 bp against 44.7 bp.
Source: xvafoundations.calibration.Stripper, continuously compounded zero rates on the same grid; website/data/ir-02-multi-curve-bootstrapping.js, key annualGrid.
Two curves is the teaching case, not the desk case. Every tenor gets its own projection curve, 1M, 3M, 6M, 12M, and the gaps between them are quoted directly as tenor basis swaps (3s6s, 1s3s) rather than backed out. In practice the 3M curve is usually built as OIS plus those quotes, which prices the basis to the market instead of inferring it from two independent calibrations.
Python implementation
Nothing is redefined here. For the deposit, FRA and swap pricing formulas, see Chapter 01.
Snippet 1: Imports
import torch
from xvafoundations.curves import YieldCurve, InterpolationMethod
from xvafoundations.instruments import build_instruments, pillar_maturities, initial_rates
from xvafoundations.calibration import Stripper
from xvafoundations.data.estr import ESTR_INSTRUMENTS, EURIBOR_INSTRUMENTS, VALUATION_DATE
torch.set_default_dtype(torch.float64)
Snippet 2: ESTR OIS discount curve
# ── Step 1: Bootstrap the ESTR OIS discount curve ──
pillars = torch.tensor(pillar_maturities(ESTR_INSTRUMENTS, VALUATION_DATE))
rates = torch.tensor(initial_rates(ESTR_INSTRUMENTS))
def estr_factory(curve: YieldCurve) -> list:
return build_instruments(ESTR_INSTRUMENTS, VALUATION_DATE, curve)
disc_stripper = Stripper(pillars, rates, estr_factory)
disc_stripper.calibrate()
disc_curve = disc_stripper.get_curve()
t0 = torch.tensor(0.0)
print(f"P_D(0, 1) = {disc_curve.zc_price(t0, torch.tensor(1.0)):.8f}")
Snippet 3: The projection curve, built on the frozen discount curve
The factory captures disc_curve from the enclosing scope; the Stripper passes fwd_curve as the argument being calibrated. Each OISSwap gets both.
# ── Step 2: Bootstrap the EURIBOR 3M forward curve ──
fwd_pillars = torch.tensor(pillar_maturities(EURIBOR_INSTRUMENTS, VALUATION_DATE))
fwd_rates = torch.tensor(initial_rates(EURIBOR_INSTRUMENTS))
def euribor_factory(fwd_curve: YieldCurve) -> list:
"""disc_curve captured from enclosing scope; fwd_curve being calibrated."""
return build_instruments(
EURIBOR_INSTRUMENTS, VALUATION_DATE,
curve=disc_curve, # captured (frozen)
fwd_curve=fwd_curve, # being calibrated
)
fwd_stripper = Stripper(fwd_pillars, fwd_rates, euribor_factory)
fwd_stripper.calibrate()
fwd_curve = fwd_stripper.get_curve()
print(f"P_F(0, 1) = {fwd_curve.zc_price(t0, torch.tensor(1.0)):.8f}")
Snippet 4: Basis spread computation
# ── Basis spread: F_EURIBOR(T) - F_ESTR(T) for each annual period ──
t0 = torch.tensor(0.0)
print("\nBasis spread (EURIBOR 3M vs ESTR):")
for T in range(1, 11):
T_prev = torch.tensor(float(T - 1))
T_i = torch.tensor(float(T))
F_eur = fwd_curve.forward_rate(t0, T_prev, T_i)
F_estr = disc_curve.forward_rate(t0, T_prev, T_i)
spread_bp = (F_eur - F_estr).item() * 10_000
print(f" {T:2d}Y: F_EUR={F_eur.item()*100:.4f}%, "
f"F_ESTR={F_estr.item()*100:.4f}%, "
f"spread={spread_bp:.1f}bp")
What this enables
- Swaption pricing: the forward swap rate becomes $S_{\alpha,\beta}(0) = \sum_i \delta_i P_D(0,t_i) F_i \,/ \sum_j \tau_j P_D(0,T_j)$, with the $F_i$ from $P_F$ and every discount factor from $P_D$, so the at-the-money-forward (ATMF) strike moves with the basis. Chapter 03 stays on the single USD SOFR curve of Chapter 01 so the change-of-numéraire argument stands on its own; multi-curve changes the strike, not the measure.
- Hull-White calibration (Chapter 05, draft): the model must reproduce one initial term structure exactly, and in a full multi-curve workflow that target is the projection curve with OIS discounting. The examples here pass a single curve and treat multi-curve as a layer on top.
- XVA: collateralised exposure discounts on OIS while floating legs project at tenor rates, so the basis enters the valuation adjustment.
Summary
The size of a cash flow and its value come from different curves. That is the whole of the post-2008 reform, and it costs one extra calibration: solve the discount curve, freeze it, solve the projection curve on top. The Jacobian stays lower-triangular, as in Chapter 01, and autograd computes it exactly. The basis it leaves, 44.7 bp at 1Y down to 17.9 bp at 10Y, shows up in every price downstream.
IR-03 takes the forward swap rate and the annuity built here and asks what a swaption is worth, which needs a volatility. It returns to the single USD SOFR curve of Chapter 01 to keep the change of numéraire uncluttered.
References
- D. Brigo and F. Mercurio, Interest Rate Models, Second Edition: Theory and Implementation, Springer, 2006.
- M. Henrard, "The Irony in the Derivatives Discounting Part II: The Crisis," SSRN, 2009.
- F. Ametrano and M. Bianchetti, "Everything You Always Wanted to Know About Multiple Interest Rate Curve Bootstrapping but Were Afraid to Ask," SSRN, 2013.
- L. B. G. Andersen and V. V. Piterbarg, Interest Rate Modeling, Volume I: Foundations and Vanilla Models, Atlantic Financial Press, 2010.
- S. Crépey, "Bilateral Counterparty Risk under Funding Constraints," Mathematical Finance, 25(1), 2015.
- D. Filipović and A. B. Trolle, "The Term Structure of Interbank Risk," Journal of Financial Economics, 109(3), 2013.
- J. M. Ortega and W. C. Rheinboldt, Iterative Solution of Nonlinear Equations in Several Variables, Academic Press, 1970.
All views expressed are strictly personal and do not represent any past or current employer. Content is educational; nothing here constitutes financial, investment, or trading advice.