Esc

    The API is under testing and may change before release.

    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.

    Rust
    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(())
    }
    Python
    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 10−1210^{-12} either way.

    How it's priced

    Order the rungs by pillar, T1<⋯<TnT_1 < \dots < T_n, and write Pj(qj;D)P_j(q_j; D) for the value of rung jj struck at its quote qjq_j on curve DD. Under log-linear interpolation a rung maturing at TjT_j reads the curve only up to TjT_j, so each pillar is solved from its own rung, given the ones before:

    Pj ⁣(qj; D(T1),…,D(Tj−1),D(Tj))=0.P_j\!\left(q_j;\ D(T_1), \dots, D(T_{j-1}), D(T_j)\right) = 0 .

    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
    qj↦Pjq_j \mapsto P_j par rungs from a template's quotes par_rungs calibration.par_rungs
    DD 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 DD 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.