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_core 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.

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.

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_core 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_core 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_core 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 curve type) 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_core/tutorials/level-3-mini-va-typed/base/model.py — flat 4% curve, discount factors fed from cumulative year fractions
  • bindings/python/gaspatchio_core/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

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