Python + C++20● Live

Interest Rate Curve Builder

Bootstrap EUR OIS (€STR-linked) and EURIBOR 3M curves from live ECB market data. Cubic spline interpolation on log-discount factors. Nelson-Siegel parametrisation and fitting. DV01 ladder via Jacobian bump-reval. Interactive Plotly visualisations.

PythonEUR OISEURIBOR 3MMulti-curveNelson-SiegelBootstrappingDV01ECB APIPlotly

1. Market Context and Assumptions

Every interest rate derivative — a swap, a cap, a swaption — is priced relative to a yield curve: a function P(0,T) mapping maturity to a discount factor. Getting this curve wrong by a few basis points translates directly into P&L mis-attribution, incorrect hedge ratios, and mispriced trades.

Pre-2008, a single LIBOR curve served for both discounting and forward rate projection. The 2008 crisis revealed that LIBOR embeds credit and liquidity risk — it is not a risk-free rate. This forced the industry into the multi-curve framework:

The Multi-Curve Framework

  • Discounting curve (OIS): Built from overnight indexed swap (OIS) instruments referencing the overnight risk-free rate. For EUR: the Euro Short-Term Rate (€STR), published by the ECB since October 2019 and replacing EONIA from January 2022.
  • Projection curves (EURIBOR tenors): One curve per IBOR tenor — EURIBOR 3M, 6M, 12M — built from deposits, FRAs, and par IRS quotes. These are used to project EURIBOR-linked cash flows.
  • OIS discounting: All projected cash flows — regardless of which EURIBOR tenor they reference — are discounted on the OIS curve. This reflects the risk-free nature of collateralised (CSA) trades.
  • Basis: The OIS-EURIBOR spread (typically 5–20bp for 3M EURIBOR pre-2022; compressed near zero after IBOR reform) is real market data and must not be suppressed by single-curve simplification.

Conventions Used Throughout

  • Rates: continuously compounded, annualised, decimal (0.04 = 4%)
  • Day count: ACT/360 for money market instruments and OIS float legs; approximated here as t × 365/360. Use QuantLib for exact business-day schedules.
  • Time to maturity: in years, ACT/365 basis.
  • Compounding on OIS float: daily compounded €STR, approximated as P(0,T₀) − P(0,T_N) for bootstrapping purposes.

2. Theory: Yield Curve Bootstrapping

2.1 Discount Factors and Zero Rates

The discount factor P(0,T)P(0,T) is the present value of one unit of currency received at time TT:

P(0,T)=er(T)TP(0,T) = e^{-r(T) \cdot T}

where r(T)r(T) is the continuously compounded zero-coupon rate. Equivalently:

r(T)=lnP(0,T)Tr(T) = -\frac{\ln P(0,T)}{T}

The instantaneous forward rate f(t)f(t) (also called the short rate at future time tt) is:

f(t)=lnP(0,t)tf(t) = -\frac{\partial \ln P(0,t)}{\partial t}

and discount factors can be recovered from forward rates via:

P(0,T)=exp ⁣(0Tf(t)dt)P(0,T) = \exp\!\left(-\int_0^T f(t)\,\mathrm{d}t\right)

2.2 OIS Bootstrap

An OIS swap exchanges a fixed coupon KK for the daily compounding of €STR over [0,T][0,T].

Fixed leg PV (annual payments at T1,,TN=TT_1, \ldots, T_N = T, day count fraction δi\delta_i): Fixed=Ki=1NδiP(0,Ti)\text{Fixed} = K \sum_{i=1}^{N} \delta_i \cdot P(0, T_i)

Float leg PV (approximation valid under OIS conventions): Float=P(0,T0)P(0,TN)1P(0,TN)\text{Float} = P(0, T_0) - P(0, T_N) \approx 1 - P(0, T_N)

Setting Fixed == Float and solving for the last unknown P(0,TN)P(0,T_N), given all prior pillars already bootstrapped:

P(0,TN)=1Ki=1N1δiP(0,Ti)1+KδN\boxed{P(0,T_N) = \frac{1 - K\,\sum_{i=1}^{N-1} \delta_i\, P(0,T_i)}{1 + K\,\delta_N}}

This is a closed-form sequential bootstrap — no root-finding required. Each instrument pins exactly one new discount factor pillar.

2.3 EURIBOR Multi-Curve Bootstrap

Let POIS(0,T)P^{\text{OIS}}(0,T) denote the OIS discount factors (already bootstrapped) and PE(0,T)P^E(0,T) the EURIBOR 3M projection curve discount factors.

FRA bootstrap (a p ⁣× ⁣qp\!\times\!q FRA at rate KK): FE(0;T1,T2)=K    PE(0,T1)PE(0,T2)=1+Kδ(T1,T2)F^E(0;T_1,T_2) = K \implies \frac{P^E(0,T_1)}{P^E(0,T_2)} = 1 + K\,\delta(T_1,T_2)

Given PE(0,T1)P^E(0,T_1) already solved: PE(0,T2)=PE(0,T1)1+Kδ(T1,T2)P^E(0,T_2) = \frac{P^E(0,T_1)}{1 + K\,\delta(T_1,T_2)}

IRS bootstrap (par swap rate KK, annual fixed vs quarterly 3M EURIBOR):

Fixed PV == Float PV: KiδifixPOIS(0,Ti)A=j[PE(0,Tj1)PE(0,Tj)1]POIS(0,Tj)K \underbrace{\sum_i \delta_i^{\text{fix}}\, P^{\text{OIS}}(0,T_i)}_{A} = \sum_j \left[\frac{P^E(0,T_{j-1})}{P^E(0,T_j)} - 1\right] P^{\text{OIS}}(0,T_j)

Separating the last unknown PE(0,TN)P^E(0,T_N) and denoting the known float sum as BB: PE(0,TN)=POIS(0,TN)PE(0,TN1)KAB+POIS(0,TN)\boxed{P^E(0,T_N) = \frac{P^{\text{OIS}}(0,T_N)\cdot P^E(0,T_{N-1})}{K\cdot A - B + P^{\text{OIS}}(0,T_N)}}

3. Theory: Interpolation

3.1 Cubic Spline on Log-Discount Factors

Between bootstrapped pillars, the curve must be interpolated. The choice of interpolation scheme determines the shape of the forward rate curve — and forward rates drive floating leg cash flows and exotic payoffs.

Log-DF cubic spline: fit a natural cubic spline s(t)s(t) to the points (0,0),(T1,lnP1),,(TN,lnPN)(0, 0), (T_1, \ln P_1), \ldots, (T_N, \ln P_N) and recover:

P(0,t)=es(t),f(t)=s(t)P(0,t) = e^{s(t)}, \quad f(t) = -s'(t)

The instantaneous forward rate equals minus the derivative of the cubic spline — it is piecewise quadratic and continuous, but not differentiable at pillar points.

Why log-DF interpolation?

  • Forward rates are controlled by s(t)s'(t): a smooth cubic keeps forward rates bounded.
  • Interpolating P(0,t)P(0,t) directly would produce forward rates with discontinuous jumps.
  • Interpolating zero rates r(t)r(t) is common but can produce negative forward rates near steeply inverted pillar transitions.

Known limitation: a cubic spline does not guarantee positive forward rates between pillars. If the bootstrapped pillars imply a very steep local inversion, s(t)>0s'(t) > 0 for some tt, producing f(t)<0f(t) < 0. The Hagan-West monotone convex interpolation resolves this at the cost of C1C^1 smoothness.

3.2 Nelson-Siegel Parametrisation

Nelson & Siegel (1987) proposed a four-parameter analytic form for the yield curve:

r(τ)=β0+β1g1 ⁣(τλ)+β2g2 ⁣(τλ)r(\tau) = \beta_0 + \beta_1\,g_1\!\left(\frac{\tau}{\lambda}\right) + \beta_2\,g_2\!\left(\frac{\tau}{\lambda}\right)

where the factor loadings are: g1(x)=1exx,g2(x)=1exxexg_1(x) = \frac{1-e^{-x}}{x}, \qquad g_2(x) = \frac{1-e^{-x}}{x} - e^{-x}

The corresponding instantaneous forward rate is: f(τ)=β0+β1eτ/λ+β2τλeτ/λf(\tau) = \beta_0 + \beta_1 e^{-\tau/\lambda} + \beta_2\,\frac{\tau}{\lambda}\,e^{-\tau/\lambda}

