Bootstrapping
This page lists what you need to solve a curve from par quotes yourself: the rungs, the solve, its
report, and the Tolerance fields that control how hard it tries. It's the layer beneath the
one-call market constructor, for when you want to see or change the solve.
Quick example
This turns twelve SOFR swap quotes into rungs, solves them, checks that the curve is the one the recipe constructor builds from the same sheet, and shows what a looser tolerance saves. 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, USD_SOFR_CLOSE};
use helixrs::{
CollateralSpec, FixingStore, FxRates, Interpolation, MarketContext, Period, QuoteSheet,
Tolerance, YieldCurve, calibrate, par_rungs,
};
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 close = date(SOFR_CLOSE_DATE);
let ten_years = date((2036, 10, 1));
let usd = CollateralSpec::Currency("USD".parse()?);
let quotes: Vec<(&str, f64)> = USD_SOFR_CLOSE.iter().map(|(_, tenor, quote)| (*tenor, *quote)).collect();
// The par rungs of a swap ladder: the template's swap from spot to each tenor,
// struck at its quote and noded at its final payment.
let tenors = quotes.iter().map(|(t, _)| t.parse::<Period>()).collect::<Result<Vec<_>, _>>()?;
let rates: Vec<f64> = quotes.iter().map(|(_, q)| *q).collect();
let rungs = par_rungs("USD-SOFR-OIS", close, &tenors, &rates, 1.0)?;
println!("rungs {}", rungs.len());
println!("first pillar {}", rungs[0].pillar);
println!("last pillar {}", rungs[rungs.len() - 1].pillar);
// The solve: one pillar per rung, each to the default tolerance.
let (fixings, fx) = (FixingStore::new(), FxRates::default());
let solved = calibrate(close, &rungs, &fixings, &fx, Tolerance::default(), Interpolation::LogLinear)?;
println!("parameters {}", solved.curve_value().parameter_count());
println!("sweeps {}", solved.sweeps);
// Filed as both the USD discount curve and the SOFR projection, the curve is
// the one the recipe constructor builds from the same sheet.
let market = MarketContext::as_of(close)
.with_discount(usd.clone(), solved.clone())
.with_projection("USD-SOFR", solved.clone());
let sheet = QuoteSheet::from_rows(USD_SOFR_CLOSE.iter().copied())?;
let recipe = MarketContext::as_of(close).with_recipe_quotes(&usd, &sheet)?;
let read = |m: &MarketContext| -> Outcome<f64> {
let (curve, _) = m.curve(&"discount(USD)".parse()?).expect("the USD curve");
Ok(curve.view::<f64>().discount(ten_years)?)
};
let factor = read(&market)?;
println!("10Y discount, solved {factor:.12}");
println!("10Y discount, recipe {:.12}", read(&recipe)?);
// The defaults, then a looser residual tolerance, which stops each pillar a step
// sooner. Inside the tolerance each pillar takes one polishing step, so the
// residuals still end below 1e-12 and the curve does not move.
let default = Tolerance::default();
println!(
"defaults residual {:.0e}, step {:.0e}, iterations {}, backtracks {}, sweeps {}",
default.residual, default.step, default.max_iterations, default.max_backtracks, default.max_sweeps
);
let tolerance = Tolerance { residual: 1e-6, ..Tolerance::default() };
let loose = calibrate(close, &rungs, &fixings, &fx, tolerance, Interpolation::LogLinear)?;
let steps: usize = solved.solves.iter().map(|s| s.iterations).sum();
let loose_steps: usize = loose.solves.iter().map(|s| s.iterations).sum();
println!("steps {steps} at 1e-12, {loose_steps} at 1e-6");
let loose_largest = loose.solves.iter().map(|s| s.residual.abs()).fold(0.0_f64, f64::max);
println!("loose residuals below 1e-12: {}", if loose_largest < 1e-12 { "yes" } else { "no" });
println!("10Y discount, loose {:.12}", loose.curve_value().view::<f64>().discount(ten_years)?);
Ok(())
}import datetime as dt
import helixio as hlx
from desk_sheets import SOFR_CLOSE_DATE, USD_SOFR_CLOSE
calibration = hlx.calibration
TEN_YEARS = dt.date(2036, 10, 1)
# The par rungs of a swap ladder: the template's swap from spot to each tenor,
# struck at its quote and noded at its final payment.
rungs = calibration.par_rungs("USD-SOFR-OIS", SOFR_CLOSE_DATE, USD_SOFR_CLOSE["USD-SOFR-OIS"])
print(f"rungs {len(rungs)}")
print(f"first pillar {rungs[0].pillar()}")
print(f"last pillar {rungs[-1].pillar()}")
# The solve: one pillar per rung, each to the default tolerance.
solved = calibration.curve(SOFR_CLOSE_DATE, rungs)
print(f"parameters {solved.curve().parameter_count()}")
print(f"sweeps {solved.sweeps()}")
# Filed as both the USD discount curve and the SOFR projection, the curve is
# the one the recipe constructor builds from the same sheet.
market = (
hlx.MarketContext(SOFR_CLOSE_DATE)
.with_discount("USD", solved.curve())
.with_projection("USD-SOFR", solved.curve())
)
recipe = hlx.MarketContext(SOFR_CLOSE_DATE).with_recipe_quotes("USD", USD_SOFR_CLOSE)
factor = market.curve("discount(USD)").discount(TEN_YEARS)
print(f"10Y discount, solved {factor:.12f}")
print(f"10Y discount, recipe {recipe.curve('discount(USD)').discount(TEN_YEARS):.12f}")
# The defaults, then a looser residual tolerance, which stops each pillar a step
# sooner. Inside the tolerance each pillar takes one polishing step, so the
# residuals still end below 1e-12 and the curve does not move.
default = calibration.Tolerance()
print(
f"defaults residual {default.residual():.0e}, step {default.step():.0e}, "
f"iterations {default.max_iterations()}, backtracks {default.max_backtracks()}, "
f"sweeps {default.max_sweeps()}"
)
loose = calibration.curve(SOFR_CLOSE_DATE, rungs, tolerance=calibration.Tolerance(residual=1e-6))
steps = solved.solves().to_dict()["iterations"]
loose_steps = loose.solves().to_dict()["iterations"]
print(f"steps {int(sum(steps))} at 1e-12, {int(sum(loose_steps))} at 1e-6")
loose_largest = max(abs(r) for r in loose.solves().to_dict()["residual"])
print(f"loose residuals below 1e-12: {'yes' if loose_largest < 1e-12 else 'no'}")
print(f"10Y discount, loose {loose.curve().discount(TEN_YEARS):.12f}")rungs 12
first pillar 2026-11-09
last pillar 2056-10-10
parameters 12
sweeps 0
10Y discount, solved 0.618430226130
10Y discount, recipe 0.618430226130
defaults residual 1e-12, step 1e-14, iterations 50, backtracks 30, sweeps 50
steps 42 at 1e-12, 30 at 1e-6
loose residuals below 1e-12: yes
10Y discount, loose 0.618430226130
The looser tolerance takes fewer steps but leaves the curve where it was: inside the tolerance each pillar still takes one polishing step, so the residuals end below either way.
How it's priced
Order the rungs by pillar, , and write for the value of rung struck at its quote on curve . Under log-linear interpolation a rung maturing at reads the curve only up to , so each pillar is solved from its own rung, given the ones before:
Newton's method solves each equation. A pillar is solved when its rung's value, the residual, is below the tolerance, per unit notional. Why the solve can go one pillar at a time, and when it can't, is in how calibration works.
API
| Symbol | Meaning | Rust | Python |
|---|---|---|---|
| par rungs from a template's quotes | par_rungs |
calibration.par_rungs |
|
| the solved curve and its solve report | calibrate → Calibrated |
calibration.curve → Calibration |
|
| residual | each rung's value at its pillar's solution | Calibrated::solves |
Calibration.solves |
| tolerance | the residual, step and budget bounds | Tolerance |
calibration.Tolerance |
| filed | the curve under its roles | MarketContext::with_discount, with_projection |
MarketContext.with_discount, MarketContext.with_projection |
calibration.par_rungs(template, valuation, quotes, notional=1.0) makes one rung per quote: the
template's swap from spot to the tenor, struck at the quote and noded at its final payment.
calibration.curve takes:
| Argument | Type | Default | Meaning |
|---|---|---|---|
reference |
date |
required | the curve's reference date |
rungs |
list[Rung] or list[tuple[date, Instrument, float]] |
required | the ladder, one rung per pillar |
market |
MarketContext or None |
None |
where fixings and FX are read, when a rung needs them |
discount |
DiscountCurve or None |
None |
a separate discount curve; without one the curve discounts itself |
interpolation |
Interpolation or str |
"loglinear" |
how the curve reads between pillars |
tension |
float or None |
None |
the tension, for a tension spline |
end_slopes |
tuple[float, float] or None |
None |
the end slopes, for a clamped spline |
tolerance |
Tolerance or None |
the defaults | how hard the solve tries |
Tolerance has five fields, each a keyword argument in Python and a public field in Rust:
| Argument | Type | Default | Meaning |
|---|---|---|---|
residual |
float |
1e-12 |
the rung value below which a pillar is solved, per unit notional |
step |
float |
1e-14 |
the Newton step below which a pillar is taken as converged |
max_iterations |
int |
50 |
Newton steps allowed per pillar |
max_backtracks |
int |
30 |
halvings allowed of one Newton step that made things worse |
max_sweeps |
int |
50 |
full passes over the ladder allowed after the first |
The result, a Calibration, has curve(), solves() (pillar, iterations, residual per
rung), sweeps(), rungs(), jacobian() and interpolation().
Defaults and limits
- Tolerance: the defaults are printed by the example. The residual is in the instrument's own units per unit notional, so it doesn't depend on the notional.
- Pillars: must rise strictly. A rung whose pillar is on or before another's is refused.
- Sweeps: after the first pass the solve sweeps the ladder again until no pillar moves; a swaps-only ladder under log-linear needs none.
- No silent failure: a solve never reports success on a value that isn't finite; it raises a
CalibrationError. - Pinning a node: a rung needn't be a traded instrument. See pin a curve node past the last quote.
See also
- Source:
crates/helixrs/src/calibration.rs,crates/helixrs/src/products.rs - Related pages:
- Quote sheets and recipes, for the one-call route.
- Interpolation, for what log-linear does between pillars.
- The Jacobian, for how the solved pillars move with the quotes.
- Joint calibration, for ladders with no order to solve in.
From the 0.0 line at revision bf7f2e2. Every printed number is that revision's own output.