Curves API¶
For the actuarial framing — when to build with from_zero_rates vs from_par_rates, when to use parallel vs key-rate shifts, how Curves compose with Schedules — see Curves.
Curve¶
gaspatchio.curves._curve.Curve
dataclass
¶
Typed term-structure curve.
Construct via :meth:from_zero_rates or :meth:from_par_rates. Direct
construction is intentionally awkward — use the classmethods.
canonical_form()
¶
Return the JSON-encodable canonical form of this Curve.
For knot-based curves the form is identical to previous versions
(keys: kind, tenors, rates, day_count,
interpolation). For parametric curves an additional
parametric sub-dict is included with kind and all parameters,
while tenors and rates are empty lists (backward-compatible:
knot curves with no parametric field produce the exact same bytes
as before this change).
Returns:
| Type | Description |
|---|---|
dict[str, object]
|
A JSON-serialisable dict uniquely identifying this curve. |
Examples:
>>> c = Curve.from_zero_rates(tenors=[1.0, 5.0], rates=[0.03, 0.04])
>>> c.canonical_form()["kind"]
'Curve'
>>> isinstance(c.canonical_form()["tenors"], list)
True
>>> c2 = Curve.from_svensson(
... b0=0.04, b1=-0.01, b2=0.005, b3=0.002, tau1=1.5, tau2=10.0
... )
>>> c2.canonical_form()["parametric"]["kind"]
'svensson'
discount_factor(t)
¶
Annually compounded discount factor: DF(t) = (1 + r(t))^(-t).
Discounting is annually compounded; continuously compounded
(exp(-r*t)) is not yet supported. Two curves with identical
rate grids but different compounding frequencies would produce
meaningfully different DFs — the choice is canonical and not
user-configurable.
Supported domain is t > 0 for log_linear and smith_wilson;
for those methods an out-of-domain t <= 0 yields NaN. For any
method, a non-finite t (NaN or ±inf) yields NaN — the out-of-domain
rate propagates through (1 + NaN)^(-t) = NaN.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
t
|
TimeInput
|
Year fraction(s) at which to evaluate the discount factor.
Accepts |
required |
Returns:
| Type | Description |
|---|---|
float | list[float] | NDArray[float64] | Series | Expr
|
The discount factor(s). Return type matches input type. |
Raises:
| Type | Description |
|---|---|
TypeError
|
If |
Examples:
>>> from gaspatchio.curves import Curve
>>> c = Curve.from_zero_rates(tenors=[1.0, 5.0], rates=[0.03, 0.03])
>>> c.discount_factor(1.0)
0.970873...
>>> c.discount_factor([1.0, 2.0])
[0.970873..., 0.942595...]
For list-column projection data use the pl.Expr path — the Rust
kernel evaluates all tenors in one pass, with no Python-level loop:
>>> import polars as pl
>>> from gaspatchio import ActuarialFrame
>>> from gaspatchio.curves import Curve
>>> c2 = Curve.from_zero_rates(tenors=[1, 5, 10], rates=[0.01, 0.02, 0.03])
>>> af = ActuarialFrame(pl.DataFrame({"t": [[1.0, 5.0, 10.0]]}))
>>> af.df = c2.discount_factor(af["t"])
>>> af.collect()["df"].to_list()[0]
[0.990..., 0.905..., 0.7...]
fit_smith_wilson(*, tenors, rates, ufr=0.033, llp=None, alpha=None, day_count=None)
classmethod
¶
Fit a classic Solvency II Smith-Wilson curve to zero-coupon market rates.
Solves the linear system W @ zeta = m - mu (see
:mod:~gaspatchio.curves._smith_wilson) for the Wilson weights
zeta and stores the result as a ParametricPayload. Subsequent
evaluation via :meth:spot_rate dispatches to either the Rust kernel
(for pl.Expr / list-column inputs) or the Python closed form (for
scalar / array inputs), both using the same precomputed
(u, zeta, omega, alpha).
The omega = log(1 + ufr) is computed once here and carried in the
payload to guarantee that the value used during the solve and the value
used during evaluation are identical.
Near-duplicate tenors within 1/12 year (~1 month) of the previously- kept tenor are dropped (first of the pair wins) after sorting.
When to use: the standard EIOPA-mandated Solvency II extrapolation method for EUR, GBP, and other major currencies where the risk-free term structure must be extended beyond the Last Liquid Point (LLP) toward the Ultimate Forward Rate (UFR). Pass your liquid market zero rates (up to and including the LLP) and let alpha auto-calibrate to the EIOPA convergence criterion. The 2026 EIOPA FSP/LLFR alternative extrapolation is a planned future method (see roadmap).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
tenors
|
list[float]
|
Tenor knot points in years. Must be > 0 and contain at least 1 unique tenor after de-duplication. |
required |
rates
|
list[float]
|
Annually-compounded zero rates at each tenor. Same length
as |
required |
ufr
|
float
|
Ultimate forward rate (annual, e.g. |
0.033
|
llp
|
float | None
|
Last Liquid Point in years. Used as the anchor for the EIOPA
convergence-point |
None
|
alpha
|
float | None
|
Mean-reversion speed. Must be |
None
|
day_count
|
DayCount | None
|
Day-count convention; defaults to |
None
|
Returns:
| Type | Description |
|---|---|
Curve
|
A frozen :class: |
Curve
|
enabled. The |
Curve
|
de-duplicated source observations for provenance. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Examples:
>>> from gaspatchio.curves import Curve
>>> sw = Curve.fit_smith_wilson(
... tenors=[1, 2, 3, 5, 7, 10, 15, 20],
... rates=[0.031, 0.033, 0.034, 0.036, 0.038, 0.040, 0.041, 0.042],
... )
>>> sw.spot_rate(20.0)
0.042...
>>> sw.spot_rate(60.0)
0.037...
fit_svensson(*, tenors, rates, day_count=None)
classmethod
¶
Fit an NSS curve to observed annually-compounded zero rates.
Uses separable nonlinear least squares (inner OLS over betas for each
candidate tau pair, scored by residual SSE) to recover NSS parameters
from market data. The fit is performed in continuously-compounded space
(linear in the betas), so annual rates are converted to CC before
fitting and the stored params are CC params consistent with
:func:~gaspatchio.curves._svensson.svensson_spot_cc.
The source tenors and rates (annual inputs) are stored on the
curve for provenance so that :meth:canonical_form / :meth:source_sha
reflect the actual fitted data. Evaluation always dispatches through the
NSS parametric payload, not the stored knots.
When to use: when you have a set of observed zero rates from market data
(e.g. treasury strips, swap zero rates, or bootstrapped par-rate data)
and want a smooth parametric curve rather than a piecewise interpolation.
Requires at least 6 observations to identify all 6 NSS parameters. For
curves where you already have official published NSS parameters (e.g.
central-bank fitted curves), use :meth:from_svensson directly.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
tenors
|
list[float]
|
Tenor knot points in years. Must have |
required |
rates
|
list[float]
|
Annually-compounded zero rates at each tenor. Same length
as |
required |
day_count
|
DayCount | None
|
Day-count convention; defaults to |
None
|
Returns:
| Type | Description |
|---|---|
Curve
|
A frozen :class: |
Curve
|
curve's |
Curve
|
observations for provenance; |
Curve
|
NSS parameters. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Examples:
>>> from gaspatchio.curves import Curve
>>> nss = Curve.fit_svensson(
... tenors=[1, 2, 5, 10, 20, 30],
... rates=[0.030, 0.032, 0.035, 0.038, 0.040, 0.041],
... )
>>> nss.parametric is not None
True
>>> nss.spot_rate(10.0)
0.037...
forward_rate(*, t1, t2)
¶
Annually compounded forward rate between t1 and t2.
Derived from the discount factors:
DF(t1) / DF(t2) = (1 + F(t1, t2))^(t2 - t1)
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
t1
|
float
|
Start year fraction. Must be strictly less than |
required |
t2
|
float
|
End year fraction. Must be strictly greater than |
required |
Returns:
| Type | Description |
|---|---|
float
|
The annually compounded forward rate as a scalar float. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Examples:
>>> c = Curve.from_zero_rates(tenors=[1.0, 30.0], rates=[0.04, 0.04])
>>> c.forward_rate(t1=2.0, t2=5.0)
0.04...
from_par_rates(*, tenors, par_rates, day_count=None, interpolation='linear', extrapolation='flat')
classmethod
¶
Build a Curve via bootstrap from annual par coupon rates.
Currently supports integer-year tenors starting at year 1 only,
contiguous. Returns a Curve whose rates are zero rates derived
via the bootstrap recursion.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
tenors
|
list[float]
|
Integer-year tenors starting at 1, contiguous (e.g.
|
required |
par_rates
|
list[float]
|
Par coupon rates at each tenor. |
required |
day_count
|
DayCount | None
|
Day-count convention; defaults to |
None
|
interpolation
|
InterpolationMethod
|
Interpolation method; |
'linear'
|
extrapolation
|
str
|
Behaviour outside the knot range; see
:meth: |
'flat'
|
Returns:
| Type | Description |
|---|---|
Curve
|
A frozen :class: |
Raises:
| Type | Description |
|---|---|
ValueError
|
If tenors are not contiguous annual integers starting at
1, or if the underlying :meth: |
Examples:
>>> c = Curve.from_par_rates(
... tenors=[1.0, 2.0, 3.0], par_rates=[0.04, 0.04, 0.04]
... )
>>> c.rates
(0.04..., 0.04..., 0.04...)
from_svensson(*, b0, b1, b2, b3, tau1, tau2, day_count=None)
classmethod
¶
Build a Curve from Nelson-Siegel-Svensson (NSS) parameters.
Implements GSW eq. 22. The curve is closed-form and does not require knot points — the parametric model is evaluated directly at any tenor.
When to use: when you have published Nelson-Siegel-Svensson parameters (e.g. from the US Federal Reserve GSW model, ECB, or central bank yield-curve publication) and want to build a smooth closed-form curve without supplying individual knot rates. The curve evaluates the NSS formula directly at any tenor — no interpolation is performed and no knot boundary is encountered, so extrapolation to very long tenors (50+ years) is well-behaved.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
b0
|
float
|
Level parameter (long-run continuously-compounded rate). Must
satisfy |
required |
b1
|
float
|
Slope parameter. |
required |
b2
|
float
|
First curvature parameter. |
required |
b3
|
float
|
Second curvature parameter. |
required |
tau1
|
float
|
First decay factor in years. Must be strictly positive. |
required |
tau2
|
float
|
Second decay factor in years. Must be strictly positive. |
required |
day_count
|
DayCount | None
|
Day-count convention; defaults to |
None
|
Returns:
| Type | Description |
|---|---|
Curve
|
A frozen :class: |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Examples:
>>> from gaspatchio.curves import Curve
>>> nss = Curve.from_svensson(
... b0=0.040, b1=-0.010, b2=0.005, b3=0.002, tau1=1.5, tau2=10.0
... )
>>> nss.parametric is not None
True
>>> nss.parametric.kind
'svensson'
>>> nss.spot_rate(7.5)
0.0402...
>>> nss.spot_rate(50)
0.0410...
from_zero_rates(*, tenors, rates, day_count=None, interpolation='linear', extrapolation='flat')
classmethod
¶
Build a Curve from zero (spot) rates indexed by tenor in years.
The standard entry point for knot-based discount curves. Supply market zero rates at a set of liquid tenor points and choose an interpolation method; the curve fills in rates at any intermediate tenor on demand.
When to use: whenever you have a published zero-rate curve (e.g. a
government bond spot curve or swap zero curve) and need to discount
projected cashflows at each projection step. For parametric curves from
central-bank model outputs, use :meth:from_svensson instead.
tenors and rates must have the same length, with tenors
strictly increasing and at least two knots present.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
tenors
|
list[float]
|
Tenor knot points in years, strictly increasing. |
required |
rates
|
list[float]
|
Annually-compounded zero rates at each knot point, same
length as |
required |
day_count
|
DayCount | None
|
Day-count convention; defaults to |
None
|
interpolation
|
InterpolationMethod
|
Interpolation method; |
'linear'
|
extrapolation
|
str
|
Behaviour outside the knot range. |
'flat'
|
Returns:
| Type | Description |
|---|---|
Curve
|
A frozen :class: |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Examples:
>>> from gaspatchio.curves import Curve
>>> c = Curve.from_zero_rates(
... tenors=[1.0, 5.0, 10.0], rates=[0.03, 0.03, 0.03]
... )
>>> c.tenors
(1.0, 5.0, 10.0)
>>> c.interpolation
'linear'
PCHIP interpolation produces smoother forward rates between knots:
>>> c_pchip = Curve.from_zero_rates(
... tenors=[1, 2, 5, 10],
... rates=[0.01, 0.02, 0.03, 0.035],
... interpolation="pchip",
... )
>>> c_pchip.spot_rate(3.5)
0.0266...
>>> c_pchip.discount_factor([1.0, 5.0])
[0.990..., 0.862...]
key_rate_shift(*, tenor, bps)
¶
Return a new Curve with the rate at the given knot tenor shifted by bps.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
tenor
|
float
|
The knot tenor (in years) at which to apply the shift. Must be an exact member of the curve's tenors. |
required |
bps
|
float
|
Basis points to add to the single knot rate. One basis point
is |
required |
Returns:
| Type | Description |
|---|---|
Curve
|
A new frozen :class: |
Curve
|
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Examples:
>>> c = Curve.from_zero_rates(
... tenors=[1.0, 5.0, 10.0], rates=[0.03, 0.04, 0.05]
... )
>>> bumped = c.key_rate_shift(tenor=5.0, bps=25)
>>> bumped.rates
(0.03, 0.0425, 0.05)
>>> c.key_rate_shift(tenor=10.0, bps=0) == c
True
shift_parallel(*, bps)
¶
Return a new Curve with every knot rate shifted by bps basis points.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
bps
|
float
|
Basis points to add to every knot rate. One basis point is
|
required |
Returns:
| Type | Description |
|---|---|
Curve
|
A new frozen :class: |
Curve
|
interpolation method, but every knot rate incremented by |
Curve
|
|
Examples:
>>> c = Curve.from_zero_rates(
... tenors=[1.0, 5.0, 10.0], rates=[0.03, 0.04, 0.05]
... )
>>> up = c.shift_parallel(bps=100)
>>> up.rates
(0.04, 0.05, 0.06...)
>>> c.shift_parallel(bps=0) == c
True
source_sha()
¶
Return sha256:<hex> over the canonical form bytes.
The digest is computed over :meth:canonical_form serialised by
:func:gaspatchio._identity.canonical_bytes (sorted
keys, no extra whitespace). Identical curves produce identical SHAs;
any knot, day-count, or interpolation difference changes the SHA.
Returns:
| Type | Description |
|---|---|
str
|
A string of the form |
Examples:
>>> a = Curve.from_zero_rates(tenors=[1.0, 5.0], rates=[0.03, 0.04])
>>> b = Curve.from_zero_rates(tenors=[1.0, 5.0], rates=[0.03, 0.04])
>>> a.source_sha() == b.source_sha()
True
>>> a.source_sha().startswith("sha256:")
True
spot_rate(t)
¶
Spot zero rate at year fraction(s) t.
Dispatches on the concrete type of t and returns a matching shape:
scalar in → scalar out, list in → list out, ndarray in → ndarray out,
Series in → Series out, Expr in → Expr out.
Supported domain is t > 0 for log_linear and smith_wilson
(the spot rate P(t)^(-1/t) - 1 is undefined at t = 0); for those
methods an out-of-domain t <= 0 yields NaN. For any method, a
non-finite t (NaN or ±inf) yields NaN. The same NaN sentinel
is returned identically across every path and container (scalar / list /
ndarray / Series / Expr).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
t
|
TimeInput
|
Year fraction(s) at which to evaluate the spot rate. Accepts
|
required |
Returns:
| Type | Description |
|---|---|
float | list[float] | NDArray[float64] | Series | Expr
|
The interpolated spot zero rate(s). Return type matches input type. |
Raises:
| Type | Description |
|---|---|
TypeError
|
If |
Examples:
>>> from gaspatchio.curves import Curve
>>> c = Curve.from_zero_rates(
... tenors=[1.0, 5.0, 10.0], rates=[0.03, 0.03, 0.03]
... )
>>> c.spot_rate(1.0)
0.03
>>> c.spot_rate([1.0, 5.0])
[0.03, 0.03]
For list-column projection data use the pl.Expr path — the Rust
kernel evaluates all tenors in one pass, with no Python-level loop:
>>> import polars as pl
>>> from gaspatchio import ActuarialFrame
>>> from gaspatchio.curves import Curve
>>> c2 = Curve.from_zero_rates(tenors=[1, 5, 10], rates=[0.01, 0.02, 0.03])
>>> af = ActuarialFrame(pl.DataFrame({"t": [[1.0, 5.0, 10.0]]}))
>>> af.r = c2.spot_rate(af["t"])
>>> af.collect()["r"].to_list()[0]
[0.01..., 0.02..., 0.03...]