Parameter interpretation:

  • β0>0\beta_0 > 0: long-run rate (rβ0r \to \beta_0 as τ\tau \to \infty)
  • β1\beta_1: short/long spread (r(0)=β0+β1r(0) = \beta_0 + \beta_1; β1<0\beta_1 < 0 for normal curve)
  • β2\beta_2: hump/trough factor; the curvature loading g2g_2 peaks at τ=λln2\tau^* = \lambda \ln 2
  • λ>0\lambda > 0: decay speed; determines the maturity of maximum β2\beta_2 influence

The ECB publishes its official yield curve using the Svensson (1994) extension, which adds a second hump factor β3\beta_3 with decay λ2\lambda_2, allowing two inflection points.

4. Interactive Demo

Adjust the Nelson-Siegel parameters with the sliders. The Yield Curve tab shows the continuously-compounded zero rate r(τ) (indigo) and instantaneous forward rate f(τ) (orange dashed). The DV01 Ladder tab shows DV01(τ) = 10M × τ × P(0,τ) × 1bp for each standard EUR swap pillar, plus a full pillar table with discount factors and zero rates.

Use the preset buttons to load EUR 2024 (inverted), normal, humped, and flat curve shapes. Observe how the forward rate curve (f) is far more sensitive to parameter changes than the zero rate curve — a key reason why forward rate fitting is harder than zero rate fitting.

Nelson-Siegel

Parameters

3.00%
1.20%
-0.50%
2.5
ShapeInverted (downward-sloping)
Short rate r(0)4.197%
Long rate r(30Y)3.058%
hump at τ1.73Y

λ·ln 2 ≈ 1.73Y is where the curvature loading g₂(x) peaks — the maturity of maximum β₂ influence.

2.7%3.1%3.6%4.0%4.4%3M6M1Y2Y3Y5Y7Y10Y15Y20Y30YZero rate r(τ)Forward rate f(τ)Rate (ann. c.c.)Maturity τ (years)

5. Python Implementation

5.1 Curve Data Structures

The core abstractions are the Instrument dataclass (a market quote) and the DiscountCurve class (a bootstrapped pillar set with cubic spline interpolation). The DiscountCurve exposes df(t), zero_rate(t), inst_forward(t), and forward_rate(t1, t2).

curve_builder.py — data structuresPython 3.10+
# curve_builder.py — Multi-curve bootstrapping for EUR OIS and EURIBOR
from __future__ import annotations

from dataclasses import dataclass, field
from enum import Enum, auto
from typing import Optional

import numpy as np
from scipy.interpolate import CubicSpline


class InstrumentType(Enum):
    DEPOSIT  = auto()   # Money market deposit: ACT/360, simple compounding
    OIS_SWAP = auto()   # OIS: fixed vs daily-compounded ESTR (ACT/360 float leg)
    FRA      = auto()   # Forward Rate Agreement: 3M EURIBOR, ACT/360
    IRS      = auto()   # Interest Rate Swap: fixed (ann, 30/360) vs 3M EURIBOR


@dataclass(frozen=True)
class Instrument:
    """
    Single market instrument input for yield curve bootstrapping.

    Conventions:
        rate     — decimal (0.035 = 3.5%)
        maturity — end maturity in years from today (ACT/365 approximation)
        start    — start maturity in years (used for FRAs; 0 for all others)
    """
    itype:    InstrumentType
    rate:     float
    maturity: float
    start:    float = 0.0
    label:    str   = ""


def _act360(t: float) -> float:
    """Convert a maturity in years (ACT/365 basis) to an ACT/360 day count fraction."""
    return t * 365.0 / 360.0


@dataclass
class DiscountCurve:
    """
    Piecewise-cubic interpolated discount curve built from (t_i, P_i) pillars.

    Interpolation: natural cubic spline on ln P(0,t).

    Rationale for log-space interpolation:
        The forward rate between t₁ and t₂ is:
            f(t₁,t₂) = −[ln P(t₂) − ln P(t₁)] / (t₂ − t₁)
        Interpolating ln P directly (rather than P or zero rates) ensures that
        the forward rate is controlled by the slope of a smooth cubic — it stays
        finite and bounded even between sparse pillars.

    Extrapolation: flat instantaneous forward rate beyond the last pillar.
        This is the industry-standard choice: it avoids the explosive growth
        that polynomial extrapolation would produce.

    Assumptions:
        - P(0,0) = 1 by definition.
        - All rates: continuously compounded, annualised.
        - No day-count adjustment beyond the ACT/360 conversion in the bootstrapper.
    """
    _pillars: list[tuple[float, float]] = field(default_factory=list)
    _spline: Optional[CubicSpline]      = field(default=None, init=False, repr=False)

    def add_pillar(self, t: float, P: float) -> None:
        """Append a (t, P(0,t)) pillar and rebuild the cubic spline."""
        if not (0 < P <= 1.0 + 1e-9):
            raise ValueError(
                f"Discount factor must satisfy 0 < P ≤ 1, got P({t:.4f}) = {P:.8f}. "
                "Check market data for implied negative forward rates."
            )
        self._pillars.append((t, max(P, 1e-12)))
        self._pillars.sort(key=lambda x: x[0])
        self._rebuild()

    def _rebuild(self) -> None:
        if len(self._pillars) < 2:
            return
        ts  = np.array([0.0] + [p[0] for p in self._pillars])
        Ps  = np.array([1.0] + [p[1] for p in self._pillars])
        self._spline = CubicSpline(ts, np.log(Ps), bc_type="not-a-knot")

    def df(self, t: float) -> float:
        """Discount factor P(0,t)."""
        if t <= 0.0:
            return 1.0
        if self._spline is None:
            raise RuntimeError("Curve has fewer than 2 pillars")
        t_max = self._pillars[-1][0]
        if t <= t_max:
            return float(np.exp(self._spline(t)))
        # Flat forward extrapolation
        f_last   = float(-self._spline(t_max, 1))   # −d(ln P)/dt at last pillar
        lnP_last = float(self._spline(t_max))
        return float(np.exp(lnP_last - f_last * (t - t_max)))

    def zero_rate(self, t: float) -> float:
        """Continuously compounded zero-coupon rate r(t) = −ln P(0,t) / t."""
        if t < 1e-9:
            return float(-self._spline(1e-6, 1)) if self._spline else 0.0
        return -np.log(self.df(t)) / t

    def inst_forward(self, t: float) -> float:
        """Instantaneous forward rate f(t) = −d(ln P)/dt."""
        if self._spline is None:
            raise RuntimeError("Curve not built")
        return float(-self._spline(max(t, 1e-9), 1))

    def forward_rate(self, t1: float, t2: float) -> float:
        """Simply-compounded forward rate F(0; t₁, t₂) = [P(t₁)/P(t₂) − 1]/(t₂−t₁)."""
        if t2 <= t1:
            raise ValueError(f"t2={t2} must exceed t1={t1}")
        return (self.df(t1) / self.df(t2) - 1.0) / (t2 - t1)

    @property
    def pillars(self) -> list[tuple[float, float]]:
        return list(self._pillars)

5.2 OIS Bootstrapping

Sequential closed-form bootstrap from deposits and OIS swaps. No root-finding — each instrument yields one new pillar via direct inversion of the pricing equation.

curve_builder.py — OIS bootstrapPython 3.10+
def bootstrap_ois(instruments: list[Instrument]) -> DiscountCurve:
    """
    Bootstrap the EUR OIS discount curve from ESTR-linked market instruments.

    Instrument types handled:
        DEPOSIT  — direct inversion: P(0,T) = 1 / (1 + r·δ(0,T))
        OIS_SWAP — iterative: solve for P(0,T_N) given all prior pillars

    OIS swap pricing equation (fixed annual vs daily-compounded ESTR):

        Fixed leg PV  =  K · Σᵢ δᵢ · P(0,Tᵢ)
        Float leg PV  =  P(0,T₀) − P(0,T_N)    [OIS ≈ compounding of overnight rates]

    Setting Fixed = Float and isolating the last unknown P(0,T_N):

        P(0,T_N) = [1 − K · Σᵢ₌₁^{N−1} δᵢ · P(0,Tᵢ)] / (1 + K · δ_N)

    This is the closed-form sequential bootstrap — no root-finding required.

    Limitations:
        - Fixed payment frequency assumed annual (standard EUR OIS convention).
        - Day count approximated as ACT/360 without exact business day adjustment.
          Use QuantLib for production schedules with holiday calendars (TARGET2).
        - For OIS tenors > 1Y with semi-annual fixed frequency, modify T_i grid.
    """
    curve = DiscountCurve()
    insts = sorted(instruments, key=lambda i: i.maturity)

    for inst in insts:
        T, K = inst.maturity, inst.rate

        if inst.itype == InstrumentType.DEPOSIT:
            # Direct inversion: P(0,T) = 1 / (1 + K·δ)
            P = 1.0 / (1.0 + K * _act360(T))
            curve.add_pillar(T, P)

        elif inst.itype == InstrumentType.OIS_SWAP:
            n = max(1, int(round(T)))
            T_i = [min(k, T) for k in range(1, n + 1)]
            δ_i = [_act360(T_i[0])] + [_act360(T_i[k] - T_i[k-1]) for k in range(1, n)]

            # Annuity from already-bootstrapped pillars (indices 0 … N-2)
            annuity = sum(δ_i[k] * curve.df(T_i[k]) for k in range(n - 1))

            P_N = (1.0 - K * annuity) / (1.0 + K * δ_i[-1])
            curve.add_pillar(T_i[-1], P_N)

    return curve

