Interpolation Methods for ZeroRateCurve

ZeroRateCurve accepts an optional third argument specifying the interpolation method. The choice of interpolation affects forward curve smoothness, key rate duration locality, and performance when used with automatic differentiation (e.g. via sensitivities() in ActuaryUtilities.jl).

Available Methods

MethodSmoothnessLocalityDescription
Spline.MonotoneConvex()C1 (smooth)Best among smoothDefault. Finance-aware. Positive forwards, best KRD locality, fastest AD.
Spline.PCHIP()C1 (smooth)LocalMonotonicity-preserving, local. Good general-purpose alternative.
Spline.Akima()C1 (smooth)LocalLocal, resistant to outlier oscillation.
Spline.Linear()C0 (kinked)Perfectly localSimplest. Kinks in forward curve at tenor points.
Spline.Cubic()C2 (smoothest)GlobalSmoothest, but bumping one rate affects the entire curve.
Spline.BSpline(n)VariesMostly localnth-order B-spline.
using FinanceModels

rates = [0.02, 0.03, 0.035, 0.04, 0.045]
tenors = [1.0, 2.0, 5.0, 10.0, 20.0]

zrc = ZeroRateCurve(rates, tenors)                              # default: MonotoneConvex
zrc_pchip = ZeroRateCurve(rates, tenors, Spline.PCHIP())        # PCHIP
zrc_lin = ZeroRateCurve(rates, tenors, Spline.Linear())          # linear
zrc_cub = ZeroRateCurve(rates, tenors, Spline.Cubic())           # cubic B-spline
zrc_aki = ZeroRateCurve(rates, tenors, Spline.Akima())           # Akima

Key Tradeoffs

Forward Curve Smoothness

The instantaneous forward rate f(t) = r(t) + t · r'(t) should be smooth for stochastic models that differentiate the forward curve (e.g. Hull-White θ(t) calibration). Linear interpolation creates discontinuous jumps in f(t) at tenor points, while PCHIP, MonotoneConvex, Akima, and CubicSpline produce smooth forward curves.

The following code evaluates forward rates near the 2yr and 5yr tenor points to illustrate the difference:

using FinanceModels
using FinanceCore: discount
DI = FinanceModels.DataInterpolations

rates = [0.02, 0.025, 0.03, 0.035, 0.04]
tenors = [1.0, 2.0, 5.0, 10.0, 20.0]

eval_points = [1.9, 1.99, 2.0, 2.01, 2.1, 4.9, 4.99, 5.0, 5.01, 5.1]

# Helper: numerical forward rate from a zero-rate interpolator
function fwd_from_interp(interp, t)
    r = interp(t); h = 1e-6
    dr = (interp(t+h) - interp(t-h)) / (2h)
    r + t * dr
end

# Helper: numerical forward rate from a discount function
function fwd_from_discount(model, t)
    h = 1e-6
    -log(discount(model, t+h) / discount(model, t-h)) / (2h)
end

for (name, make_fwd) in [
    ("Linear", (r, t) -> begin
        interp = DI.BSplineInterpolation(r, t, 1, :Uniform, :Average;
            extrapolation=DI.ExtrapolationType.Extension)
        pt -> fwd_from_interp(interp, pt)
    end),
    ("PCHIP", (r, t) -> begin
        interp = DI.PCHIPInterpolation(r, t;
            extrapolation=DI.ExtrapolationType.Extension)
        pt -> fwd_from_interp(interp, pt)
    end),
    ("MonotoneConvex", (r, t) -> begin
        mc = FinanceModels.Yield.MonotoneConvex(collect(r), collect(float.(t)))
        pt -> fwd_from_discount(mc, pt)
    end),
    ("Akima", (r, t) -> begin
        interp = DI.AkimaInterpolation(r, t;
            extrapolation=DI.ExtrapolationType.Extension)
        pt -> fwd_from_interp(interp, pt)
    end),
    ("CubicSpline", (r, t) -> begin
        interp = DI.CubicSpline(r, t;
            extrapolation=DI.ExtrapolationType.Extension)
        pt -> fwd_from_interp(interp, pt)
    end),
]
    fwd = make_fwd(rates, tenors)
    println("\n--- $name: forward rate f(t) ---")
    for t in eval_points
        println("  t=$(lpad(round(t, digits=2), 5)):  f=$(round(fwd(t)*100, digits=4))%")
    end
end

Results:

Methodf(1.99)f(2.0)f(2.01)Jump?f(4.99)f(5.0)f(5.01)Jump?
Linear3.49%3.17%2.84%Yes3.83%3.67%3.50%Yes
PCHIP3.05%3.05%3.05%No3.63%3.64%3.64%No
MonotoneConvex3.08%3.08%3.09%No3.58%3.58%3.59%No
Akima2.96%2.94%2.95%No3.54%3.54%3.55%No
CubicSpline3.32%3.33%3.33%No3.32%3.32%3.33%No

MonotoneConvex additionally guarantees positive continuous forward rates when input rates imply positive forwards — a property unique to this method among those listed (Hagan & West, 2006).

Key Rate Duration Locality

