Validating Interest Rate Assumptions Against Treasury Curves

Validating an interest rate assumption against a Treasury curve means proving, node by node, that every spot rate a valuation intends to use sits within tolerance of an independently sourced reference curve for the same settlement date, and flagging any tenor that breaches so it never reaches a filed reserve. This focused walkthrough sits under Interest Rate Assumption Governance within the broader Assumption Validation & Rule Engine Design discipline, and turns the governance gate into a small, runnable check.

The Problem

A proposed assumption curve and a reference Treasury curve can look identical on a chart and still disagree by enough at a single tenor to move a long-duration reserve materially, because a two-basis-point error at the thirty-year node discounts decades of cash flow. The validation question is therefore not “does the curve look right?” but “does every node agree with source within a defensible tolerance, and if not, exactly which nodes fail?” A pass/fail on the whole curve hides the one tenor that breached; a real check reports the breach node, its size, and its direction so an actuary can decide whether it is a bootstrapping bug, a stale quote, or an interpolation artifact.

A Minimal Working Example

The check takes a proposed set of spot rates and a reference Treasury spot curve, aligns them on a common tenor grid, interpolates the reference where the grids differ, and flags every node whose absolute difference exceeds tolerance. It has no framework dependencies and runs as-is:

from __future__ import annotations

from dataclasses import dataclass
from datetime import date

import numpy as np


@dataclass(frozen=True)
class NodeBreach:
    tenor_years: float
    proposed_rate: float
    reference_rate: float
    diff_bps: float


def validate_against_treasury(
    proposed_tenors: np.ndarray,
    proposed_spots: np.ndarray,
    reference_tenors: np.ndarray,
    reference_spots: np.ndarray,
    proposed_asof: date,
    reference_asof: date,
    tolerance_bps: float = 2.0,
    max_stale_days: int = 3,
) -> list[NodeBreach]:
    """Flag every proposed spot node that breaches the reference curve."""
    # Guard 1: the two curves must describe the same market date.
    age = abs((proposed_asof - reference_asof).days)
    if age > max_stale_days:
        raise ValueError(
            f"reference curve is {age}d from the assumption date; re-source it"
        )

    # Guard 2: never extrapolate -- validate only where reference has support.
    lo, hi = reference_tenors.min(), reference_tenors.max()
    if proposed_tenors.min() < lo or proposed_tenors.max() > hi:
        raise ValueError("proposed tenors fall outside the reference curve span")

    # Interpolate the reference onto the proposed tenor grid, on log discount
    # factors rather than on rates, so implied forwards stay smooth.
    ref_dfs = (1.0 + reference_spots) ** (-reference_tenors)
    log_ref_at_proposed = np.interp(
        proposed_tenors, reference_tenors, np.log(ref_dfs)
    )
    ref_at_proposed = np.exp(log_ref_at_proposed) ** (-1.0 / proposed_tenors) - 1.0

    diff_bps = (proposed_spots - ref_at_proposed) * 1e4
    breaches = [
        NodeBreach(float(t), float(p), float(r), float(d))
        for t, p, r, d in zip(
            proposed_tenors, proposed_spots, ref_at_proposed, diff_bps
        )
        if abs(d) > tolerance_bps
    ]
    return breaches


if __name__ == "__main__":
    proposed_t = np.array([1.0, 2.0, 5.0, 10.0, 30.0])
    proposed_s = np.array([0.0481, 0.0454, 0.0429, 0.0426, 0.0451])
    ref_t = np.array([0.5, 1.0, 2.0, 3.0, 5.0, 7.0, 10.0, 20.0, 30.0])
    ref_s = np.array(
        [0.0495, 0.0480, 0.0455, 0.0442, 0.0428, 0.0424, 0.0421, 0.0438, 0.0447]
    )
    flags = validate_against_treasury(
        proposed_t, proposed_s, ref_t, ref_s,
        proposed_asof=date(2026, 6, 30), reference_asof=date(2026, 6, 30),
    )
    for b in flags:
        print(f"{b.tenor_years:>5}y  {b.diff_bps:+.1f} bps  breach")
Node-by-node validation of a proposed spot curve against a Treasury reference Left to right: a proposed spot curve and a reference Treasury curve both feed a tenor-grid alignment step that interpolates the reference onto the proposed nodes. That feeds a highlighted tolerance comparison, which splits into a flagged-breach node and a cleared node. Proposed spots assumption curve Reference curve Treasury spots Align + interpolate onto proposed grid Compare vs tolerance per-node diff in bps Flag breach tenor + size + sign Clear node within tolerance
Each proposed node is compared against the interpolated reference; breaches are flagged with their tenor, size, and sign.

How It Works, Block by Block

The staleness guard runs first. Before any arithmetic, the check confirms the reference curve’s as-of date is within max_stale_days of the assumption date. This catches the single most common validation defect — comparing today’s proposed curve against last week’s Treasury pull — before it can produce a falsely passing or falsely failing result. A gap beyond the ceiling raises rather than warns, because a stale reference makes the entire comparison meaningless.