5.3 EURIBOR 3M Multi-Curve Bootstrap

Projects EURIBOR forward rates using OIS discount factors. Handles deposits, FRAs, and par IRS in sequence. The IRS equation requires solving for a ratio involving the last unknown EURIBOR pillar.

curve_builder.py — EURIBOR multi-curve bootstrapPython 3.10+
def bootstrap_euribor(
    instruments: list[Instrument],
    ois_curve: DiscountCurve,
) -> DiscountCurve:
    """
    Bootstrap the EURIBOR 3M projection curve using the OIS discount curve.

    Post-2008 multi-curve framework (Mercurio 2010):
        All cash flows are discounted on the OIS (risk-free) curve.
        EURIBOR forward rates are projected from a separate EURIBOR tenor curve.

    Instrument types:
        DEPOSIT — defines the 3M pillar directly: P_E(0,T) = 1/(1+K·δ)

        FRA — 3M EURIBOR forward, p×q months:
            F(0; T₁, T₂) = K  ⟹  P_E(0,T₁)/P_E(0,T₂) = 1 + K·δ(T₁,T₂)
            Given P_E(0,T₁): P_E(0,T₂) = P_E(0,T₁) / (1 + K·δ(T₁,T₂))

        IRS — par swap rate K (annual fixed vs quarterly 3M EURIBOR):

            Fixed PV  = K · Σᵢ δᵢ_fix · P_OIS(0,Tᵢ)
            Float PV  = Σⱼ [P_E(0,Tⱼ₋₁)/P_E(0,Tⱼ) − 1] · P_OIS(0,Tⱼ)

            Setting equal and solving for the last unknown P_E(0,T_N):

                Let  A = Σᵢ δᵢ_fix · P_OIS(0,Tᵢ)           [fixed-leg annuity]
                     B = Σⱼ₌₁^{N−1} [P_OIS(0,Tⱼ)·P_E(0,Tⱼ₋₁)/P_E(0,Tⱼ) − P_OIS(0,Tⱼ)]
                         [float PV from known pillars]

                P_E(0,T_N) = P_OIS(0,T_N) · P_E(0,T_{N−1}) / (K·A − B + P_OIS(0,T_N))

    Limitations:
        - OIS discounting assumes no CVA/DVA (collateralised trades, CSA assumed).
        - Tenor mismatch: if hedging with 6M instruments, need a separate 6M curve.
        - Basis between EURIBOR 3M and 6M is real (≈5–15bp pre-2022) and must be
          captured if pricing cross-tenor products (EURIBOR 3M vs 6M basis swaps).
    """
    curve = DiscountCurve()
    insts = sorted(instruments, key=lambda i: i.maturity)

    for inst in insts:
        T, T0, K = inst.maturity, inst.start, inst.rate

        if inst.itype == InstrumentType.DEPOSIT:
            P = 1.0 / (1.0 + K * _act360(T - T0))
            curve.add_pillar(T, P)

        elif inst.itype == InstrumentType.FRA:
            δ    = _act360(T - T0)
            P_T0 = curve.df(T0) if T0 > 1e-9 else 1.0
            curve.add_pillar(T, P_T0 / (1.0 + K * δ))

        elif inst.itype == InstrumentType.IRS:
            n_fix = max(1, int(round(T)))
            T_fix = [min(k, T) for k in range(1, n_fix + 1)]
            δ_fix = [_act360(T_fix[0])] + [_act360(T_fix[k] - T_fix[k-1]) for k in range(1, n_fix)]
            A = sum(d * ois_curve.df(t) for d, t in zip(δ_fix, T_fix))

            n_flt = max(1, int(round(T * 4)))
            T_flt = [min(k * 0.25, T) for k in range(1, n_flt + 1)]

            B, T_prev = 0.0, 0.0
            for j in range(len(T_flt) - 1):
                Tj     = T_flt[j]
                P_prev = curve.df(T_prev) if T_prev > 1e-9 else 1.0
                P_j    = curve.df(Tj)
                if P_j <= 0:
                    break
                B     += ois_curve.df(Tj) * (P_prev / P_j - 1.0)
                T_prev = Tj

            T_prev_N = T_flt[-2] if len(T_flt) > 1 else 0.0
            P_E_prev = curve.df(T_prev_N) if T_prev_N > 1e-9 else 1.0
            denom    = K * A - B + ois_curve.df(T)
            if abs(denom) < 1e-12:
                raise ValueError(f"Degenerate IRS bootstrap at T={T:.2f}Y (denom={denom:.2e})")
            curve.add_pillar(T, ois_curve.df(T) * P_E_prev / denom)

    return curve

5.4 Nelson-Siegel Fitting

Fits four NS parameters to a bootstrapped zero-rate curve via Levenberg-Marquardt. The Svensson variant (two humps) is also included — it is the model used by the ECB for its official published yield curve.

nelson_siegel.py — NS and SvenssonPython 3.10+
# nelson_siegel.py — Nelson-Siegel (1987) and Svensson (1994) parametrisations
from __future__ import annotations

import numpy as np
from scipy.optimize import least_squares
from typing import NamedTuple


class NSParams(NamedTuple):
    """
    Nelson-Siegel (1987) yield curve parametrisation.

    r(τ) = β₀ + β₁·g₁(τ/λ) + β₂·g₂(τ/λ)

    where:
        g₁(x) = (1 − e^{−x}) / x                  [Level loading]
        g₂(x) = (1 − e^{−x}) / x − e^{−x}         [Curvature loading]

    Factor interpretation:
        β₀ > 0  : Long-run level    (r → β₀ as τ → ∞)
        β₁      : Short/long spread (r(0⁺) − r(∞) = β₁; β₁ < 0 → normal curve)
        β₂      : Hump/trough       (β₂ < 0 → mid-curve trough; β₂ > 0 → hump)
        λ > 0   : Decay speed       (g₂ peaks at τ* = λ·ln 2 ≈ 0.693λ years)

    Limitations:
        - One hump only. Cannot fit curves with two local extrema (use Svensson).
        - β₀ must be positive for positive long-run forward rates.
        - Extrapolation to τ → ∞ collapses to β₀ regardless of market structure.
    """
    beta0:   float
    beta1:   float
    beta2:   float
    lambda_: float


class SvenssonParams(NamedTuple):
    """
    Svensson (1994) extension: adds a second hump factor β₃ with decay λ₂.

    r(τ) = β₀ + β₁·g₁(τ/λ₁) + β₂·g₂(τ/λ₁) + β₃·g₂(τ/λ₂)

    Used by the ECB and most central banks for publishing official yield curves.
    """
    beta0:    float
    beta1:    float
    beta2:    float
    beta3:    float
    lambda1:  float
    lambda2:  float


def _g1(x: np.ndarray) -> np.ndarray:
    return np.where(x < 1e-9, 1.0, (1 - np.exp(-x)) / x)


def _g2(x: np.ndarray) -> np.ndarray:
    ex = np.exp(-np.clip(x, 0, 500))
    return np.where(x < 1e-9, 0.0, (1 - ex) / x - ex)


def ns_zero(tau: np.ndarray | float, p: NSParams) -> np.ndarray:
    """Nelson-Siegel zero-coupon yield. Continuously compounded, annualised."""
    tau = np.asarray(tau, dtype=float)
    x   = tau / p.lambda_
    return p.beta0 + p.beta1 * _g1(x) + p.beta2 * _g2(x)


