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 fractionsbindings/python/gaspatchio_core/tutorials/level-3-mini-va-typed/steps/05-rate-curves/model.py— non-flat curve plusshift_parallel(bps=100)andkey_rate_shift(tenor=5.0, bps=50)worked through
See Also¶
- Schedules —
cumulative_year_fractions()is the natural input todiscount_factor(t=...) - Inspection — the Curve's identity rolls up into the rollforward's overall version stamp