The support guard forbids extrapolation. The reference curve is only trustworthy between its shortest and longest quoted tenors. If the proposed curve carries a node outside that span, the check refuses rather than extrapolating a reference value out of thin air, because an extrapolated reference is not an independent source — it is a guess dressed as one.

Interpolation happens on discount factors, not rates. The reference and proposed curves rarely share a tenor grid, so the reference must be re-expressed at the proposed nodes. The code converts reference spots to discount factors, interpolates linearly on their logarithm, then inverts back to a spot rate. Interpolating log discount factors keeps the implied forward rates smooth; interpolating the annualized rates directly would inject small forward-rate kinks between quoted tenors that show up as spurious breaches.

The comparison is per node, reported in basis points, and signed. The difference at each proposed tenor is scaled to basis points and tested against tolerance. Every breach is returned as a NodeBreach carrying the tenor, both rates, and the signed difference — not a bare boolean — so an actuary can immediately see whether the proposed curve sits above or below source and by how much. A positive difference means the assumption is discounting less aggressively than the market; a negative one means more. That sign is diagnostic: a consistent one-directional bias across tenors points to a systematic bootstrapping or day-count error, while a lone outlier points to a single mis-keyed quote.

Edge Cases and Production Hardening

Par-to-spot bootstrapping mismatch. If the proposed curve is expressed as spot rates but the reference was pulled as par yields, comparing them directly flags phantom breaches that grow with tenor, because par and spot diverge on any non-flat curve. Bootstrap the reference to spot before comparing:

def par_to_spot(par_tenors: np.ndarray, par_yields: np.ndarray) -> np.ndarray:
    """Bootstrap annual-pay par yields to zero-coupon spot rates."""
    spots = np.zeros_like(par_yields)
    for i, (t, c) in enumerate(zip(par_tenors, par_yields)):
        if i == 0:
            spots[i] = c  # first node: par == spot for a one-period bond
            continue
        coupon_pv = sum(
            c / (1.0 + spots[j]) ** par_tenors[j] for j in range(i)
        )
        spots[i] = ((1.0 + c) / (1.0 - coupon_pv)) ** (1.0 / t) - 1.0
    return spots

Run this on the reference par quotes first, then feed the resulting spots into the validator, so like is compared with like.

Stale curve dates that slip past a coarse guard. A three-day ceiling still admits a Friday reference against a Monday assumption across a holiday weekend where rates gapped. For high-sensitivity long-duration blocks, tighten max_stale_days to zero and require an exact settlement-date match, so any date mismatch forces a fresh pull rather than a tolerance judgement.

Tenor interpolation mismatch at sparse long ends. Treasury quotes thin out past twenty years, so a proposed thirty-year node may be validated against a reference interpolated across a wide twenty-to-thirty gap, inflating apparent error. Where the reference grid is sparse, widen the tolerance for that bucket or, better, source an additional long-tenor quote so the interpolation spans a shorter interval and the comparison stays meaningful.

Compliance Note

This node-level check is the concrete evidence behind the discounting basis of NAIC VM-20 Section 4, where the deterministic reserve is discounted at the net asset earned rate path built on a validated risk-free curve. ASOP No. 22, on the actuary’s reliance on data and assumptions, expects the source of the interest basis to be identified and its reasonableness assessed — a per-node reconciliation against an independent Treasury curve is exactly that assessment made mechanical. Retaining the breach report, the tolerance policy, and the reference settlement date turns “we checked the curve” into a reproducible artifact an examiner can re-run, and mirrors the same discipline applied when Constructing IFRS 17 Discount Curves in Python for the parallel IFRS 17 measurement basis.

Frequently Asked Questions

Why interpolate on discount factors instead of on rates?

Interpolating the logarithm of discount factors keeps the implied forward rates smooth between quoted tenors. Interpolating annualized rates directly introduces small forward-rate kinks that register as spurious breaches, so discount-factor interpolation avoids flagging errors that are artifacts of the method rather than the curve.

What tolerance is defensible for Treasury validation?

Around two basis points is a common default that absorbs quote rounding and day-count nuance without hiding a genuine bootstrapping error, but it should be bucketed by tenor: tighter at the short end where a small miss signals a real bug, and looser at the long end where quotes are sparse and noisier.

How does the check catch a stale reference curve?

The staleness guard compares the reference curve’s as-of date against the assumption date and raises if the gap exceeds the configured ceiling. This runs before any comparison arithmetic, so a curve pulled on the wrong date cannot produce a falsely passing or falsely failing result.

Should breaches be a single pass or fail flag?

No. Returning a per-node breach record with the tenor, both rates, and the signed difference is far more useful than a whole-curve boolean, because the pattern of breaches is diagnostic: a one-directional bias across tenors points to a systematic error, while a lone outlier points to a single mis-keyed quote.

Up a level: Interest Rate Assumption Governance · Assumption Validation & Rule Engine Design

Regulatory references: NAIC Valuation Manual VM-20 (Requirements for Principle-Based Reserves for Life Products), Section 4; Actuarial Standards Board ASOP No. 22.