Parametric fits
This page lists what you need to fit a smooth parametric curve to a universe of bonds: the forms the library offers, the fit's arguments and weights, how to read the natural parameters back, and how to read each bond's misfit as rich or cheap.
Quick example
This fits a Nelson-Siegel curve to eight bonds, reads back its natural parameters, prints the curve beside the formula, and lists each bond's misfit. The example reads its quotes from the desk sheets; load them once and it runs as shown.
use chrono::NaiveDate;
use helixio_docs_sheets::SOFR_CLOSE_DATE;
use helixrs::{
Curve, DayCountFraction, FitWeights, FittedForm, FixedRateBond, Frequency, YieldCurve,
fit_bond_curve,
};
type Outcome<T> = Result<T, Box<dyn std::error::Error>>;
fn date((year, month, day): (i32, u32, u32)) -> NaiveDate {
NaiveDate::from_ymd_opt(year, month, day).expect("a real calendar date")
}
fn main() -> Outcome<()> {
let reference = date(SOFR_CLOSE_DATE);
// Eight Treasury-style bullets, semiannual on ACT/ACT ICMA, at their clean
// prices: issue, maturity, coupon, price.
let universe = [
((2025, 11, 15), (2027, 11, 15), 0.0400, 99.80),
((2025, 8, 15), (2028, 8, 15), 0.0375, 99.10),
((2024, 11, 15), (2029, 11, 15), 0.0425, 99.95),
((2026, 2, 15), (2031, 2, 15), 0.0400, 98.70),
((2026, 5, 15), (2033, 5, 15), 0.0425, 99.00),
((2025, 11, 15), (2035, 11, 15), 0.0450, 99.60),
((2026, 8, 15), (2046, 8, 15), 0.0475, 98.40),
((2026, 5, 15), (2056, 5, 15), 0.0475, 96.90),
];
let mut quotes = Vec::new();
for (issue, maturity, coupon, price) in universe {
let bond = FixedRateBond::bullet(
"USD".parse()?,
date(issue),
date(maturity),
coupon,
Frequency::SemiAnnual,
DayCountFraction::ActActIcma,
100.0,
)?;
quotes.push((bond, price));
}
let fitted = fit_bond_curve(reference, "es, FittedForm::NelsonSiegel, FitWeights::InverseDuration)?;
let Curve::NelsonSiegel(form) = &fitted.curve else { unreachable!("a Nelson-Siegel fit") };
let (level, slope, hump, scale) = form.natural();
println!("parameters {}", fitted.curve.parameter_count());
println!("level {level:.8} slope {slope:.8} hump {hump:.8} scale {scale:.6}");
// The curve's ten-year zero rate is the Nelson-Siegel formula at those numbers.
let ten = date((2036, 10, 1));
let x = (ten - reference).num_days() as f64 / 365.0 / scale;
let loading = (1.0 - (-x).exp()) / x;
let formula = level + slope * loading + hump * (loading - (-x).exp());
let zero = fitted.curve.view::<f64>().zero_rate(ten)?;
println!("10Y zero {zero:.10}");
println!("formula {formula:.10}");
// How far each bond sits from the curve, as a yield in basis points.
for row in &fitted.rows {
println!(
"{} market {:.3} model {:.3} misfit {:+.2}bp",
row.maturity,
row.market_clean,
row.model_clean,
row.yield_misfit * 1e4
);
}
Ok(())
}import datetime as dt
import math
import helixio as hlx
from desk_sheets import SOFR_CLOSE_DATE
REFERENCE = SOFR_CLOSE_DATE
# Eight Treasury-style bullets, semiannual on ACT/ACT ICMA, at their clean
# prices: issue, maturity, coupon, price.
UNIVERSE = [
(dt.date(2025, 11, 15), dt.date(2027, 11, 15), 0.0400, 99.80),
(dt.date(2025, 8, 15), dt.date(2028, 8, 15), 0.0375, 99.10),
(dt.date(2024, 11, 15), dt.date(2029, 11, 15), 0.0425, 99.95),
(dt.date(2026, 2, 15), dt.date(2031, 2, 15), 0.0400, 98.70),
(dt.date(2026, 5, 15), dt.date(2033, 5, 15), 0.0425, 99.00),
(dt.date(2025, 11, 15), dt.date(2035, 11, 15), 0.0450, 99.60),
(dt.date(2026, 8, 15), dt.date(2046, 8, 15), 0.0475, 98.40),
(dt.date(2026, 5, 15), dt.date(2056, 5, 15), 0.0475, 96.90),
]
quotes = [
(hlx.products.fixed_rate_bond("USD", issue, maturity, coupon=coupon, frequency="6M", day_count="ACT/ACT.ICMA"), price)
for issue, maturity, coupon, price in UNIVERSE
]
curve, rows = hlx.curves.fit_bond_curve(REFERENCE, quotes, form="nelson_siegel")
level, slope, hump, scale = curve.natural()
print(f"parameters {curve.parameter_count()}")
print(f"level {level:.8f} slope {slope:.8f} hump {hump:.8f} scale {scale:.6f}")
# The curve's ten-year zero rate is the Nelson-Siegel formula at those numbers.
ten = dt.date(2036, 10, 1)
x = (ten - REFERENCE).days / 365.0 / scale
loading = (1.0 - math.exp(-x)) / x
formula = level + slope * loading + hump * (loading - math.exp(-x))
print(f"10Y zero {curve.zero_rate(ten):.10f}")
print(f"formula {formula:.10f}")
# How far each bond sits from the curve, as a yield in basis points.
for row in rows:
print(f"{row.maturity} market {row.market_clean:.3f} model {row.model_clean:.3f} misfit {row.yield_misfit * 1e4:+.2f}bp")parameters 4
level 0.05393351 slope -0.01248426 hump -0.01298477 scale 3.934506
10Y zero 0.0457279409
formula 0.0457279409
2027-11-15 market 99.800 model 99.778 misfit +1.99bp
2028-08-15 market 99.100 model 99.157 misfit -3.22bp
2029-11-15 market 99.950 model 99.938 misfit +0.41bp
2031-02-15 market 98.700 model 98.695 misfit +0.12bp
2033-05-15 market 99.000 model 98.931 misfit +1.21bp
2035-11-15 market 99.600 model 99.598 misfit +0.03bp
2046-08-15 market 98.400 model 98.654 misfit -2.03bp
2056-05-15 market 96.900 model 96.689 misfit +1.40bp
A positive misfit is a bond whose market price sits above the curve's model price: rich.
How it's priced
The Nelson-Siegel zero rate at maturity in years is
The loading falls from one at towards zero, so is the short rate and the long-run level; adds a hump at maturities near . Both loadings are at most , so far out
Why a government curve is fitted rather than bootstrapped is covered in how curves work.
API
| Symbol | Meaning | Rust | Python |
|---|---|---|---|
| — | a fit to bonds at clean prices | fit_bond_curve(reference, "es, FittedForm::NelsonSiegel, FitWeights::InverseDuration) |
curves.fit_bond_curve(reference, quotes, form="nelson_siegel") |
| the natural parameters | NelsonSiegel::natural on Curve::NelsonSiegel |
curves.DiscountCurve.natural |
|
| the fitted curve's zero rate | YieldCurve::zero_rate |
curves.DiscountCurve.zero_rate |
|
| misfit | each bond against the curve | BondFitRow fields |
curves.BondFitRow properties |
| — | a bond in the universe | FixedRateBond::bullet |
products.fixed_rate_bond |
curves.fit_bond_curve(...) returns the fitted curve and one row per bond:
| Argument | Type | Default | Meaning |
|---|---|---|---|
reference |
date |
required | the curve's reference date |
quotes |
list[tuple[FixedRateBond, float]] |
required | each bond with its clean price |
form |
str |
"nelson_siegel" |
"nelson_siegel" (or "ns"), "svensson" (or "nss"), or "exponential_splines" (or "splines"); in Rust, a FittedForm |
weights |
str |
"inverse_duration" |
"inverse_duration" (or "duration") or "equal"; in Rust, a FitWeights |
basis |
int |
3 |
the number of exponentials, read by the splines form only |
Defaults and limits
- The forms: Nelson-Siegel has four natural parameters (),
Svensson six (a second hump and decay), and exponential splines a basis of
basisdecaying exponentials.natural()reads them back in that order. - Weights: by default each bond's price error is weighted by the inverse of its duration, so the
fit matches yields rather than prices.
"equal"weights prices equally, and the answer changes. - A smoother, not a repricer: a handful of parameters can't reproduce every price, and the rows say how far each bond misses.
- Failure: the fit raises
CalibrationErrorif the universe can't determine the form or the solve doesn't converge.
See also
- Source:
crates/helixrs/src/curves/fit.rs,crates/helixrs/src/curves/parametric.rs - Related pages:
- Bootstrapping, for a curve that reprices every quote.
- Reading a curve, for the zero rate.
From the 0.0 line at revision bf7f2e2. Every printed number is that revision's own output.