Skip to content

Curves

Discount Rates Belong in Their Own Object

Present-value calculations run against a curve — zero rates from a regulator, a swap curve from the market, a stress shifted off a baseline. A single flat rate stuffed into a column works for a first cut; a Curve carries a full term structure, supports parallel and key-rate stresses, and can be reconciled tenor-by-tenor against a vendor's discounting.

A Curve holds the discount-rate term structure as a single named value. You build it once from zero rates or par rates, and read off spot rates, discount factors, and forward rates by tenor. Sensitivity stresses produce new Curves you can swap into the same pricing logic with no code changes — only the curve differs.

Building a Curve

The two construction paths cover most actuarial use cases.

from_zero_rates — most common

You already have continuously- or annually-compounded zero rates at a list of tenors (often a regulator-published set). Build the Curve directly:

from gaspatchio import Curve

curve = Curve.from_zero_rates(
    tenors=[0.5, 1.0, 2.0, 5.0, 10.0, 20.0, 30.0],
    rates=[0.04, 0.041, 0.042, 0.044, 0.046, 0.045, 0.044],
)

Tenors are in years. Rates are annually compounded. Between knot tenors the curve interpolates linearly on the rate by default — see Interpolation and parametric curves for log-linear, monotone-cubic, and parametric (Svensson, Smith-Wilson) alternatives.

from_par_rates — when you start from market quotes

You have par yields at standard tenors (the typical market quote shape). The Curve bootstraps zero rates from the par rates internally:

curve = Curve.from_par_rates(
    tenors=[1.0, 2.0, 3.0, 4.0, 5.0],
    par_rates=[0.041, 0.042, 0.043, 0.0435, 0.044],
)

from_par_rates requires consecutive annual tenors starting at 1 — the bootstrap walks the curve year-by-year, so the input has to be dense. Use it when reconciling against a vendor that quotes par yields at every year; use from_zero_rates when the input is already a zero curve from a regulator publication or model output.

Interpolation and Parametric Curves

A discount curve is only pinned down at its knot tenors; everything between and beyond them is a modelling choice. The default — a straight line on the zero rates — is transparent and reconciles cleanly, but two situations call for more: a smoother shape between liquid points, and a principled extrapolation past the last traded tenor to a regulator-specified ultimate rate.

Choosing an interpolation

from_zero_rates and from_par_rates take an interpolation= argument. Every method passes through the input knots exactly; they differ only between knots. What happens past the grid is a separate choice — see Extrapolation: beyond the last knot.

interpolation= Between knots Reach for it when
"linear" (default) Straight line on the zero rates The regulator publishes a rate at every tenor you need; transparency matters most
"log_linear" Straight line in log-discount-factor space — a piecewise-constant forward rate You discount on a money-market basis, or want constant forwards between knots
"pchip" Monotone cubic (Fritsch-Carlson) — smooth, but shape-preserving, so it never overshoots You need a smooth curve and a plain cubic spline would introduce spurious humps
from gaspatchio import Curve

knots = {"tenors": [1.0, 2.0, 5.0, 10.0], "rates": [0.020, 0.025, 0.030, 0.033]}
linear_curve = Curve.from_zero_rates(**knots)                              # default
log_linear_curve = Curve.from_zero_rates(**knots, interpolation="log_linear")
monotone_curve = Curve.from_zero_rates(**knots, interpolation="pchip")

Extrapolation: beyond the last knot

A pension or whole-of-life projection discounts cashflows at 30, 40, 50 years; liquid market quotes often stop at 10 or 20. Everything past the last knot is convention, not observation — and the convention you pick moves long-tail present values by real money. So it is an explicit argument on from_zero_rates and from_par_rates, not a side effect of the interpolation method:

extrapolation= Beyond the last knot Reach for it when
"flat" (default) Holds the boundary spot rate — the 30y read equals the last-knot rate Transparency and reconcilability matter most; the tail is easy to explain and easy to check
"forward" (log_linear only) Holds the last segment's forward rate — the curve keeps compounding at the rate the market last implied Market-consistent discounting of cashflows well beyond the last liquid tenor
from gaspatchio import Curve

knots = {"tenors": [1.0, 2.0, 5.0, 10.0], "rates": [0.020, 0.025, 0.030, 0.033]}
flat_tail = Curve.from_zero_rates(**knots, interpolation="log_linear")
forward_tail = Curve.from_zero_rates(
    **knots, interpolation="log_linear", extrapolation="forward"
)

