Joint calibration
This page lists what you need to solve several curves at once, for ladders whose rungs read each other: the curves and their roles, the solve, and what it reports.
Quick example
This solves two pairs of euro curves. The first pair has an order, €STR then EURIBOR 6M, so the joint solve has nothing left to do. The second pair quotes €STR only as a basis to EURIBOR 3M, so every rung reads both curves and the solve iterates. The example states its own quotes and takes its date from the desk sheets; load them once and it runs as shown.
use chrono::NaiveDate;
use helixio_docs_sheets::SOFR_CLOSE_DATE;
use helixrs::{
CollateralSpec, Curve, CurveId, FixingStore, FxRates, Interpolation, JointCurve,
MarketContext, Period, Rung, Tolerance, YieldCurve, calibrate_jointly, par_rungs, template,
tenor_basis_rung,
};
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 ladder(code: &str, close: NaiveDate, quotes: &[(&str, f64)]) -> Outcome<Vec<Rung>> {
let tenors = quotes.iter().map(|(t, _)| t.parse::<Period>()).collect::<Result<Vec<_>, _>>()?;
let rates: Vec<f64> = quotes.iter().map(|(_, q)| *q).collect();
Ok(par_rungs(code, close, &tenors, &rates, 1.0)?)
}
fn curve(roles: &[&str], rungs: &[Rung]) -> Outcome<JointCurve> {
Ok(JointCurve {
roles: roles.iter().map(|r| r.parse::<CurveId>()).collect::<Result<Vec<_>, _>>()?,
rungs: rungs.to_vec(),
})
}
fn main() -> Outcome<()> {
let close = date(SOFR_CLOSE_DATE);
let (fixings, fx) = (FixingStore::new(), FxRates::default());
let solve = |curves: &[JointCurve]| {
calibrate_jointly(close, curves, &fixings, &fx, Tolerance::default(), Interpolation::LogLinear)
};
// €STR swaps, then EURIBOR 6M swaps discounted on €STR: the second ladder
// reads the first, never the reverse, so the sequential answer is the answer.
let estr = ladder("EUR-ESTR-OIS", close, &[("1Y", 0.0195), ("2Y", 0.0205), ("5Y", 0.0230), ("10Y", 0.0262)])?;
let six_month = ladder("EUR-EURIBOR-6M-IRS", close, &[("2Y", 0.0228), ("5Y", 0.0252), ("10Y", 0.0281)])?;
let ordered = solve(&[
curve(&["discount(EUR)", "projection(EUR-ESTR)"], &estr)?,
curve(&["projection(EUR-EURIBOR-6M)"], &six_month)?,
])?;
println!("iterations {}", ordered.iterations);
// €STR quoted only as a basis to EURIBOR 3M, and EURIBOR 3M through swaps:
// every rung reads both curves, so the solve iterates.
let basis_template = template("EUR-ESTR-EURIBOR-BASIS")?;
let mut basis = Vec::new();
for (tenor, spread) in [("2Y", 0.0018), ("5Y", 0.0020), ("10Y", 0.0021)] {
basis.push(tenor_basis_rung("EUR-ESTR-EURIBOR-BASIS", &basis_template, close, tenor.parse()?, spread, 1.0)?);
}
let three_month = ladder("EUR-EURIBOR-3M-IRS", close, &[("2Y", 0.0222), ("5Y", 0.0246), ("10Y", 0.0275)])?;
let coupled = solve(&[
curve(&["discount(EUR)", "projection(EUR-ESTR)"], &basis)?,
curve(&["projection(EUR-EURIBOR-3M)"], &three_month)?,
])?;
for (role, solved) in &coupled.curves {
println!("{:<27} {} pillars", role.to_string(), solved.parameters().len());
}
println!("iterations {}", coupled.iterations);
// Filed under their roles, the two curves price every rung of both ladders
// back to nothing.
let estr_curve = Curve::Piecewise(coupled.curves[0].1.clone());
let euribor = Curve::Piecewise(coupled.curves[1].1.clone());
let market = MarketContext::as_of(close)
.with_discount(CollateralSpec::Currency("EUR".parse()?), estr_curve.clone())
.with_projection("EUR-ESTR", estr_curve.clone())
.with_projection("EUR-EURIBOR-3M", euribor);
let mut worst: f64 = 0.0;
for rung in basis.iter().chain(&three_month) {
worst = worst.max(rung.instrument.npv(&market)?.abs());
}
println!("largest rung value, both ladders {worst:.2}");
let factor = estr_curve.view::<f64>().discount(date((2036, 10, 1)))?;
println!("10Y EUR discount {factor:.12}");
Ok(())
}import datetime as dt
import helixio as hlx
from desk_sheets import SOFR_CLOSE_DATE
calibration = hlx.calibration
helpers = calibration.helpers
# €STR swaps, then EURIBOR 6M swaps discounted on €STR: the second ladder
# reads the first, never the reverse, so the sequential answer is the answer.
estr = calibration.par_rungs("EUR-ESTR-OIS", SOFR_CLOSE_DATE, {"1Y": 0.0195, "2Y": 0.0205, "5Y": 0.0230, "10Y": 0.0262})
six_month = calibration.par_rungs("EUR-EURIBOR-6M-IRS", SOFR_CLOSE_DATE, {"2Y": 0.0228, "5Y": 0.0252, "10Y": 0.0281})
ordered = calibration.curves(
SOFR_CLOSE_DATE,
[(["discount(EUR)", "projection(EUR-ESTR)"], estr), ("projection(EUR-EURIBOR-6M)", six_month)],
)
print(f"iterations {ordered.iterations()}")
# €STR quoted only as a basis to EURIBOR 3M, and EURIBOR 3M through swaps:
# every rung reads both curves, so the solve iterates.
basis = [
helpers.tenor_basis("EUR-ESTR-EURIBOR-BASIS", SOFR_CLOSE_DATE, tenor, spread)
for tenor, spread in [("2Y", 0.0018), ("5Y", 0.0020), ("10Y", 0.0021)]
]
three_month = calibration.par_rungs("EUR-EURIBOR-3M-IRS", SOFR_CLOSE_DATE, {"2Y": 0.0222, "5Y": 0.0246, "10Y": 0.0275})
coupled = calibration.curves(
SOFR_CLOSE_DATE,
[(["discount(EUR)", "projection(EUR-ESTR)"], basis), ("projection(EUR-EURIBOR-3M)", three_month)],
)
for role, curve in coupled.curves():
print(f"{role:<27} {curve.parameter_count()} pillars")
print(f"iterations {coupled.iterations()}")
# Filed under their roles, the two curves price every rung of both ladders
# back to nothing.
estr_curve = coupled.curve("discount(EUR)")
market = (
hlx.MarketContext(SOFR_CLOSE_DATE)
.with_discount("EUR", estr_curve)
.with_projection("EUR-ESTR", estr_curve)
.with_projection("EUR-EURIBOR-3M", coupled.curve("projection(EUR-EURIBOR-3M)"))
)
worst = max(abs(rung.instrument().npv(market)) for rung in basis + three_month)
print(f"largest rung value, both ladders {worst:.2f}")
factor = estr_curve.discount(dt.date(2036, 10, 1))
print(f"10Y EUR discount {factor:.12f}")iterations 0
discount(EUR) 3 pillars
projection(EUR-EURIBOR-3M) 3 pillars
iterations 4
largest rung value, both ladders 0.00
10Y EUR discount 0.776973683474
iterations counts Newton steps beyond the sequential starting point: none for the ordered pair,
four for the coupled one.
How it's priced
Stack the log discount factors of every curve into one vector , and every rung's value into one vector . The joint solve finds
by damped Newton steps , with from one pricing pass over a market holding every trial curve. A step is halved until the residual falls. Each curve starts from the sequential solve where that succeeds. When to reach for this solve rather than a bootstrap is in how calibration works.
API
| Symbol | Meaning | Rust | Python |
|---|---|---|---|
| one curve's ladder | rungs and the roles the curve is filed under | JointCurve { roles, rungs } |
(roles, rungs) |
| the joint solve | calibrate_jointly → JointCalibration |
calibration.curves → JointCalibration |
|
| basis rung | a tenor basis swap at its spread | tenor_basis_rung |
calibration.helpers.tenor_basis |
| iterations | Newton steps beyond the starting point | JointCalibration::iterations |
JointCalibration.iterations |
| the largest residual left | JointCalibration::residual |
JointCalibration.residual |
calibration.curves takes:
| Argument | Type | Default | Meaning |
|---|---|---|---|
reference |
date |
required | the curves' reference date |
curves |
list[tuple[str or list[str], list[Rung]]] |
required | one (roles, rungs) pair per curve; a role is spelled as a risk row spells it, such as "discount(EUR)" or "projection(EUR-EURIBOR-3M)" |
market |
MarketContext or None |
None |
where fixings and FX are read |
interpolation |
Interpolation or str |
"loglinear" |
how every solved 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; see bootstrapping |
The result, a JointCalibration, has curves() ((role, curve) pairs in the order given),
curve(role), iterations() and residual().
Defaults and limits
- Roles: a curve is filed under its first role and answers to all of them. Asking
curve(role)for a role no curve carries raises aValueError. - Filing: the solve doesn't build a market. File each curve under its roles with
with_discountandwith_projection, as the example does. - Ordered ladders: cost nothing extra; the joint solve returns the sequential answer with no iteration.
See also
- Source:
crates/helixrs/src/calibration.rs,crates/helixrs/src/products.rs - Related pages:
- Bootstrapping, for the sequential solve the joint one starts from.
- Curve helpers, for the rung kinds.
- Indices, for €STR and EURIBOR.
From the 0.0 line at revision bf7f2e2. Every printed number is that revision's own output.