def ns_forward(tau: np.ndarray | float, p: NSParams) -> np.ndarray:
    """Instantaneous forward rate f(τ) = β₀ + β₁·e^{−τ/λ} + β₂·(τ/λ)·e^{−τ/λ}."""
    tau = np.asarray(tau, dtype=float)
    x   = tau / p.lambda_
    ex  = np.exp(-np.clip(x, 0, 500))
    return p.beta0 + p.beta1 * ex + p.beta2 * x * ex


def svensson_zero(tau: np.ndarray | float, p: SvenssonParams) -> np.ndarray:
    """Svensson zero-coupon yield."""
    tau = np.asarray(tau, dtype=float)
    return (
        p.beta0
        + p.beta1 * _g1(tau / p.lambda1)
        + p.beta2 * _g2(tau / p.lambda1)
        + p.beta3 * _g2(tau / p.lambda2)
    )


def fit_nelson_siegel(
    maturities: np.ndarray,
    zero_rates:  np.ndarray,
    weights:     np.ndarray | None = None,
    n_restarts:  int = 8,
) -> tuple[NSParams, float]:
    """
    Fit Nelson-Siegel parameters to a bootstrapped zero-rate curve.

    Method: Levenberg-Marquardt on weighted residuals.
    Minimises: Σᵢ wᵢ · (r_NS(τᵢ; θ) − rᵢ)²

    Args:
        maturities: Pillar maturities (years).
        zero_rates:  Continuously compounded zero rates (decimal).
        weights:    Per-pillar weights (default: uniform).
        n_restarts: Random restarts to avoid local minima.

    Returns:
        (params, rmse_in_bps)
    """
    if weights is None:
        weights = np.ones_like(maturities)

    def residuals(x: np.ndarray) -> np.ndarray:
        p = NSParams(beta0=x[0], beta1=x[1], beta2=x[2], lambda_=max(x[3], 0.05))
        return weights * (ns_zero(maturities, p) - zero_rates)

    bounds = ([0.0, -0.20, -0.20, 0.1], [0.20, 0.20, 0.20, 10.0])

    rng     = np.random.default_rng(42)
    r_short = float(zero_rates[0])
    r_long  = float(np.mean(zero_rates[-3:]) if len(zero_rates) >= 3 else zero_rates[-1])
    best, best_cost = None, np.inf

    for i in range(n_restarts):
        x0 = (
            [max(r_long, 0.001), r_short - r_long, 0.0, 2.0]
            if i == 0 else
            [rng.uniform(0.001, 0.12), rng.uniform(-0.15, 0.15),
             rng.uniform(-0.15, 0.15), rng.uniform(0.5, 8.0)]
        )
        try:
            res = least_squares(residuals, x0, bounds=bounds, method="trf",
                                ftol=1e-12, xtol=1e-12, gtol=1e-12)
            if res.cost < best_cost:
                best, best_cost = res, res.cost
        except Exception:
            continue

    if best is None:
        raise RuntimeError("Nelson-Siegel fitting failed on all restarts")

    p = NSParams(*best.x[:3], lambda_=best.x[3])
    rmse_bps = float(np.sqrt(np.mean(residuals(best.x) ** 2))) * 10_000
    return p, rmse_bps

5.5 DV01 per Pillar (Jacobian Bump-Reval)

For each market instrument, bump its rate by +1bp, re-bootstrap, and record the change in every discount factor. This gives the full Jacobian matrix ∂P(0,T_j)/∂K_i — the exact sensitivity of bootstrapped discount factors to market quotes.

sensitivities.py — DV01 ladderPython 3.10+
# sensitivities.py — DV01 per pillar via Jacobian bump-reval
from __future__ import annotations

from copy import deepcopy
from dataclasses import dataclass

import numpy as np

from curve_builder import Instrument, InstrumentType, DiscountCurve, bootstrap_ois


@dataclass
class PillarSensitivity:
    label:         str
    maturity:      float          # years
    zero_rate_pct: float          # base zero rate at this pillar, in %
    df:            float          # base discount factor P(0, T)
    dv01_eur:      float          # DV01 in EUR (10M notional zero-coupon bond)
    dv01_curve:    dict[str, float]  # ΔP(0,Tⱼ) per 1bp bump of this instrument


def dv01_ladder(
    ois_instruments: list[Instrument],
    bump_bp: float = 1.0,
    notional: float = 10_000_000,
) -> list[PillarSensitivity]:
    """
    DV01 ladder via Jacobian bump-reval.

    For each instrument i:
        1. Bump instrument i's rate by +bump_bp basis points.
        2. Re-bootstrap the OIS curve.
        3. DV01(i, j) = ΔP(0,Tⱼ) × notional / bump_bp  for all pillars j.

    This gives the Jacobian ∂P(0,Tⱼ) / ∂Kᵢ (per 1bp) — the full sensitivity
    matrix of bootstrapped discount factors to market instrument quotes.

    Analytic approximation (for a ZCB at pillar T):
        DV01(T) = notional · T · P(0,T) · 1bp
    This is the modified duration formula and is accurate for parallel shifts
    of the zero curve; the Jacobian method is exact for instrument-specific bumps.

    Args:
        ois_instruments: List of OIS market instruments.
        bump_bp:         Bump size in basis points.
        notional:        EUR notional per pillar (default 10M).

    Returns:
        List of PillarSensitivity, one per instrument.
    """
    bump    = bump_bp * 1e-4
    base    = bootstrap_ois(ois_instruments)
    result  = []

    for i, inst in enumerate(ois_instruments):
        bumped_insts       = deepcopy(ois_instruments)
        bumped_insts[i]    = Instrument(
            itype=inst.itype, rate=inst.rate + bump,
            maturity=inst.maturity, start=inst.start, label=inst.label,
        )
        bumped = bootstrap_ois(bumped_insts)

        # Jacobian row: change in each pillar DF per 1bp bump of instrument i
        dv01_curve: dict[str, float] = {}
        for t, P_base in base.pillars:
            delta_P = bumped.df(t) - P_base
            dv01_curve[f"{t:.3f}Y"] = delta_P * notional / bump_bp

        T = inst.maturity
        result.append(PillarSensitivity(
            label         = inst.label or f"OIS_{T:.2f}Y",
            maturity      = T,
            zero_rate_pct = base.zero_rate(T) * 100,
            df            = base.df(T),
            dv01_eur      = notional * T * base.df(T) * 1e-4,
            dv01_curve    = dv01_curve,
        ))

    return result

6. Live Market Data (ECB SDMX API)

The ECB Statistical Data Warehouse provides daily fixings for EURIBOR (all tenors), €STR, and the ECB AAA government bond yield curve — all free, no API key required. The API conforms to SDMX 2.1 REST and returns JSON.

The fetch_euribor() and fetch_estr() functions return pd.Series with a DatetimeIndex. The fetch_ecb_yield_curve() function pulls spot rates at 8 standard tenors from the ECB's Svensson-fitted AAA-rated government bond curve — suitable for initialising a Nelson-Siegel fit.

market_data.py — ECB SDMX REST APIPython 3.10+
# market_data.py — Live EUR rate data from ECB SDMX REST API
from __future__ import annotations

from typing import Optional
import requests
import pandas as pd


ECB_BASE = "https://data-api.ecb.europa.eu/service/data"


def _fetch_ecb(flow: str, key: str, start: str, end: Optional[str] = None) -> pd.Series:
    """
    Fetch a single time series from the ECB Data Portal (SDMX-JSON format).

    Args:
        flow:  Dataset code (e.g. "FM" = financial markets, "EST" = €STR).
        key:   Series key within the dataset.
        start: ISO date string "YYYY-MM-DD" for start period.
        end:   ISO date string for end period (default: today).

    Returns:
        pd.Series with DatetimeIndex and float values in decimal (not %).

    Raises:
        requests.HTTPError on non-200 response.
        ValueError if the API returns an empty or malformed payload.

    Note on rate units:
        The ECB publishes EURIBOR and €STR in percent (e.g. 3.90 for 3.90%).
        This function divides by 100 before returning. Always check units
        when mixing sources.
    """
    params: dict[str, str] = {"format": "jsondata", "startPeriod": start}
    if end:
        params["endPeriod"] = end

    r = requests.get(f"{ECB_BASE}/{flow}/{key}", params=params, timeout=30)
    r.raise_for_status()
    data = r.json()

    try:
        # The series key varies by dataset; take the first (and only) series.
        series_key = next(iter(data["dataSets"][0]["series"]))
        obs        = data["dataSets"][0]["series"][series_key]["observations"]
        date_vals  = data["structure"]["dimensions"]["observation"][0]["values"]
    except (KeyError, IndexError, StopIteration) as e:
        raise ValueError(f"Unexpected ECB API response for {flow}/{key}: {e}") from e

    index  = pd.to_datetime([d["id"] for d in date_vals])
    values = [obs[str(i)][0] if str(i) in obs else float("nan") for i in range(len(date_vals))]
    s = pd.Series(values, index=index, dtype=float).dropna()

    if s.empty:
        raise ValueError(f"No data returned for {flow}/{key} from {start}")
    return s / 100.0   # percent → decimal


# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------

EURIBOR_SERIES = {
    "1W":  "FM/B.U2.EUR.RT0.MM.EURIBOR1WD_.HSTA",
    "1M":  "FM/B.U2.EUR.RT0.MM.EURIBOR1MD_.HSTA",
    "3M":  "FM/B.U2.EUR.RT0.MM.EURIBOR3MD_.HSTA",
    "6M":  "FM/B.U2.EUR.RT0.MM.EURIBOR6MD_.HSTA",
    "12M": "FM/B.U2.EUR.RT0.MM.EURIBOR12MD_.HSTA",
}

ECB_YC_SERIES = {
    "3M":  "YC/B.U2.EUR.4F.G_N_A.SV_C_YM.SR_3M",
    "6M":  "YC/B.U2.EUR.4F.G_N_A.SV_C_YM.SR_6M",
    "1Y":  "YC/B.U2.EUR.4F.G_N_A.SV_C_YM.SR_1Y",
    "2Y":  "YC/B.U2.EUR.4F.G_N_A.SV_C_YM.SR_2Y",
    "5Y":  "YC/B.U2.EUR.4F.G_N_A.SV_C_YM.SR_5Y",
    "10Y": "YC/B.U2.EUR.4F.G_N_A.SV_C_YM.SR_10Y",
    "20Y": "YC/B.U2.EUR.4F.G_N_A.SV_C_YM.SR_20Y",
    "30Y": "YC/B.U2.EUR.4F.G_N_A.SV_C_YM.SR_30Y",
}


def fetch_euribor(tenor: str = "3M", start: str = "2020-01-01",
                  end: Optional[str] = None) -> pd.Series:
    """
    EURIBOR fixing from the ECB Statistical Data Warehouse.

    Convention: ACT/360, simple compounding.
    Tenors available: "1W", "1M", "3M", "6M", "12M".

    Example:
        euribor3m = fetch_euribor("3M", start="2023-01-01")
        print(f"Latest 3M EURIBOR: {euribor3m.iloc[-1]:.4%}")
    """
    if tenor not in EURIBOR_SERIES:
        raise ValueError(f"Tenor {tenor!r} not in {list(EURIBOR_SERIES)}")
    flow, key = EURIBOR_SERIES[tenor].split("/", 1)
    return _fetch_ecb(flow, key, start, end)


def fetch_estr(start: str = "2020-01-01", end: Optional[str] = None) -> pd.Series:
    """
    Euro Short-Term Rate (€STR) from the ECB.

    €STR replaced EONIA effective 2 January 2022.
    Convention: ACT/360, simple compounding.
    Published by ECB by 08:00 CET each TARGET2 business day.

    Historical note: EONIA = €STR + 8.5bp was maintained transitionally until
    3 January 2022. For historical back-fill before 2 October 2019 (€STR launch),
    use EONIA − 8.5bp as an approximation.
    """
    return _fetch_ecb("EST", "B.EU000A2X2A25.WT", start, end)


def fetch_ecb_yield_curve(start: str = "2020-01-01",
                           end: Optional[str] = None) -> pd.DataFrame:
    """
    ECB AAA-rated government bond spot yield curve at standard tenors.

    The ECB fits a Svensson (1994) model daily to a basket of euro-area
    government bonds rated AAA. This function extracts spot rates at
    standard tenors from the published curve.

    Returns:
        DataFrame with DatetimeIndex and columns = tenor strings
        (e.g. "3M", "1Y", "10Y"). Rates in decimal.

    Usage:
        df = fetch_ecb_yield_curve(start="2023-01-01")
        latest = df.iloc[-1]   # most recent yield curve
        print(latest * 100)    # in percent

    Reference:
        https://www.ecb.europa.eu/stats/financial_markets_and_interest_rates/
        euro_area_yield_curves/html/index.en.html
    """
    frames: list[pd.Series] = []
    for label, series_id in ECB_YC_SERIES.items():
        flow, key = series_id.split("/", 1)
        try:
            s = _fetch_ecb(flow, key, start, end)
            s.name = label
            frames.append(s)
        except Exception as e:
            print(f"Warning: could not fetch {label}: {e}")
            continue

    if not frames:
        raise ValueError("No ECB yield curve data returned")
    return pd.DataFrame(frames).T.sort_index()


def build_ois_instruments_from_estr(
    estr_rate: float,
    swap_quotes: dict[str, float],
) -> list["Instrument"]:
    """
    Construct a list of OIS Instrument objects from live market data.

    Args:
        estr_rate:   Latest €STR fixing (decimal).
        swap_quotes: Dict of {tenor_label: par_rate_decimal}, e.g.:
                     {"1M": 0.039, "3M": 0.038, "6M": 0.037, "1Y": 0.035, ...}

    Returns:
        List[Instrument] ready for bootstrap_ois().

    Tenor to maturity mapping (approximate; use QuantLib for exact dates):
        "1M" → 1/12, "3M" → 0.25, "6M" → 0.5, "1Y" → 1.0, etc.
    """
    from curve_builder import Instrument, InstrumentType

    tenor_to_years = {
        "ON": 1/365, "TN": 2/365, "1W": 1/52,
        "1M": 1/12,  "3M": 0.25,  "6M": 0.5,
        "9M": 0.75,  "1Y": 1.0,   "2Y": 2.0,
        "3Y": 3.0,   "5Y": 5.0,   "7Y": 7.0,
        "10Y": 10.0, "15Y": 15.0, "20Y": 20.0, "30Y": 30.0,
    }

    insts: list[Instrument] = [
        Instrument(InstrumentType.DEPOSIT, rate=estr_rate,
                   maturity=1/365, label="ESTR_ON"),
    ]
    for tenor, rate in swap_quotes.items():
        t = tenor_to_years.get(tenor)
        if t is None:
            continue
        itype = InstrumentType.DEPOSIT if t <= 0.25 else InstrumentType.OIS_SWAP
        insts.append(Instrument(itype=itype, rate=rate, maturity=t, label=f"OIS_{tenor}"))

    return sorted(insts, key=lambda i: i.maturity)

Running the Full Pipeline

from market_data import fetch_estr, fetch_euribor, fetch_ecb_yield_curve, build_ois_instruments_from_estr
from curve_builder import bootstrap_ois, bootstrap_euribor, InstrumentType, Instrument
from nelson_siegel import fit_nelson_siegel, ns_zero
from visualisation import plot_curves, plot_dv01_ladder, plot_historical_surface
import numpy as np

# 1. Fetch live data
estr_rate = fetch_estr(start="2026-03-01").iloc[-1]   # latest €STR fixing
swap_quotes = {"1M": 0.038, "3M": 0.037, "6M": 0.036, "1Y": 0.034,
               "2Y": 0.031, "5Y": 0.028, "10Y": 0.027, "30Y": 0.026}

# 2. Build OIS curve
ois_insts = build_ois_instruments_from_estr(estr_rate, swap_quotes)
ois_curve = bootstrap_ois(ois_insts)

# 3. Build EURIBOR 3M projection curve
euribor_insts = [
    Instrument(InstrumentType.DEPOSIT, rate=0.039, maturity=0.25, label="EURIBOR_3M"),
    Instrument(InstrumentType.FRA,     rate=0.037, maturity=0.50, start=0.25, label="3x6"),
    Instrument(InstrumentType.IRS,     rate=0.031, maturity=2.0,  label="IRS_2Y"),
    Instrument(InstrumentType.IRS,     rate=0.028, maturity=5.0,  label="IRS_5Y"),
]
euribor_curve = bootstrap_euribor(euribor_insts, ois_curve)