print(f"5y->10y forward:   {flat_tail.forward_rate(t1=5.0, t2=10.0):.4%}")
print(f"30y spot, flat:    {flat_tail.spot_rate(30.0):.4%}")
print(f"30y spot, forward: {forward_tail.spot_rate(30.0):.4%}")
print(f"30y DF, flat:      {flat_tail.discount_factor(30.0):.6f}")
print(f"30y DF, forward:   {forward_tail.discount_factor(30.0):.6f}")
5y->10y forward:   3.6009%
30y spot, flat:    3.3000%
30y spot, forward: 3.5005%
30y DF, flat:      0.377564
30y DF, forward:   0.356228

Inside the knot grid the two curves are identical — the choice only shows up past the boundary. That is exactly why it deserves an explicit argument: a tail convention that never appears in a short-dated reconciliation still moves a 40-year liability.

Both options clamp the rate, at both ends of the grid. Clamping the discount factor instead — holding the log-discount-factor level past the boundary — implies a spot rate that decays toward zero: on the curve above it reads 1.09% at 30y and prices a dollar due in 30 years as if it were due in 10. Below the first knot the same clamp errs the other way, reading 4.04% at six months against a 2% one-year rate. Gaspatchio holds rates, not discount factors, so the tail stays at the boundary rate rather than drifting to either extreme.

"forward" needs a forward rate to hold, and only the log-discount-factor representation defines one — on "linear" and "pchip" knots, extrapolation="forward" raises ValueError at construction rather than silently behaving like "flat".

For a regulator-mandated convergence to an ultimate forward rate — the Solvency II shape — extrapolation is not a clamp at all: build the curve with Smith-Wilson below. The parametric constructors take no extrapolation= argument, because the formula itself defines the tail.

Parametric curves: Svensson and Smith-Wilson

Where interpolation reads between the knots you supply, a parametric curve is defined by a formula — the same form central banks publish, or the extrapolation regulators mandate beyond the last liquid tenor.

Nelson-Siegel-Svensson (NSS) is the six-parameter form the Federal Reserve and ECB publish for the nominal yield curve — a level, a slope, and two curvature humps with their own decay rates. Build one from published parameters, or calibrate the parameters to market knots:

nss = Curve.from_svensson(b0=0.04, b1=-0.01, b2=0.005, b3=0.002, tau1=1.5, tau2=10.0)
nss_fitted = Curve.fit_svensson(
    tenors=[1.0, 2.0, 5.0, 10.0, 20.0, 30.0],   # six observations minimum (six NSS parameters)
    rates=[0.030, 0.032, 0.035, 0.037, 0.038, 0.039],
)

Smith-Wilson is the extrapolation method behind EIOPA's risk-free interest-rate term structures under Solvency II. It reproduces the liquid market rates exactly, then pulls the curve smoothly toward an Ultimate Forward Rate (UFR) beyond the last liquid point:

sw = Curve.fit_smith_wilson(
    tenors=[1.0, 2.0, 4.0, 5.0, 6.0, 7.0],
    rates=[0.01, 0.02, 0.03, 0.032, 0.035, 0.04],
    ufr=0.04,        # ultimate forward rate the curve converges to
    alpha=0.15,      # convergence speed; omit (alpha=None) to auto-calibrate to the EIOPA floor
)

sw_20y = sw.spot_rate(20.0)   # extrapolated well beyond the 7y last-liquid-point, toward the UFR

Both constructors return an ordinary Curvespot_rate, discount_factor, the stresses, and source_sha() all behave identically, so a parametric curve drops into the same pricing and governance flow as a knot-based one.

Reading Off the Curve

curve.spot_rate(t=5.0)            # zero rate at 5y, scalar
curve.discount_factor(t=10.0)     # PV(1 received at t=10), scalar
curve.forward_rate(t1=5.0, t2=10.0)   # 5y forward, 5y tenor

spot_rate and discount_factor also accept a Polars Series, Polars expression, list, or af["col"] reference — so you can feed Schedule-derived t values straight through:

from datetime import date
from gaspatchio import Schedule

sched = Schedule.from_calendar_grid(
    start_date=date(2025, 1, 31), n_periods=240, frequency="1M"
)
# n_periods=240 produces 241 cumulative year-fractions (one per period
# boundary, inclusive); drop the leading 0 so the vector aligns with the
# 240 period-end cashflows.
t_years = sched.cumulative_year_fractions()[1:]    # 240 floats
disc_factors = curve.discount_factor(t=t_years)    # 240 floats

discount_factor accepts a Python list, a Polars Series or expression, an af["col"] reference — anything that resolves to scalar year-fractions. To broadcast the resulting vector onto every policy, assign it as a list column:

import polars as pl
from gaspatchio import ActuarialFrame

policies = pl.DataFrame({"premium": [[100.0] * 240, [200.0] * 240]})
policies = policies.with_columns(
    pl.Series("df", [disc_factors] * len(policies), dtype=pl.List(pl.Float64))
)
af = ActuarialFrame(policies)
af.pv_premium = (af["premium"] * af["df"]).list.sum()

