Esc

    The API is under testing and may change before release.

    The Jacobian

    You'll see how a solved curve knows how far each of its pillars moves when one quote moves, why that table has the shape it has, and why it's what turns risk to a curve into risk to the quotes you hedge with.

    Risk in the quotes you trade

    A desk hedges in the instruments it quotes, not in discount factors. A trade's sensitivity to each pillar of a curve is cheap to compute, but nobody can trade a 2031 discount factor. The useful number is how much the trade moves when the five-year swap rate moves one basis point, everything else held. Getting from one to the other takes the calibration's Jacobian: the derivative of every pillar with respect to every quote.

    Where it comes from

    Write zi=ln⁡D(Ti)z_i = \ln D(T_i) for pillar ii's parameter and qjq_j for rung jj's quote. The solve defines z(q)z(q) implicitly through P(q,z(q))=0P(q, z(q)) = 0, every rung worth nothing. Differentiating that identity gives

    Jij=∂zi∂qj,J=−(∂P∂z)−1∂P∂q.J_{ij} = \frac{\partial z_i}{\partial q_j}, \qquad J = -\left(\frac{\partial P}{\partial z}\right)^{-1} \frac{\partial P}{\partial q} .

    A trade's sensitivity to the quotes is its sensitivity to the pillars times JJ. You rarely need to do that yourself: a book's par-basis risk report already applies it.

    Why it's lower triangular

    In a sequential bootstrap, pillar ii depends only on quotes 1,…,i1, \dots, i: a five-year swap can't read the curve past five years. So every entry above the diagonal is zero. And a higher par rate on a swap means its fixed leg is worth more, which a lower discount factor at its pillar offsets, so every diagonal entry is negative. A joint solve, where ladders read each other, fills in more of the matrix.

    Seeing it

    This solves a SOFR curve from twelve quotes, reads its Jacobian, counts the entries that break the shape above (there are none), and then checks one entry the slow way: bump the five-year quote by a hundredth of a basis point, solve again, and measure how far the five-year pillar moved. The two agree to the bump's first order. The example reads its quotes from the desk sheets.

    Rust
    use chrono::NaiveDate;
    use helixio_docs_sheets::{SOFR_CLOSE_DATE, USD_SOFR_CLOSE};
    use helixrs::{
        Calibrated, FixingStore, FxRates, Interpolation, Period, 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 solve(close: NaiveDate, quotes: &[(&str, f64)]) -> Outcome<Calibrated> {
        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)?;
        Ok(calibrate(close, &rungs, &FixingStore::new(), &FxRates::default(), Tolerance::default(), Interpolation::LogLinear)?)
    }
    
    fn main() -> Outcome<()> {
        let close = date(SOFR_CLOSE_DATE);
        let quotes: Vec<(&str, f64)> = USD_SOFR_CLOSE.iter().map(|(_, tenor, quote)| (*tenor, *quote)).collect();
    
        // One row per pillar, one column per quote.
        let solved = solve(close, &quotes)?;
        let ladder = solved.provenance.ladder().expect("a log-linear solve carries its ladder");
        let jacobian = ladder.jacobian();
        let size = jacobian.len();
        println!("rows {size}, columns {}", jacobian[0].len());
        let above = (0..size).map(|i| (i + 1..size).filter(|&j| jacobian[i][j] != 0.0).count()).sum::<usize>();
        let negative = (0..size).filter(|&i| jacobian[i][i] < 0.0).count();
        println!("non-zero entries above the diagonal  {above}");
        println!("negative diagonal entries            {negative} of {size}");
    
        // Bump the five-year quote by a hundredth of a basis point and solve again: the
        // five-year pillar's log discount factor moves by its diagonal entry times the
        // bump, to first order.
        let five_year = quotes.iter().position(|(t, _)| *t == "5Y").expect("quoted");
        let pillar = ladder.entries()[five_year].maturity;
        let step = 1e-6;
        let mut bumped_quotes = quotes.clone();
        bumped_quotes[five_year].1 += step;
        let bumped = solve(close, &bumped_quotes)?;
        let log_discount = |c: &Calibrated| -> Outcome<f64> { Ok(c.curve_value().view::<f64>().discount(pillar)?.ln()) };
        let moved = (log_discount(&bumped)? - log_discount(&solved)?) / step;
        let analytic = jacobian[five_year][five_year];
        println!("d ln D(5Y) / d q(5Y)  {analytic:.6}");
        println!("re-solved             {moved:.6}");
    
        Ok(())
    }
    Python
    import math
    
    import helixio as hlx
    from desk_sheets import SOFR_CLOSE_DATE, USD_SOFR_CLOSE
    
    calibration = hlx.calibration
    QUOTES = dict(USD_SOFR_CLOSE["USD-SOFR-OIS"])
    TENORS = list(QUOTES)
    
    def solve(quotes):
        return calibration.curve(SOFR_CLOSE_DATE, calibration.par_rungs("USD-SOFR-OIS", SOFR_CLOSE_DATE, quotes))
    
    # One row per pillar, one column per quote.
    solved = solve(QUOTES)
    jacobian = solved.jacobian()
    size = len(jacobian)
    print(f"rows {size}, columns {len(jacobian[0])}")
    above = sum(1 for i in range(size) for j in range(i + 1, size) if jacobian[i][j] != 0.0)
    negative = sum(1 for i in range(size) if jacobian[i][i] < 0.0)
    print(f"non-zero entries above the diagonal  {above}")
    print(f"negative diagonal entries            {negative} of {size}")
    
    # Bump the five-year quote by a hundredth of a basis point and solve again: the
    # five-year pillar's log discount factor moves by its diagonal entry times the
    # bump, to first order.
    five_year = TENORS.index("5Y")
    pillar = solved.rungs().to_dict()["pillar"][five_year]
    step = 1e-6
    bumped = solve({**QUOTES, "5Y": QUOTES["5Y"] + step})
    moved = (math.log(bumped.curve().discount(pillar)) - math.log(solved.curve().discount(pillar))) / step
    analytic = jacobian[five_year][five_year]
    print(f"d ln D(5Y) / d q(5Y)  {analytic:.6f}")
    print(f"re-solved             {moved:.6f}")
    rows 12, columns 12
    non-zero entries above the diagonal  0
    negative diagonal entries            12 of 12
    d ln D(5Y) / d q(5Y)  -5.367704
    re-solved             -5.367713

    From formula to API

    Symbol Meaning Rust Python
    JJ ∂z/∂q\partial z / \partial q, row a pillar, column a quote jacobian() on the ladder Calibrated::provenance carries Calibration.jacobian
    rows each pillar and its quote CurveProvenance::entries Calibration.rungs
    credit JJ per hazard or density CalibratedSurvival::provenance calibration.CalibratedSurvival.jacobian
    inflation JJ per index level CalibratedInflation::provenance calibration.CalibratedInflation.jacobian

    Each solve's arguments are on bootstrapping and the other calibration reference pages.

    See also

    • Source: crates/helixrs/src/calibration.rs
    • Related pages:
      • Bootstrapping, for the solve the Jacobian differentiates.
      • Provenance, for a curve's ladder once it is filed.
      • How calibration works, for the solves that produce it.

    From the 0.0 line at revision bf7f2e2. Every printed number is that revision's own output.