Esc

    The API is under testing and may change before release.

    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.

    Rust
    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(())
    }
    Python
    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 xx, and every rung's value into one vector r(x)r(x). The joint solve finds

    r(x)=(P1(q1;x),…,Pn(qn;x))=0r(x) = \left(P_1(q_1; x), \dots, P_n(q_n; x)\right) = 0

    by damped Newton steps x←x−J−1rx \leftarrow x - J^{-1} r, with J=∂r/∂xJ = \partial r / \partial x 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)
    r(x)=0r(x) = 0 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
    max⁡∣r∣\max \lvert r \rvert 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 a ValueError.
    • Filing: the solve doesn't build a market. File each curve under its roles with with_discount and with_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.