Skip to content

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 float, int, list[float], np.ndarray, pl.Series, or pl.Expr.

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 t is not one of the supported types.

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

required
ufr float

Ultimate forward rate (annual, e.g. 0.04 for 4 %). Must satisfy ufr > -1. Defaults to 0.033 (EIOPA 2026 long-term average).

0.033
llp float | None

Last Liquid Point in years. Used as the anchor for the EIOPA convergence-point CP = max(llp + 40, 60) when alpha=None. Defaults to max(tenors) when None.

None
alpha float | None

Mean-reversion speed. Must be >= 0.05. If None, alpha is calibrated automatically using the EIOPA convergence criterion: smallest alpha in [0.05, 1.0] such that the instantaneous forward rate at the convergence point is within 1 bp of omega.

None
day_count DayCount | None

Day-count convention; defaults to ActualActualISDA. Recorded for identity / source_sha only — it does not affect rate evaluation.

None

Returns:

Type Description
Curve

A frozen :class:Curve with parametric Smith-Wilson dispatch

Curve

enabled. The tenors and rates fields hold the

Curve

de-duplicated source observations for provenance.

Raises:

Type Description
ValueError

If ufr <= -1, alpha < 0.05, or tenors and rates differ in length.

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 >= 6 elements.

required
rates list[float]

Annually-compounded zero rates at each tenor. Same length as tenors.

required
day_count DayCount | None

Day-count convention; defaults to ActualActualISDA. Recorded for identity / source_sha only — it does not affect rate evaluation.

None

Returns:

Type Description
Curve

A frozen :class:Curve with parametric dispatch enabled. The

Curve

curve's tenors and rates fields hold the source annual

Curve

observations for provenance; parametric holds the fitted CC

Curve

NSS parameters.

Raises:

Type Description
ValueError

If tenors and rates differ in length, or fewer than 6 observations are supplied.

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

required
t2 float

End year fraction. Must be strictly greater than t1.

required

Returns:

Type Description
float

The annually compounded forward rate as a scalar float.

Raises:

Type Description
ValueError

If t1 >= t2.

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. [1.0, 2.0, 3.0]).

required
par_rates list[float]

Par coupon rates at each tenor.

required
day_count DayCount | None

Day-count convention; defaults to ActualActualISDA. Recorded for identity / source_sha only — it does not affect rate evaluation.

None
interpolation InterpolationMethod

Interpolation method; 'linear' (default) or 'log_linear'.

'linear'
extrapolation str

Behaviour outside the knot range; see :meth:from_zero_rates.

'flat'

Returns:

Type Description
Curve

A frozen :class:Curve whose rates are bootstrapped zero rates.

Raises:

Type Description
ValueError

If tenors are not contiguous annual integers starting at 1, or if the underlying :meth:from_zero_rates validation fails.

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 b0 > 0 for a positive long-run rate in most regimes (a warning is emitted if not, but not raised — negative rate regimes are valid in ZIRP/NIRP environments).

required
b1 float

Slope parameter. b0 + b1 is the short-rate limit.

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 ActualActualISDA. Recorded for identity / source_sha only — it does not affect rate evaluation.

None

Returns:

Type Description
Curve

A frozen :class:Curve with parametric dispatch enabled.

Raises:

Type Description
ValueError

If tau1 <= 0 or tau2 <= 0.

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

required
day_count DayCount | None

Day-count convention; defaults to ActualActualISDA. Recorded for identity / source_sha only — it does not affect rate evaluation.

None
interpolation InterpolationMethod

Interpolation method; 'linear' (default), 'log_linear' (linear in log-discount-factor space, better for preserving positivity of discount factors), or 'pchip' (shape-preserving cubic Hermite, smoother forward rates).

'linear'
extrapolation str

Behaviour outside the knot range. 'flat' (default) holds the boundary knot's spot rate; 'forward' (log_linear only) holds the last segment's forward rate — the market-consistent choice for discounting cashflows well beyond the last liquid tenor.

'flat'

Returns:

Type Description
Curve

A frozen :class:Curve instance.

Raises:

Type Description
ValueError

If tenors and rates differ in length, fewer than 2 knots are supplied, tenors are not strictly increasing, or an unsupported interpolation method is requested.

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 0.0001 (i.e. 100 bps == 1 percentage point).

required

Returns:

Type Description
Curve

A new frozen :class:Curve with all rates identical except at

Curve

tenor, which is incremented by bps / 10_000.

Raises:

Type Description
ValueError

If tenor is not an exact knot in this curve.

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 0.0001 (i.e. 100 bps == 1 percentage point).

required

Returns:

Type Description
Curve

A new frozen :class:Curve with the same tenors, day-count, and

Curve

interpolation method, but every knot rate incremented by

Curve

bps / 10_000.

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 sha256:<64-hex-chars>.

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 float, int, list[float], np.ndarray, pl.Series, or pl.Expr.

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 t is not one of the supported types.

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