# 4. Fit Nelson-Siegel to OIS curve
pillars = [t for t, _ in ois_curve.pillars]
zeros   = [ois_curve.zero_rate(t) for t in pillars]
ns_params, rmse_bps = fit_nelson_siegel(np.array(pillars), np.array(zeros))
print(f"Nelson-Siegel fit RMSE: {rmse_bps:.2f}bps")

# 5. Plot (returns go.Figure — call .show() or .write_html("curve.html"))
fig = plot_curves(ois_curve, euribor_curve, ns_params)
fig.show()

dv01_fig = plot_dv01_ladder(ois_curve)
dv01_fig.show()

# 6. Historical surface
df_hist = fetch_ecb_yield_curve(start="2022-01-01")
surf_fig = plot_historical_surface(df_hist)
surf_fig.show()

7. Plotly Visualisations

Four interactive Plotly figures are provided. All use the plotly_dark theme and can be exported to standalone HTML files with .write_html().

visualisation.py — Plotly chartsPython 3.10+
# visualisation.py — Plotly interactive yield curve charts
from __future__ import annotations

import numpy as np
import pandas as pd
import plotly.graph_objects as go
from plotly.subplots import make_subplots

from curve_builder import DiscountCurve
from nelson_siegel import NSParams, ns_zero, ns_forward, SvenssonParams, svensson_zero


TAU_GRID = np.linspace(0.25, 30, 300)


def plot_curves(
    ois_curve: DiscountCurve,
    euribor_curve: DiscountCurve | None = None,
    ns_params: NSParams | None = None,
) -> go.Figure:
    """
    Interactive yield curve: zero rates + instantaneous forward rates.

    Panels:
        Left  — Zero coupon rates (OIS, EURIBOR 3M, Nelson-Siegel fit)
        Right — Instantaneous forward rates (same three curves)

    All rates in % on y-axis. Continuously compounded, ACT/365 convention.
    """
    fig = make_subplots(
        rows=1, cols=2,
        subplot_titles=["Zero Coupon Rates (%)", "Instantaneous Forward Rates (%)"],
        column_widths=[0.5, 0.5],
    )

    ois_z = [ois_curve.zero_rate(t) * 100 for t in TAU_GRID]
    ois_f = [ois_curve.inst_forward(t) * 100 for t in TAU_GRID]

    for col, ys in enumerate([ois_z, ois_f], start=1):
        fig.add_trace(go.Scatter(
            x=TAU_GRID, y=ys, mode="lines", name="OIS (ESTR)",
            line=dict(color="#6366f1", width=2.5), showlegend=(col == 1),
        ), row=1, col=col)

    # Pillar markers on zero curve
    for t, P in ois_curve.pillars:
        fig.add_trace(go.Scatter(
            x=[t], y=[-np.log(P) / t * 100], mode="markers",
            marker=dict(color="#6366f1", size=7, symbol="circle-open", line=dict(width=2)),
            name="OIS pillars", showlegend=False,
        ), row=1, col=1)

    if euribor_curve is not None and len(euribor_curve.pillars) > 1:
        eib_z = [euribor_curve.zero_rate(t) * 100 for t in TAU_GRID]
        eib_f = [euribor_curve.inst_forward(t) * 100 for t in TAU_GRID]
        for col, ys in enumerate([eib_z, eib_f], start=1):
            fig.add_trace(go.Scatter(
                x=TAU_GRID, y=ys, mode="lines", name="EURIBOR 3M",
                line=dict(color="#10b981", width=2.5), showlegend=(col == 1),
            ), row=1, col=col)

        # EURIBOR-OIS spread (basis) overlay on left panel
        spread_bps = [(e - o) * 100 for e, o in zip(eib_z, ois_z)]
        fig.add_trace(go.Scatter(
            x=TAU_GRID, y=spread_bps, mode="lines",
            name="EURIBOR-OIS spread (%)",
            line=dict(color="#f59e0b", width=1.5, dash="dot"),
            showlegend=True,
        ), row=1, col=1)

    if ns_params is not None:
        ns_z = ns_zero(TAU_GRID, ns_params) * 100
        ns_f = ns_forward(TAU_GRID, ns_params) * 100
        for col, ys in enumerate([ns_z, ns_f], start=1):
            fig.add_trace(go.Scatter(
                x=TAU_GRID, y=ys, mode="lines", name="Nelson-Siegel fit",
                line=dict(color="#f97316", width=2, dash="dot"), showlegend=(col == 1),
            ), row=1, col=col)

    fig.update_xaxes(title_text="Maturity τ (years)")
    fig.update_yaxes(title_text="Rate (%)", row=1, col=1)
    fig.update_yaxes(title_text="Forward Rate (%)", row=1, col=2)
    fig.update_layout(
        height=430, template="plotly_dark",
        title="EUR Yield Curves — OIS & EURIBOR 3M",
        hovermode="x unified",
        legend=dict(orientation="h", yanchor="bottom", y=1.02),
    )
    return fig


def plot_dv01_ladder(
    curve: DiscountCurve,
    pillars: list[float] | None = None,
    notional: float = 10_000_000,
) -> go.Figure:
    """
    Bar chart of DV01 per pillar (EUR per 1bp, ZCB approximation).

    DV01(τ) = Notional · τ · P(0,τ) · 1bp

    This is the modified duration formula for a zero-coupon bond. For coupon
    bonds or par swaps, use the full Jacobian from sensitivities.dv01_ladder().

    Args:
        curve:    Bootstrapped OIS (or EURIBOR) discount curve.
        pillars:  Maturities in years (default: standard EUR swap tenors).
        notional: EUR notional per pillar.
    """
    if pillars is None:
        pillars = [0.25, 0.5, 1, 2, 3, 5, 7, 10, 15, 20, 30]

    labels = [f"{int(t*12)}M" if t < 1 else f"{int(t)}Y" for t in pillars]
    dv01s  = [notional * t * curve.df(t) * 1e-4 for t in pillars]
    zeros  = [curve.zero_rate(t) * 100 for t in pillars]
    n      = len(pillars)
    colors = [f"hsl({int(240 - i * 200 / (n-1))}, 65%, 55%)" for i in range(n)]

    fig = make_subplots(
        rows=2, cols=1,
        subplot_titles=["DV01 per Pillar (EUR, 10M notional)", "Zero Coupon Rate (%)"],
        row_heights=[0.65, 0.35], vertical_spacing=0.12,
    )
    fig.add_trace(go.Bar(
        x=labels, y=dv01s, marker=dict(color=colors),
        text=[f"€{d:,.0f}" for d in dv01s], textposition="outside",
        name="DV01",
    ), row=1, col=1)
    fig.add_trace(go.Scatter(
        x=labels, y=zeros, mode="lines+markers",
        line=dict(color="#6366f1", width=2), marker=dict(size=6),
        name="Zero Rate",
    ), row=2, col=1)

    fig.update_yaxes(title_text="DV01 (EUR)", row=1, col=1)
    fig.update_yaxes(title_text="Rate (%)", row=2, col=1)
    fig.update_layout(
        height=520, template="plotly_dark",
        title="OIS Curve — DV01 Ladder", showlegend=False,
    )
    return fig