Sensitivity Stresses

Curve supports parallel and key-rate sensitivities directly. Both are pure-function transforms — they return a new Curve, leaving the original untouched, so you can run the same pricing logic against any combination of them without restating your baseline.

Parallel shift

Shift every zero rate by the same number of basis points:

up_100 = curve.shift_parallel(bps=100)
down_50 = curve.shift_parallel(bps=-50)

Run the same pricing logic against curve, up_100, and down_50 to compute DV01-style sensitivities.

Key-rate shift

Shift exactly one knot tenor by a basis-point amount, leaving all other knots unchanged. Used for key-rate-duration analysis and for isolating the sensitivity to a single point on the curve:

key_5y = curve.key_rate_shift(tenor=5.0, bps=50)

The tenor= argument must be one of the knot tenors used to build the curve. Shifting at a non-knot tenor raises ValueError — the curve doesn't infer between-knot perturbation rules.

Worked Example

A flat 4% curve, three readings, and two stresses:

from gaspatchio import Curve

flat = Curve.from_zero_rates(
    tenors=[0.5, 1.0, 2.0, 5.0, 10.0, 20.0, 30.0],
    rates=[0.04] * 7,
)

print(f"Spot 1y:  {flat.spot_rate(1.0):.6f}")
print(f"Spot 5y:  {flat.spot_rate(5.0):.6f}")
print(f"Spot 7y:  {flat.spot_rate(7.0):.6f}")
print(f"DF 1y:    {flat.discount_factor(1.0):.6f}")
print(f"DF 5y:    {flat.discount_factor(5.0):.6f}")
print(f"DF 10y:   {flat.discount_factor(10.0):.6f}")
print(f"5y->10y forward: {flat.forward_rate(t1=5.0, t2=10.0):.6f}")

parallel = flat.shift_parallel(bps=100)
print(f"After +100bp parallel — DF 5y: {parallel.discount_factor(5.0):.6f}")

key = flat.key_rate_shift(tenor=5.0, bps=50)
print(f"After +50bp at 5y    — DF 5y: {key.discount_factor(5.0):.6f}")
print(f"                       DF 1y: {key.discount_factor(1.0):.6f}")
print(f"                       DF 10y: {key.discount_factor(10.0):.6f}")
Spot 1y:  0.040000
Spot 5y:  0.040000
Spot 7y:  0.040000
DF 1y:    0.961538
DF 5y:    0.821927
DF 10y:   0.675564
5y->10y forward: 0.040000
After +100bp parallel — DF 5y: 0.783526
After +50bp at 5y    — DF 5y: 0.802451
                       DF 1y: 0.961538
                       DF 10y: 0.675564

The parallel shift moves every discount factor; the key-rate shift moves only the 5y point and lets nearby tenors absorb the perturbation through interpolation. The 1y and 10y discount factors after the key-rate shift sit between the unstressed and parallel-stressed values — exactly what a single-knot sensitivity should produce.

Governance: Curve Identity

Quarter-over-quarter curve drift is one of the easiest contributors to miss in a movement analysis. A regulator publishes a fresh zero curve, someone rebuilds with a slightly different tenor set, a stale copy slips back in from an old notebook — and the AOM ends up with discounting movement nobody set out to put there. source_sha() gives the curve a single value that captures its construction (the tenors, the rates, the day count, the interpolation and extrapolation choices, the parametric form if any) so a quiet drift can't go unnoticed:

flat.source_sha()
# 'sha256:a594be4f4ede393146f83f32f895645c56118a0140af855da001761f84f8e890'

Record this alongside each valuation. Two curves with the same tenors and rates have the same source_sha(); a regulator update that moves a single rate by a basis point gives you a different one. The Curve's identity also rolls up into the rollforward's overall version stamp, so a curve change between quarters is visible at the model level without separate tracking — and a curve change you didn't expect to see is one you can spot before it shows up on the AOM.

Runnable Companions

The patterns above run end-to-end in the mini-VA tutorial:

  • bindings/python/gaspatchio/tutorials/level-3-mini-va-typed/base/model.py — flat 4% curve, discount factors fed from cumulative year fractions
  • bindings/python/gaspatchio/tutorials/level-3-mini-va-typed/steps/05-rate-curves/model.py — non-flat curve plus shift_parallel(bps=100) and key_rate_shift(tenor=5.0, bps=50) worked through
  • bindings/python/gaspatchio/tutorials/patterns/curves-and-scheduling/05_interpolation_methods.py — the three interpolations plus the Svensson and Smith-Wilson parametric curves, each asserted against a closed form or the published lifelib spot values

See Also

  • Schedulescumulative_year_fractions() is the natural input to discount_factor(t=...)
  • Inspection — the Curve's identity rolls up into the rollforward's overall version stamp