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 for pillar 's parameter and for rung 's quote. The solve defines implicitly through , every rung worth nothing. Differentiating that identity gives
A trade's sensitivity to the quotes is its sensitivity to the pillars times . 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 depends only on quotes : 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.
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, "es)?;
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(())
}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 |
|---|---|---|---|
| , 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 | per hazard or density | CalibratedSurvival::provenance |
calibration.CalibratedSurvival.jacobian |
| inflation | 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.