Skip to content

Yield Curve Calibration from Rates

This tutorial calibrates two short-rate models, the VasicekCurve and the CIRCurve, to a panel of historical US Treasury yields by maximum likelihood. Both treat the short rate as a latent state and the yields as noisy observations of it, but they need different filters: Vasicek is linear-Gaussian and uses the exact KalmanFilter, while CIR has a state-dependent variance and uses the UnscentedKalmanFilter.

For the mechanics of the filters themselves, see the Kalman Filter theory page.

The idea

A short-rate model never observes its state directly: what we observe at each date is a cross section of yields. Treating the short rate as a latent state and the yields as noisy observations of it turns calibration into a state-space estimation problem.

At each date every yield is affine in the short rate, so the observation is linear in both models. The filter computes the Gaussian log-likelihood of the observed panel, and the calibrator maximises it over \((\kappa, \theta, \sigma, h)\), where \(h\) is the observation noise standard deviation.

Fetching the data

FederalReserve.yield_curves returns the daily Treasury par-yield panel, indexed by date with one column per tenor and rates as decimals. The cached_df helper stores the result as a parquet file so repeated runs do not hit the network.

The calibration assumes a uniform time step, while the raw data is sampled on business days. Resampling to weekly Wednesdays with the average yield over each week gives an evenly spaced panel.

Vasicek: the exact Kalman filter

The Vasicek short rate is an Ornstein-Uhlenbeck process. Over a uniform time step its dynamics reduce to a Gaussian AR(1) with a constant innovation variance, and each yield is affine in the short rate. The calibrate_historical_rates method documents these equations.

Because the model is fully linear-Gaussian, the exact Kalman filter gives the exact log-likelihood. One full Kalman pass over the panel is performed per optimiser iteration.

CIR: why the unscented filter

The CIR short rate is a square-root diffusion, \(dr_t = \kappa(\theta - r_t)\,dt + \sigma\sqrt{r_t}\,dW_t\). Its conditional mean is still linear in the previous state, but its conditional variance depends on the state:

\[\begin{equation} \text{Var}[r_t \mid r_{t-1}] = r_{t-1}\,\frac{\sigma^2}{\kappa}\left(\phi - \phi^2\right) + \theta\,\frac{\sigma^2}{2\kappa}\left(1 - \phi\right)^2, \quad \phi = e^{-\kappa \Delta t}. \end{equation}\]

The exact Kalman filter assumes a constant process-noise covariance, so it cannot represent this heteroskedasticity. The unscented Kalman filter only needs the conditional mean and covariance of the transition, which it propagates through sigma points, so the state-dependent variance drops straight in. The [CIRStateSpaceModel][quantflow.rates.cir.CIRStateSpaceModel] supplies those moments and the affine observation, and calibrate_historical_rates runs the unscented filter inside the same maximum-likelihood loop.

Calibrating

For both models, calibrate_historical_rates_dataframe parses the tenor columns into times to maturity, infers the time step from the index, converts the par yields to continuously compounded rates (here frequency=2 for semiannual compounding), and runs the maximum-likelihood fit.

The fitted parameters and the final filtered short rate are returned on each calibrated curve:

Vasicek (Kalman filter)
{
  "curve_type": "vasicek_curve",
  "rate": "0.0403982712",
  "kappa": "0.2634535836",
  "theta": "0.0718450873",
  "sigma": "0.0613356808"
}
CIR (unscented Kalman filter)
{
  "curve_type": "cir_curve",
  "rate": "0.0370463245",
  "kappa": "0.0731213397",
  "theta": "0.0686465956",
  "sigma": "0.1001951199"
}

Model versus observed through time

The fit is a time-series fit, so the right check is whether each model tracks the history of every tenor. Both calibrators expose a filtered_short_rate path (Vasicek, CIR), from which each tenor's model-implied yield is reconstructed through the affine yield relation and plotted against its observed history.

Both single-factor models track the short and intermediate tenors (1Y, 2Y) closely and are smoother than the data at the long end (5Y, 10Y): one mean-reverting factor cannot capture the independent variation of the long end. The two fits stay close in this rate regime, the difference being the CIR volatility that scales with the level of rates.

Observed vs Vasicek and CIR model yields

Code

import asyncio
from datetime import timedelta
from decimal import Decimal

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

from docs.examples._utils import assets_path, cached_df
from quantflow.data.fed import FederalReserve
from quantflow.rates.calibration import tenor_to_years
from quantflow.rates.cir import CIRCurve
from quantflow.rates.vasicek import VasicekCurve


@cached_df(ttl=timedelta(days=1))
def fed_yield_curves() -> pd.DataFrame:
    async def fetch() -> pd.DataFrame:
        async with FederalReserve() as fed:
            return await fed.yield_curves()

    return asyncio.run(fetch())


# daily par-yield panel from the Federal Reserve (cached for a day)
df = fed_yield_curves()
# weekly panel (uniform 7-day step) using the average yield over each week
weekly = df.resample("W-WED").mean().dropna()
ttm = np.array([tenor_to_years(c) for c in weekly.columns])

# Vasicek: linear-Gaussian, fitted with the exact Kalman filter
vasicek_cal = VasicekCurve().calibrator()
vasicek = vasicek_cal.calibrate_historical_rates_dataframe(weekly, frequency=2)
print("Vasicek (Kalman filter)")
print(vasicek.model_dump_json(indent=2, exclude={"ref_date"}))

# CIR: state-dependent variance, fitted with the unscented Kalman filter
cir_cal = CIRCurve().calibrator()
cir = cir_cal.calibrate_historical_rates_dataframe(weekly, frequency=2)
print("CIR (unscented Kalman filter)")
print(cir.model_dump_json(indent=2, exclude={"ref_date"}))

# model-implied semi-annual rates at each date from the filtered short rate paths
va_short = vasicek_cal.filtered_short_rate
cir_short = cir_cal.filtered_short_rate
va_model = np.zeros((len(va_short), len(ttm)))
cir_model = np.zeros((len(cir_short), len(ttm)))
for t in range(len(va_short)):
    vasicek.rate = Decimal(str(float(va_short[t])))
    va_model[t] = np.asarray(vasicek.rates(ttm), dtype=float)
    cir.rate = Decimal(str(float(cir_short[t])))
    cir_model[t] = np.asarray(cir.rates(ttm), dtype=float)

# observed (par -> continuous) and both model yields per tenor, over time
tenors = ["1Y", "2Y", "5Y", "10Y"]
colours = dict(observed="#636efa", Vasicek="#ef553b", CIR="#00cc96")
fig = make_subplots(rows=2, cols=2, subplot_titles=tenors)
for k, tenor in enumerate(tenors):
    i = weekly.columns.get_loc(tenor)
    row, col = k // 2 + 1, k % 2 + 1
    series = {
        "observed": 2.0 * np.log1p(weekly[tenor].to_numpy() / 2.0) * 100,
        "Vasicek": va_model[:, i] * 100,
        "CIR": cir_model[:, i] * 100,
    }
    for name, values in series.items():
        fig.add_trace(
            go.Scatter(
                x=weekly.index,
                y=values,
                name=name,
                legendgroup=name,
                showlegend=k == 0,
                line=dict(color=colours[name]),
            ),
            row=row,
            col=col,
        )
fig.update_layout(title="Observed vs Vasicek and CIR model yields")
fig.update_yaxes(title_text="yield (%)")
fig.write_image(assets_path("rates_kalman.png"), width=1600, height=800)