Yield curves
Module: Shoals.Curves.
This module represents a yield curve as a set of pillar times and rates tagged with a curve kind, interpolates the rate at an arbitrary maturity by several methods, derives discount factors, bootstraps a zero curve from par yields in the single-curve case, and applies sensitivity shifts.
Types and curve kinds
Section titled “Types and curve kinds”type CurveKind = | Ois | Ibor | Sofr | Sonia | Estr | Custom { label: string }
type YieldCurve[n] = | YieldCurve { kind: CurveKind, times: tensor[n, f32], rates: tensor[n, f32] }A YieldCurve[n] carries its kind, a tensor of pillar times, and a tensor
of pillar rates, both of length n. The kind is one of the standard
overnight or term-rate families, or a Custom kind with a free-text label.
Constructors for each kind:
def ois() -> CurveKinddef ibor() -> CurveKinddef sofr() -> CurveKinddef sonia() -> CurveKinddef estr() -> CurveKinddef custom_curve(label: string) -> CurveKindBuilding a curve
Section titled “Building a curve”def yield_curve_from_pillars[n](times: tensor[n, f32], rates: tensor[n, f32]) -> YieldCurve[n]def yield_curve_tagged[n](kind: CurveKind, times: tensor[n, f32], rates: tensor[n, f32]) -> YieldCurve[n]def curve_kind[n](curve: YieldCurve[n]) -> CurveKindyield_curve_from_pillars builds an untagged curve (a Custom kind with
the label "untagged"). yield_curve_tagged builds a curve with an
explicit kind. curve_kind reads the kind back. For example:
curve = yield_curve_from_pillars( to_tensor([cast(1.0, f32), cast(2.0, f32), cast(3.0, f32)]), to_tensor([cast(0.03, f32), cast(0.04, f32), cast(0.045, f32)]))tagged = yield_curve_tagged(sofr(), to_tensor([cast(1.0, f32)]), to_tensor([cast(0.04, f32)]))kind = curve_kind(tagged) -- Sofrflat = rate_at(tagged, cast(7.0, f32)) -- 0.04: one pillar reads flatRates are continuously compounded zero rates as decimals (0.04 is 4%), and
times are years from the curve date.
Pillar times must be strictly increasing
Section titled “Pillar times must be strictly increasing”Both constructors reject pillar times that are not strictly increasing, with a
runtime fail naming the entry point, the first offending index, and both
times:
Shoals.Curves.yield_curve_from_pillars: pillar times must be strictly increasing: index 2 has time 2.0, which does not exceed time 3.0 at index 1rate_at uses Nautilus.Interpolation.linear_interp_sorted, which brackets
a query in traversal order. Without the ordering guard, reordering the example's
pairs can change the rate at 1.5 from 0.035 to 0.025, changing every
price discounted at that rate. The constructors reject duplicate times and
NaN times among two or more pillars. They do not sort the supplied pairs.
The same guard applies to bootstrap_zero_from_par and
curve_basis_from_pillars. It checks adjacent ordering, not finiteness:
a single-pillar curve has no pair to compare, so a lone NaN time is accepted
and the curve reads flat.
Reading opaque curves
Section titled “Reading opaque curves”YieldCurve and CurveBasis are opaque. Outside Shoals.Curves, use the
constructors above instead of record literals. Direct construction is rejected:
record construction of opaque type `YieldCurve` outside its defining module `Shoals.Curves`Record patterns are also unavailable outside the module. Use curve_kind,
rate_at, or the pillar readers:
def curve_pillars[n](curve: YieldCurve[n]) -> (tensor[n, f32], tensor[n, f32])def basis_pillars[n](basis: CurveBasis[n]) -> (tensor[n, f32], tensor[n, f32])Each returns the times and rates together. You may name YieldCurve[3] in a
signature or annotation; opacity restricts access to its representation, not
use of the type. copy accepts tensors, not curve values.
Rebuilding a curve from its pillars goes through the same constructor checks.
Sensitivity shifts preserve the existing times; bootstrap_multi_curve checks
the ordering of instrument tenors. These are runtime guards, not a proof of
pillar ordering by the type checker. They do not establish finiteness: the
single-pillar NaN case above still applies.
Interpolation and discount factors
Section titled “Interpolation and discount factors”def rate_at[n](curve: YieldCurve[n], t: f32) -> f32def spline_rate_at[n](curve: YieldCurve[n], t: f32) -> f32def log_linear_rate_at[n](curve: YieldCurve[n], t: f32) -> f32def nss_rate(beta0: f32, beta1: f32, beta2: f32, beta3: f32, tau1: f32, tau2: f32, t: f32) -> f32def discount_factor[n](curve: YieldCurve[n], t: f32) -> f32All three pillar interpolators hold the end rates flat outside the pillars:
a t before the first pillar reads the first rate, and a t after the
last reads the last. One pillar gives a flat curve: spline_rate_at of a
one-pillar curve at 4% reads 0.04 everywhere, and a two-pillar spline is
the straight line between them (0.04 midway between 3% and 5%). A curve
with no pillars can be built and reads rate 0.0 and discount factor
1.0 at every t.
rate_atinterpolates the pillar rates linearly int(usingNautilus.Interpolation.linear_interp_sorted).spline_rate_atevaluates a natural cubic spline through the pillars (second derivative zero at both ends). It refits the spline on every call, so evaluating many maturities costs one fit each.log_linear_rate_atinterpolatesln(rate)linearly and exponentiates, so it needs every pillar rate to be positive. A zero or negative pillar rate has no logarithm: a query in any interval that touches it returns NaN, without a failure. On rates[-0.005, 0.01]at times[1, 2], botht = 1.5andt = 2.0return NaN. Userate_atfor curves that cross zero.discount_factorreturnsexp(-rate_at(curve, t) * t).
nss_rate evaluates the Nelson-Siegel-Svensson form directly from its six
parameters, independent of any pillar set:
r(t) = beta0 + beta1 * g(t / tau1) + beta2 * (g(t / tau1) - exp(-t / tau1)) + beta3 * (g(t / tau2) - exp(-t / tau2))
with g(x) = (1 - exp(-x)) / x. beta0 is the long rate and
beta0 + beta1 the instantaneous short rate; at t = 0 the function
uses the limit g(0) = 1 and returns exactly beta0 + beta1. Supply
tau1 > 0, tau2 > 0, and t >= 0; nothing checks them. A zero tau
does not fail: at t > 0 it makes g zero, so tau1 = 0 silently drops
the beta1 and beta2 terms (the example parameters with tau1 = 0 give
0.04 at t = 1).
For example, on the three-pillar curve above:
r = rate_at(curve, cast(1.5, f32)) -- 0.035s = spline_rate_at(curve, cast(1.5, f32)) -- 0.03546875l = log_linear_rate_at(curve, cast(1.5, f32)) -- 0.034641013early = rate_at(curve, cast(0.5, f32)) -- 0.03, flat before the first pillard = discount_factor(curve, cast(2.0, f32)) -- 0.9231163 == exp(-0.08)For example, the NSS rate tends to beta0 + beta1 as the
maturity goes to zero and to beta0 at long horizons:
r = nss_rate(cast(0.04, f32), cast(-0.02, f32), cast(0.01, f32), cast(0.0, f32), cast(1.0, f32), cast(2.0, f32), cast(0.0, f32))-- 0.02 (beta0 + beta1)r30 = nss_rate(cast(0.04, f32), cast(-0.02, f32), cast(0.01, f32), cast(0.0, f32), cast(1.0, f32), cast(2.0, f32), cast(30.0, f32))-- 0.039666668, approaching beta0Bootstrapping
Section titled “Bootstrapping”def bootstrap_zero_from_par[n](times: tensor[n, f32], par_yields: tensor[n, f32]) -> YieldCurve[n]bootstrap_zero_from_par builds a zero curve from par yields of bonds
paying one coupon per year. It treats pillar i as the bond whose coupons
fall on pillars 0..i, each paying the full annual coupon c_i, so the
pillars must be the consecutive coupon dates 1, 2, 3, ... years. The
discount factor at pillar i is
P_i = (1 - c_i * (P_0 + ... + P_{i-1})) / (1 + c_i)
and the zero rate is -ln(P_i) / t_i. The times enter only that last
division. Nothing checks the spacing, and no rate is interpolated for a
missing coupon date: pillars [0.5, 1.0] at 5% are read as two annual
coupons and return rates [0.097580194, 0.09758029] instead of about 5%.
For semiannual or gapped instruments use bootstrap_multi below. A
P_i <= 0 gives a NaN rate. Times must be strictly increasing. For example:
times = to_tensor([cast(1.0, f32), cast(2.0, f32)])pars = to_tensor([cast(0.05, f32), cast(0.06, f32)])par_curve = bootstrap_zero_from_par(times, pars)-- rates [0.048790097, 0.058554675]; the two-year par bond reprices to 1.0Instrument bootstrap
Section titled “Instrument bootstrap”type Instrument = | Deposit { tenor: f32, rate: f32 } | ZeroCoupon { tenor: f32, price: f32 } | ParSwap { tenor: f32, par_rate: f32, payments_per_year: i64 }
def deposit(tenor: f32, rate: f32) -> Instrumentdef zero_coupon(tenor: f32, price: f32) -> Instrumentdef cur_par_swap(tenor: f32, par_rate: f32, payments_per_year: i64) -> Instrumentdef instrument_validate(inst: Instrument) -> booldef bootstrap_multi(instruments: List[Instrument]) -> (List[f32], List[f32])def bootstrap_multi_curve[n](instruments: List[Instrument], times_template: tensor[n, f32]) -> YieldCurve[n]bootstrap_multi solves one continuously compounded zero rate per
instrument, in list order, and returns the pillar times and rates.
bootstrap_multi_curve wraps the same result as a YieldCurve.
times_template supplies the n in YieldCurve[n]; its values are ignored.
Its length must equal the instrument count. A mismatch raises a runtime fail
naming Shoals.Curves.bootstrap_multi_curve and reporting both counts, for
example template has 3 entries for 2 instruments.
- A deposit is simple interest:
DF(t) = 1 / (1 + rate * t). - A zero-coupon price is the discount factor at its tenor.
- A par swap's fixed leg pays
par_rate / payments_per_yearon each datek / payments_per_yearfork = 1..N, whereN = tenor * payments_per_year, and the solved pillar satisfiespar_rate * annuity + DF(tenor) = 1. Discount factors at coupon dates between pillars come from the curve being built under therate_atconvention: zero rates are linear between pillars and flat outside them. A bootstrapped curve therefore reprices each input swap throughdiscount_factor.
instrument_validate rejects a non-finite tenor or quote, a non-positive
tenor, a deposit rate at or below -1 or whose 1 + rate * tenor is not
positive, a zero-coupon price outside (0, 1], a non-positive
payments_per_year, and a swap tenor that is not a whole number of payment
periods. The bootstrap raises a runtime fail naming
Shoals.Curves.bootstrap_multi when an instrument is invalid, or when any
instrument's tenor does not exceed every earlier pillar (instruments must be
listed in strictly increasing tenor). It never snaps a schedule or re-sorts
pillars.
bootstrap_multi returns finite zero rates or raises a runtime fail.
This contract applies to the instrument bootstrap; bootstrap_zero_from_par
can return a NaN rate for a non-positive par price.
The instrument bootstrap searches [-0.5, 2.0] using Nautilus.Roots.brent.
If the solver returns NaN, Shoals reports the instrument's tenor and quote,
the repricing residuals at both endpoints, and one of three diagnostics:
- a quote whose zero rate lies outside the bracket leaves the residual the
same sign at both endpoints, and fails with no zero rate for this
instrument in the search bracket
[-0.5, 2.0]; - a non-finite endpoint residual fails with the repricing residual is not
finite over the search bracket. A long-dated swap can overflow
exp(0.5 * tenor)at about 177 years;instrument_validatedoes not cap maturity. Caller-supplied NaN earlier pillars can also cause this throughfd_bump_pillar_rateand the gradient entry points. A root may exist even though this bracket cannot be searched in f32; - a failed solve with finite, sign-changing endpoint residuals reports did not converge. The solver allows one hundred iterations.
For example, a gapped annual strip:
insts = [ deposit(cast(0.5, f32), cast(0.041, f32)), deposit(cast(1.0, f32), cast(0.042, f32)), cur_par_swap(cast(2.0, f32), cast(0.0435, f32), cast(1, i64)), cur_par_swap(cast(5.0, f32), cast(0.0452, f32), cast(1, i64)), cur_par_swap(cast(10.0, f32), cast(0.0468, f32), cast(1, i64))]rates = bootstrap_multi(insts).1-- 10y zero rate ~0.0460236Quote sensitivities
Section titled “Quote sensitivities”def bootstrap_grad_at_solution(instruments: List[Instrument]) -> List[f32]def bootstrap_grad_full_jacobian[m](paths_template: &tensor[m, f32], instruments: List[Instrument]) -> tensor[m, m, f32]Both bootstrap the instruments as bootstrap_multi does and differentiate
the solved zero rates with respect to the quotes by the implicit function
theorem, over the same coupon schedule and interpolation. Index i is the
i-th instrument in list order, which is also the i-th pillar. The quote
is the deposit rate, the zero-coupon price, or the swap par rate, so the
units are zero-rate change per unit change of that quote.
bootstrap_grad_at_solutionreturns the diagonal:d z_i / d q_iwith the earlier pillars held fixed, one entry per instrument.bootstrap_grad_full_jacobianreturns them x mmatrix with entry[i, j] = d z_i / d q_j, row-major. It is lower triangular, because a pillar depends only on its own and earlier quotes, and its diagonal is the list above.paths_templatesuppliesm; its values are ignored.
insts = [ deposit(cast(1.0, f32), cast(0.04, f32)), cur_par_swap(cast(2.0, f32), cast(0.045, f32), cast(1, i64)), cur_par_swap(cast(3.0, f32), cast(0.05, f32), cast(1, i64))]diag = bootstrap_grad_at_solution(insts)-- [0.9615385, 0.9809815, 1.0079565]tmpl = to_tensor([cast(0.0, f32), cast(0.0, f32), cast(0.0, f32)])jac = bootstrap_grad_full_jacobian(tmpl, insts)-- rows [0.9615385, 0.0, 0.0], [-0.021743337, 0.9809815, 0.0],-- [-0.016273007, -0.033038095, 1.0079565]A paths_template whose length differs from the instrument count raises a
runtime fail reporting both counts, and an invalid instrument fails with
the bootstrap's diagnostic; neither returns a NaN-filled Jacobian. FRAs and
futures are not instruments here, and the solve is sequential rather than
joint; see Scope and limitations.
Basis spreads
Section titled “Basis spreads”def curve_basis_from_pillars[n](times: tensor[n, f32], spreads: tensor[n, f32]) -> CurveBasis[n]def basis_pillars[n](basis: CurveBasis[n]) -> (tensor[n, f32], tensor[n, f32])def basis_spread_at[n](basis: CurveBasis[n], t: f32) -> f32def discount_factor_with_basis[n, m](domestic: YieldCurve[n], basis: CurveBasis[m], t: f32) -> f32A CurveBasis holds spreads as continuously compounded decimals added to a
zero rate (0.001 is 10 basis points). curve_basis_from_pillars applies
the same strictly-increasing check as the curve constructors.
basis_spread_at interpolates the spreads linearly, flat outside the
pillars, exactly as rate_at does. discount_factor_with_basis returns
exp(-(rate_at(domestic, t) + basis_spread_at(basis, t)) * t); the two
curves may have different pillars.
basis = curve_basis_from_pillars(to_tensor([cast(1.0, f32), cast(5.0, f32)]), to_tensor([cast(0.001, f32), cast(0.003, f32)]))s3 = basis_spread_at(basis, cast(3.0, f32)) -- 0.0019999999dfb = discount_factor_with_basis(curve, basis, cast(2.0, f32)) -- 0.92035115 == exp(-(0.04 + 0.0015) * 2)These functions do not calibrate a basis curve against a domestic curve or solve basis-swap quotes.
Sensitivity shifts
Section titled “Sensitivity shifts”def parallel_shift[n](curve: YieldCurve[n], delta: f32) -> YieldCurve[n]def key_rate_shift[n](curve: YieldCurve[n], pillar_index: i64, delta: f32) -> YieldCurve[n]def twist[n](curve: YieldCurve[n], short_delta: f32, long_delta: f32) -> YieldCurve[n]def butterfly[n](curve: YieldCurve[n], wing_delta: f32, body_delta: f32) -> YieldCurve[n]def scale_rates[n](curve: YieldCurve[n], factor: f32) -> YieldCurve[n]parallel_shift adds the same delta to every pillar rate.
key_rate_shift adds delta only to the pillar at the zero-based
pillar_index, leaving the others fixed. An index that is negative or past
the last pillar matches nothing and returns the curve unchanged, without a
failure. twist interpolates a shift linearly in maturity
from short_delta at the shortest pillar to long_delta at the longest;
at the midpoint the applied shift is the average of the two. butterfly
adds w * wing_delta + (1 - w) * body_delta at a pillar at time t, where
w = |t - t_mid| / (t_max - t_mid) and t_mid is the midpoint of the
first and last pillar times. The shift is therefore wing_delta at the two
end pillars, body_delta at the time midpoint (whether or not a pillar sits
there), and linear in time between. On a single-pillar curve butterfly
adds body_delta and twist adds short_delta. scale_rates multiplies
every rate by factor.
On flat zero pillars at 1 to 5 years, butterfly(curve, 0.01, -0.01) gives
[0.01, 0.0, -0.01, 0.0, 0.01] and twist(curve, 0.01, -0.01) gives
[0.01, 0.005, 0.0, -0.005, -0.01].
For example, a parallel shift lifts every pillar by the same amount and a key-rate shift moves only the chosen pillar:
shifted = parallel_shift(curve, cast(0.001, f32)) -- every rate +10bpkr = key_rate_shift(curve, cast(1, i64), cast(0.005, f32)) -- only the 2y pillar +50bp