def plot_historical_surface(df: pd.DataFrame) -> go.Figure:
    """
    3-D yield surface: rate = f(maturity, date).

    Args:
        df: DataFrame from fetch_ecb_yield_curve(); index = dates,
            columns = tenor strings, values = rates in decimal.
    """
    def to_years(s: str) -> float:
        return int(s[:-1]) / 12 if s.endswith("M") else float(s[:-1])

    x = [to_years(c) for c in df.columns]
    z = df.values * 100
    date_labels = [d.strftime("%Y-%m-%d") for d in df.index]

    fig = go.Figure(data=[go.Surface(
        x=x, y=list(range(len(df))), z=z,
        colorscale="RdYlBu_r",
        colorbar=dict(title="Rate (%)"),
        hovertemplate="Maturity: %{x:.1f}Y<br>Rate: %{z:.3f}%<extra></extra>",
    )])
    tick_step = max(1, len(df) // 10)
    fig.update_layout(
        title="EUR Yield Surface — ECB AAA Government Bonds",
        scene=dict(
            xaxis_title="Maturity (years)",
            yaxis=dict(
                title="Date",
                tickvals=list(range(0, len(df), tick_step)),
                ticktext=date_labels[::tick_step],
            ),
            zaxis_title="Rate (%)",
            camera=dict(eye=dict(x=1.8, y=-1.5, z=0.8)),
        ),
        height=560, template="plotly_dark",
    )
    return fig


def plot_animated_curve(df: pd.DataFrame) -> go.Figure:
    """
    Animated yield curve with a date slider. Useful for
    visualising ECB rate-hiking / cutting cycles.

    Args:
        df: Same format as fetch_ecb_yield_curve() output.
    """
    def to_years(s: str) -> float:
        return int(s[:-1]) / 12 if s.endswith("M") else float(s[:-1])

    x = [to_years(c) for c in df.columns]
    frames = [
        go.Frame(
            data=[go.Scatter(x=x, y=df.iloc[i].values * 100,
                             mode="lines+markers",
                             line=dict(color="#6366f1", width=2.5),
                             marker=dict(size=6))],
            name=df.index[i].strftime("%Y-%m-%d"),
            layout=go.Layout(title_text=f"EUR Yield Curve — {df.index[i].strftime('%Y-%m-%d')}"),
        )
        for i in range(len(df))
    ]
    fig = go.Figure(
        data=frames[0].data,
        frames=frames,
        layout=go.Layout(
            title=f"EUR Yield Curve — {df.index[-1].strftime('%Y-%m-%d')}",
            xaxis_title="Maturity (years)",
            yaxis_title="Spot Rate (%)",
            template="plotly_dark", height=430,
            updatemenus=[dict(type="buttons", buttons=[
                dict(label="▶ Play",  method="animate",
                     args=[None, dict(frame=dict(duration=180, redraw=True), fromcurrent=True)]),
                dict(label="⏸ Pause", method="animate",
                     args=[[None], dict(frame=dict(duration=0), mode="immediate")]),
            ])],
            sliders=[dict(
                steps=[dict(args=[[f.name], dict(frame=dict(duration=180), mode="immediate")],
                            label=f.name, method="animate") for f in frames],
                currentvalue=dict(prefix="Date: "), len=0.9, x=0.05,
            )],
        ),
    )
    return fig

plot_curves()

Side-by-side: zero rates and instantaneous forward rates for OIS, EURIBOR 3M, and Nelson-Siegel fit. EURIBOR-OIS spread overlaid.

plot_dv01_ladder()

Bar chart of DV01 per pillar (ZCB approximation) with zero rate curve below. Color-coded by duration — short blue, long red.

plot_historical_surface()

3-D Plotly surface: rate = f(maturity, date). Visualises term structure evolution over ECB rate cycles (e.g. 2022 hiking cycle).

plot_animated_curve()

Animated yield curve with date slider and play/pause controls. Shows the curve morphing from normal → inverted → steepening.

8. Test Suite

Tests verify: par repricing of every bootstrapped instrument (the most fundamental self-consistency check), monotonicity of discount factors, positivity of forward rates, multi-curve EURIBOR-OIS ordering, and Nelson-Siegel round-trip fitting accuracy. Run with pytest tests/test_curve_builder.py -v.

tests/test_curve_builder.py — pytest suitePython 3.10+
# tests/test_curve_builder.py — pytest test suite
# Run: pytest tests/ -v
import numpy as np
import pytest

from curve_builder import (
    Instrument, InstrumentType, DiscountCurve,
    bootstrap_ois, bootstrap_euribor, _act360,
)
from nelson_siegel import (
    NSParams, ns_zero, ns_forward, fit_nelson_siegel,
)


# ── Fixtures ──────────────────────────────────────────────────────────────────

@pytest.fixture
def ois_instruments() -> list[Instrument]:
    """EUR OIS market snapshot (approximate 2024 inverted curve)."""
    return [
        Instrument(InstrumentType.DEPOSIT,  rate=0.0390, maturity=1/12,  label="DEP_1M"),
        Instrument(InstrumentType.DEPOSIT,  rate=0.0388, maturity=0.25,  label="DEP_3M"),
        Instrument(InstrumentType.OIS_SWAP, rate=0.0370, maturity=0.5,   label="OIS_6M"),
        Instrument(InstrumentType.OIS_SWAP, rate=0.0350, maturity=1.0,   label="OIS_1Y"),
        Instrument(InstrumentType.OIS_SWAP, rate=0.0310, maturity=2.0,   label="OIS_2Y"),
        Instrument(InstrumentType.OIS_SWAP, rate=0.0280, maturity=5.0,   label="OIS_5Y"),
        Instrument(InstrumentType.OIS_SWAP, rate=0.0265, maturity=10.0,  label="OIS_10Y"),
        Instrument(InstrumentType.OIS_SWAP, rate=0.0255, maturity=30.0,  label="OIS_30Y"),
    ]

@pytest.fixture
def euribor_instruments() -> list[Instrument]:
    """EUR EURIBOR 3M curve instruments (approximate 2024 levels)."""
    return [
        Instrument(InstrumentType.DEPOSIT, rate=0.0393, maturity=0.25, label="EURIBOR_3M"),
        Instrument(InstrumentType.FRA,     rate=0.0375, maturity=0.50, start=0.25, label="3x6"),
        Instrument(InstrumentType.FRA,     rate=0.0360, maturity=0.75, start=0.50, label="6x9"),
        Instrument(InstrumentType.FRA,     rate=0.0345, maturity=1.00, start=0.75, label="9x12"),
        Instrument(InstrumentType.IRS,     rate=0.0310, maturity=2.0,  label="IRS_2Y"),
        Instrument(InstrumentType.IRS,     rate=0.0285, maturity=5.0,  label="IRS_5Y"),
        Instrument(InstrumentType.IRS,     rate=0.0268, maturity=10.0, label="IRS_10Y"),
    ]


# ── Discount curve fundamentals ────────────────────────────────────────────────

class TestDiscountCurve:
    def test_df_at_zero(self, ois_instruments):
        curve = bootstrap_ois(ois_instruments)
        assert abs(curve.df(0.0) - 1.0) < 1e-10, "P(0,0) must equal 1"

    def test_dfs_monotone_decreasing(self, ois_instruments):
        curve = bootstrap_ois(ois_instruments)
        ts    = [0.25, 0.5, 1, 2, 5, 10, 20]
        dfs   = [curve.df(t) for t in ts]
        for i in range(len(dfs) - 1):
            assert dfs[i] > dfs[i+1], (
                f"Non-monotone: P({ts[i]}) = {dfs[i]:.6f} ≤ P({ts[i+1]}) = {dfs[i+1]:.6f}"
            )

    def test_zero_rates_positive(self, ois_instruments):
        curve = bootstrap_ois(ois_instruments)
        for t in [0.5, 1, 5, 10]:
            assert curve.zero_rate(t) > 0, f"Negative zero rate at t={t}"

    def test_forward_rates_positive(self, ois_instruments):
        """No negative simply-compounded forward rates (necessary for no-arb)."""
        curve = bootstrap_ois(ois_instruments)
        for t in [0.25, 0.5, 1, 2, 5, 10]:
            f = curve.forward_rate(t, t + 0.25)
            assert f > 0, f"Negative forward F(0;{t},{t+0.25}) = {f:.6f}"


# ── OIS bootstrap self-consistency ────────────────────────────────────────────

class TestOISBootstrap:
    def test_deposit_reprices_at_par(self, ois_instruments):
        """
        A bootstrapped deposit must satisfy: (1 + K·δ) · P(0,T) = 1.
        This is the definition of par repricing.
        """
        curve = bootstrap_ois(ois_instruments)
        for inst in ois_instruments:
            if inst.itype == InstrumentType.DEPOSIT:
                T   = inst.maturity
                pv  = (1 + inst.rate * _act360(T)) * curve.df(T)
                assert abs(pv - 1.0) < 1e-8, (
                    f"Deposit {T:.3f}Y PV = {pv:.10f} ≠ 1"
                )

    def test_ois_swap_reprices_at_par(self, ois_instruments):
        """Par OIS swap: fixed leg PV = float leg PV = P(0,0) − P(0,T) = 1 − P(0,T)."""
        curve = bootstrap_ois(ois_instruments)
        for inst in ois_instruments:
            if inst.itype == InstrumentType.OIS_SWAP:
                T      = inst.maturity
                K      = inst.rate
                n      = max(1, int(round(T)))
                T_i    = [min(k, T) for k in range(1, n + 1)]
                d_i    = [_act360(T_i[0])] + [_act360(T_i[k] - T_i[k-1]) for k in range(1, n)]
                fixed  = K * sum(d * curve.df(t) for d, t in zip(d_i, T_i))
                flt    = 1.0 - curve.df(T)
                assert abs(fixed - flt) < 1e-6, (
                    f"OIS {T}Y NPV = {fixed - flt:.2e} (should be 0)"
                )


# ── Multi-curve bootstrap ─────────────────────────────────────────────────────

class TestMultiCurve:
    def test_euribor_above_ois(self, ois_instruments, euribor_instruments):
        """EURIBOR zero rates must exceed OIS zero rates (positive credit/liquidity basis)."""
        ois   = bootstrap_ois(ois_instruments)
        eibor = bootstrap_euribor(euribor_instruments, ois)
        for t in [0.25, 0.5, 1.0, 2.0]:
            assert eibor.zero_rate(t) >= ois.zero_rate(t) - 1e-6, (
                f"EURIBOR < OIS at t={t}: {eibor.zero_rate(t):.4%} < {ois.zero_rate(t):.4%}"
            )

    def test_euribor_deposit_reprices(self, ois_instruments, euribor_instruments):
        ois   = bootstrap_ois(ois_instruments)
        eibor = bootstrap_euribor(euribor_instruments, ois)
        dep   = next(i for i in euribor_instruments if i.itype == InstrumentType.DEPOSIT)
        pv    = (1 + dep.rate * _act360(dep.maturity)) * eibor.df(dep.maturity)
        assert abs(pv - 1.0) < 1e-7, f"EURIBOR deposit PV = {pv:.10f}"


# ── Nelson-Siegel ─────────────────────────────────────────────────────────────

class TestNelsonSiegel:
    def test_short_rate_limit(self):
        """r(0⁺) = β₀ + β₁ (g₁ → 1, g₂ → 0 as x → 0)."""
        p = NSParams(beta0=0.04, beta1=-0.015, beta2=0.008, lambda_=2.5)
        r0 = float(ns_zero(1e-9, p))
        assert abs(r0 - (p.beta0 + p.beta1)) < 1e-6

    def test_long_rate_limit(self):
        """r(∞) = β₀ (g₁ → 0, g₂ → 0 as x → ∞)."""
        p = NSParams(beta0=0.04, beta1=-0.015, beta2=0.008, lambda_=2.5)
        assert abs(float(ns_zero(1000.0, p)) - p.beta0) < 1e-6

    def test_hump_location(self):
        """The curvature factor g₂ peaks at τ* = λ · ln 2."""
        p    = NSParams(beta0=0.04, beta1=0.0, beta2=1.0, lambda_=2.0)
        tau* = p.lambda_ * np.log(2)
        taus = np.linspace(0.1, 10, 500)
        # The NS forward rate with β₀=β₁=0 is just β₂·(τ/λ)·e^{−τ/λ},
        # which peaks at τ = λ. The NS zero rate (g₂) peaks near τ = λ·ln2.
        g2_vals = np.where(
            taus / p.lambda_ < 1e-9, 0.0,
            (1 - np.exp(-taus / p.lambda_)) / (taus / p.lambda_) - np.exp(-taus / p.lambda_)
        )
        peak_tau = taus[np.argmax(g2_vals)]
        assert abs(peak_tau - tau*) < 0.15, f"g₂ peak at {peak_tau:.3f}, expected {tau*:.3f}"

    def test_roundtrip_fitting(self):
        """Generate zero rates from known NS params, fit, verify recovery."""
        true = NSParams(beta0=0.04, beta1=-0.015, beta2=0.008, lambda_=2.5)
        taus = np.array([0.25, 0.5, 1, 2, 3, 5, 7, 10, 15, 20, 30])
        rates = ns_zero(taus, true)
        fitted, rmse_bps = fit_nelson_siegel(taus, rates)
        assert rmse_bps < 0.1, f"Fit RMSE {rmse_bps:.3f}bps > 0.1bps"
        np.testing.assert_allclose(ns_zero(taus, fitted), rates, atol=5e-6)

    @pytest.mark.parametrize("t", [0.25, 1.0, 5.0, 10.0, 30.0])
    def test_discount_factors_in_unit_interval(self, t):
        p   = NSParams(beta0=0.04, beta1=-0.015, beta2=0.008, lambda_=2.5)
        P   = float(np.exp(-float(ns_zero(t, p)) * t))
        assert 0 < P <= 1, f"P(0,{t}) = {P:.6f} out of (0,1]"

9. Limitations and Failure Modes

Where This Bootstrapper Breaks Down

  • No business day calendar: Day count fractions use ACT/360 approximated as t × 365/360. Exact schedules require TARGET2 holiday calendar and modified following convention. Use QuantLib (ql.Schedule, ql.Actual360) for production.
  • Negative forward rates: Cubic spline interpolation can produce f(t) < 0 between pillars when the curve is steeply inverted (e.g., the 2022–2023 EUR inversion). Check curve.inst_forward(t) > −0.01 for all t and switch to monotone convex interpolation (Hagan & West 2006) if violations appear.
  • Tenor basis ignored: EURIBOR 3M and 6M carry different credit and liquidity premia. If a product references 6M EURIBOR, use a separately bootstrapped 6M curve — not the 3M curve with a flat basis assumption.
  • CSA assumption: OIS discounting assumes a fully collateralised (CSA) trade with daily margining. Uncollateralised trades require CVA adjustments — the OIS discount curve alone is insufficient.
  • OIS swap approximation: The float leg identity P(0,T₀) − P(0,T_N) is exact only when OIS compounding periods align with swap payment dates. Intraperiod compounding effects are ignored here (typically <0.1bp for annual payment swaps).
  • Nelson-Siegel not arbitrage-free: NS does not satisfy the HJM drift condition in general (Björk & Christensen 1999). It is a fitting tool, not a dynamic model — do not use it for Monte Carlo pricing of rate derivatives. Use Hull-White or LMM for that purpose.
  • ECB API rate limits: The ECB Data Portal imposes throttling for bulk historical requests. Cache responses locally; do not poll more than once per business day in production pipelines.

10. Interview Angle

Yield curve bootstrapping appears at all seniority levels in rates interviews. Junior candidates must understand the basic mechanics. Senior candidates must derive the multi-curve equations and discuss interpolation choices. Researcher-level candidates must connect the bootstrapped curve to dynamic models and cross-currency frameworks.

L1 — Junior

  • What is a discount factor P(0,T) and how is it related to a zero-coupon rate?
  • What is bootstrapping and why is it preferred over fitting an analytic model directly to deposit rates?
  • An OIS swap exchanges fixed vs daily-compounded overnight rates. How do you extract a discount factor from a par OIS rate?
  • What is DV01 and what does it represent economically for a fixed-income position?
  • Why do we interpolate on log-discount factors rather than discount factors directly?

L2 — Senior

  • Derive the bootstrapping equation for an OIS swap: P(0,T_N) = [1 − K·Σδᵢ·P(0,Tᵢ)] / (1 + K·δ_N).
  • Explain the multi-curve framework. Why do OIS and EURIBOR 3M discount factors differ post-2008, and what caused the basis to emerge?
  • How does the choice of interpolation scheme (log-DF cubic spline vs monotone convex vs linear zero rates) affect forward rates? Which is numerically most stable?
  • Describe the Jacobian bump-reval approach to DV01. What is the computational cost relative to analytic sensitivities? When would you use each?
  • EURIBOR is being reformed (EURIBOR Reform, BMR compliance). How does this affect curve construction if EURIBOR is replaced by a compounded €STR term rate?
  • What is a basis swap? How do you use EURIBOR 3M vs 6M basis swap quotes to build a consistent dual-tenor curve?

L3 — Researcher

  • The OIS-EURIBOR basis is non-zero. Derive the pricing formula for a EUR basis swap (pay EURIBOR 3M, receive EURIBOR 6M + spread) under the multi-curve framework.
  • Nelson-Siegel is not arbitrage-free in general (Björk & Christensen 1999). Under what conditions does it admit a consistent HJM framework? What does this imply for its use in derivative pricing?
  • The cubic spline interpolation on ln P can produce negative instantaneous forward rates between pillars even when all instrument rates are positive. How do you detect and correct this? Describe the monotone convex interpolation (Hagan & West 2006) approach.
  • Describe cross-currency basis curves (e.g., EUR vs USD OIS, linked via EURUSD FX swap). How do you bootstrap a EUR curve consistent with USD OIS and FX forward market data?