When computing key rate durations (KRDs), bumping one zero rate should ideally affect only nearby discount factors. The table below shows ∂rate(t)/∂r₃ — the sensitivity of the interpolated rate at various times to a bump in the 5yr rate (rate index 3, with tenors at 1, 2, 5, 10, 20):

using FinanceModels
using FinanceModels: DataInterpolations as DI
using FinanceCore: discount
using ForwardDiff

rates = [0.02, 0.03, 0.035, 0.04, 0.045]
tenors = [1.0, 2.0, 5.0, 10.0, 20.0]

eval_points = [0.5, 1.0, 1.5, 2.0, 3.0, 5.0, 7.0, 10.0, 15.0, 20.0]

for (name, rate_at) in [
    ("Linear", (r, t, pt) ->
        DI.BSplineInterpolation(r, t, 1, :Uniform, :Average;
            extrapolation=DI.ExtrapolationType.Extension)(pt)),
    ("PCHIP", (r, t, pt) ->
        DI.PCHIPInterpolation(r, t;
            extrapolation=DI.ExtrapolationType.Extension)(pt)),
    ("MonotoneConvex", (r, t, pt) -> begin
        mc = FinanceModels.Yield.MonotoneConvex(collect(r), collect(float.(t)))
        -log(discount(mc, pt)) / pt
    end),
    ("Akima", (r, t, pt) ->
        DI.AkimaInterpolation(r, t;
            extrapolation=DI.ExtrapolationType.Extension)(pt)),
    ("CubicSpline", (r, t, pt) ->
        DI.CubicSpline(r, t;
            extrapolation=DI.ExtrapolationType.Extension)(pt)),
]
    println("\n--- $name: ∂rate(t)/∂r₃  (bump at 5yr) ---")
    for pt in eval_points
        g = ForwardDiff.gradient(r -> rate_at(r, tenors, pt), rates)
        println("  t=$(lpad(pt,4)):  $(round(g[3], digits=4))")
    end
end

Results (sensitivity of interpolated rate to 5yr rate bump):

tLinearPCHIPMonotoneConvexAkimaCubicSpline
0.50.0-0.100.0-0.110.02
1.00.00.00.00.00.0
1.50.0-0.08-0.02-0.12-0.02
2.00.00.00.00.00.0
3.00.330.500.450.650.26
5.01.01.01.01.01.0
7.00.60.640.530.630.95
10.00.00.00.00.00.0
15.00.0-0.23-0.08-0.42-0.56
20.00.00.00.00.00.0

Linear is perfectly local — zero sensitivity outside adjacent intervals. MonotoneConvex has the best locality among smooth methods (only -0.08 at t=15 vs -0.23 for PCHIP, -0.42 for Akima, and -0.56 for CubicSpline). All smooth methods have zero sensitivity at the exact tenor points (t=1, 2, 10, 20) because the interpolation passes through those data points exactly.

Performance

All methods are fast enough for interactive use. The table below shows end-to-end sensitivities() timing from ActuaryUtilities.jl, which includes gradient + Hessian + result packaging in a single call:

using ActuaryUtilities, FinanceModels, Printf

rates5 = [0.02, 0.025, 0.03, 0.035, 0.04]
tenors5 = [1.0, 2.0, 5.0, 10.0, 20.0]
cfs5 = [5.0, 5.0, 5.0, 5.0, 105.0]

for (name, spline) in [
    ("PCHIP", Spline.PCHIP()),
    ("MonotoneConvex", Spline.MonotoneConvex()),
    ("Linear", Spline.Linear()),
    ("Akima", Spline.Akima()),
    ("Cubic", Spline.Cubic()),
]
    zrc = ZeroRateCurve(rates5, tenors5, spline)
    sensitivities(zrc, cfs5, tenors5)  # warmup

    N = 5_000
    t0 = time_ns()
    for _ in 1:N; sensitivities(zrc, cfs5, tenors5); end
    elapsed = (time_ns() - t0) / 1e3 / N
    @printf("  %-20s  %7.1f μs\n", name, elapsed)
end

sensitivities() (5 tenors):

MethodTime
MonotoneConvex5.9 μs
Linear5.3 μs
PCHIP10.1 μs
Cubic10.1 μs
Akima15.2 μs

sensitivities() (12 tenors):

MethodTime
MonotoneConvex40.3 μs
PCHIP69.4 μs
Akima102.1 μs
Cubic112.8 μs
Linear131.2 μs

MonotoneConvex is fastest at both sizes. At 12 tenors the advantage is substantial — roughly 2x faster than PCHIP and 3x faster than Linear.

Recommendations

  • Spline.MonotoneConvex() (default): Best for finance applications. Guarantees positive continuous forward rates, best KRD locality among smooth methods (-0.08 vs -0.23 for PCHIP), and fastest AD performance. Based on Hagan & West (2006).
  • Spline.PCHIP(): Good general-purpose alternative. Smooth forward curves, local sensitivity, monotonicity-preserving.
  • Spline.Linear(): Use when you need perfectly localized KRDs (zero sensitivity outside adjacent intervals) and don't need smooth forwards.
  • Spline.Akima(): Alternative to PCHIP with different behavior near inflection points. Slightly more non-local leakage than PCHIP.
  • Spline.Cubic(): Use when curve smoothness matters most and you accept non-local KRD effects (e.g. negative duration at distant tenors from a local rate bump).