Economic Scenario Mapping & Yield Curve Alignment

Every discounted liability an insurer reports is only as defensible as the interest rate curve behind it, yet the curves that arrive from central banks, market data vendors, and internal economic scenario generators rarely share a tenor grid, a compounding convention, or an extrapolation rule. This is the reconciliation problem this guide solves: how to ingest heterogeneous external rate data, align it to the internal projection grid without introducing arbitrage, and hand a validated, versioned curve to the reserve engine with an audit trail an examiner can reproduce. The regulatory mandate is explicit — NAIC VM-20 Section 7.G prescribes the interest rate scenarios that drive the deterministic and stochastic reserves, IFRS 17 paragraphs B78–B85 require a documented discount-rate construction that reflects the liquidity and timing of insurance cash flows, and Solvency II binds valuation to the EIOPA risk-free term structure with a fixed Last Liquid Point and Ultimate Forward Rate. A curve that silently mis-extrapolates beyond the last liquid tenor does not raise an exception; it surfaces quarters later as a restated present value. Alignment, therefore, is a validation control, not a data-loading convenience.

Deterministic yield-curve alignment and fallback pipeline External rate curves flow left to right through schema validation of tenor, currency and basis, then bootstrapping and monotone interpolation, into a single decision gate that asks whether the curve is available. On yes, the curve becomes an aligned scenario grid guarded by a no-arbitrage gate and is reconciled into the statutory filing. On missing, a three-tier deterministic fallback ladder escalates from reusing the last validated curve, to a regulator-accepted proxy curve plus fixed spread, to a hard-stop exception. yes missing External rate curves Schema validation tenor · currency · basis Bootstrap · interpolate monotone spline Curve available? Aligned scenario grid no-arbitrage gate Statutory filing reconciliation · fingerprint Tier 1 last validated curve Tier 2 proxy curve + spread Tier 3 hard-stop exception
The decision gate is the only branch point: an available curve is aligned and reconciled into the filing, while a missing feed escalates through a deterministic, logged three-tier fallback rather than improvising.

The alignment problem in actuarial terms

A yield curve alignment failure is a valuation failure. Reference rates publish at discrete, illiquid tenors — typically 1M, 3M, 6M, 1Y, 2Y, 5Y, 10Y, and 30Y — while a cash flow projection demands a continuous discount function evaluated at every projection month out to the run-off of the longest-dated policy. The engine must bridge that gap while preserving three invariants. First, no arbitrage: the discount function P(0,t)P(0,t) must be strictly positive and monotonically non-increasing, so implied forward rates never go negative in a way the market does not support. Second, regulatory extrapolation discipline: beyond the last liquid point, most frameworks forbid naive linear extension and require convergence to a prescribed Ultimate Forward Rate. Third, reproducibility: the same input curve, interpolation method, and parameters must produce a bitwise-identical grid on every run, because that is the property an examiner relies on to reconcile a filed reserve.

These requirements sit downstream of the broader Assumption Validation & Rule Engine Design discipline — the interest rate assumption is validated by the same deterministic control plane that vets mortality and lapse — and consume the clean, typed inputs produced by Actuarial Model Ingestion & Testing Workflows. The economic curve is also the axis every other assumption pivots on: interest environments drive policyholder behavior, so a validated curve feeds directly into the Policy Lapse & Surrender Assumption Engines, and it anchors the forward-looking stress tests applied in Mortality & Morbidity Rate Validation.

Prerequisites

Before implementing the alignment engine, establish the following:

  • Python packages. pydantic (v2) for the ingestion contract, numpy for vectorized curve math, scipy.interpolate and scipy.optimize for spline fitting and Smith-Wilson calibration, and pandas for tenor-indexed handling. A hashing primitive from the standard-library hashlib fingerprints each accepted curve.
  • Actuarial data contracts. Every incoming curve must carry immutable metadata: curve_id, valuation_date, currency, source authority, compounding convention (continuous vs. annual), day-count basis, and the interpolation method to be applied. Rates without a stated basis cannot be validated and must be rejected at the boundary.
  • Regulatory context. Read the interest rate provisions you are filing under — VM-20 Section 7.G for US statutory reserves, the NAIC VM-20 Compliance Frameworks mapping for how the curve ties to the reserve, and IFRS 17 B78–B85 for the discount-rate disclosure — before deciding an extrapolation rule.
  • Sibling context. The scenario grid produced here is often perturbed across economic paths by Stochastic Scenario Generation Frameworks; the aligned base curve is the deterministic centre of that fan. Schema mechanics are covered in depth in Schema Validation with Pydantic & Great Expectations.

Core implementation: a validated, aligned curve

The canonical pattern enforces the contract at ingestion, bootstraps spot rates, extends to a continuous discount function under a regulator-aware extrapolation rule, and refuses to emit a grid that violates no-arbitrage. The discount factor under continuous compounding is P(0,t)=ez(t)tP(0,t) = e^{-z(t)\,t}, and the instantaneous forward is f(t)=tlnP(0,t)f(t) = -\frac{\partial}{\partial t}\ln P(0,t); the validation gate asserts PP is positive and non-increasing so ff stays admissible.

from __future__ import annotations

import hashlib
import json
from datetime import date
from typing import Literal

import numpy as np
from pydantic import BaseModel, Field, field_validator, model_validator
from scipy.interpolate import PchipInterpolator


class YieldCurve(BaseModel):
    """Immutable, self-describing curve accepted at the ingestion boundary."""

    curve_id: str
    valuation_date: date
    currency: Literal["USD", "CAD", "EUR", "GBP"]
    compounding: Literal["continuous", "annual"]
    source_authority: str
    tenors_years: list[float] = Field(min_length=2)
    spot_rates: list[float]
    last_liquid_point: float          # LLP in years, e.g. 30.0 for USD
    ultimate_forward_rate: float      # UFR as a continuous rate, e.g. 0.036

    @field_validator("tenors_years")
    @classmethod
    def tenors_increasing(cls, v: list[float]) -> list[float]:
        if any(b <= a for a, b in zip(v, v[1:])):
            raise ValueError("tenors_years must be strictly increasing")
        return v

    @model_validator(mode="after")
    def dimensions_and_positivity(self) -> "YieldCurve":
        if len(self.tenors_years) != len(self.spot_rates):
            raise ValueError("tenors_years and spot_rates length mismatch")
        if self.tenors_years[-1] > self.last_liquid_point + 1e-9:
            raise ValueError("observed tenor extends past the last liquid point")
        return self

    def fingerprint(self) -> str:
        payload = json.dumps(self.model_dump(mode="json"), sort_keys=True)
        return hashlib.sha256(payload.encode("utf-8")).hexdigest()


def align_to_grid(curve: YieldCurve, projection_months: int) -> dict:
    """Return a monthly discount function aligned to the projection horizon."""
    tenors = np.asarray(curve.tenors_years, dtype=np.float64)
    spots = np.asarray(curve.spot_rates, dtype=np.float64)

    # Convert annual-compounded input to continuous zero rates for a single basis.
    if curve.compounding == "annual":
        spots = np.log1p(spots)

    grid = np.arange(1, projection_months + 1, dtype=np.float64) / 12.0
    llp, ufr = curve.last_liquid_point, curve.ultimate_forward_rate

    # Interpolate inside the liquid range with a shape-preserving monotone spline.
    liquid = grid <= llp
    interp = PchipInterpolator(tenors, spots, extrapolate=False)
    zero_rates = np.empty_like(grid)
    zero_rates[liquid] = interp(grid[liquid])

    # Extrapolate beyond the LLP: converge the forward smoothly toward the UFR.
    if (~liquid).any():
        z_llp = float(interp(llp))
        alpha = 0.10  # convergence speed toward the UFR
        t_ext = grid[~liquid]
        decay = np.exp(-alpha * (t_ext - llp))
        zero_rates[~liquid] = ufr + (z_llp - ufr) * decay

    discount = np.exp(-zero_rates * grid)

    # No-arbitrage gate: discount function must be positive and non-increasing.
    if not np.all(discount > 0.0):
        raise ValueError("arbitrage: non-positive discount factor produced")
    if not np.all(np.diff(discount) <= 1e-12):
        raise ValueError("arbitrage: discount function is not monotone")

    return {
        "curve_id": curve.curve_id,
        "valuation_date": curve.valuation_date.isoformat(),
        "fingerprint": curve.fingerprint(),
        "grid_years": grid,
        "zero_rates": zero_rates,
        "discount_factors": discount,
    }

The YieldCurve model is the enforcement boundary. It rejects a non-monotone tenor vector, a length mismatch between tenors and rates, and any observed point that claims to sit past the declared last liquid point — three of the most common vendor-feed corruptions. Normalising every curve to a single continuous basis before interpolation eliminates the silent unit mismatch where an annually compounded 30Y rate is discounted as if it were continuous. The fingerprint method turns the accepted curve into a SHA-256 hash that is later written into the filing package, so the exact curve behind a reserve is provable rather than asserted.

Configuration and tuning

Alignment behaviour is governed by a small set of parameters that should live in version-controlled configuration, never hard-coded in the engine. The values below are illustrative starting points; the correct numbers are dictated by the currency and the framework you file under.

ALIGNMENT_CONFIG = {
    "USD": {
        "last_liquid_point": 30.0,      # years; longest liquid Treasury tenor
        "ultimate_forward_rate": 0.036, # continuous UFR
        "ufr_convergence_alpha": 0.10,  # higher = faster convergence past LLP
        "drift_tolerance_bp": 15.0,     # bp deviation vs. prior curve before recalibration
        "min_liquid_tenors": 6,         # reject curves with too few observation points
    },
    "EUR": {
        "last_liquid_point": 20.0,      # EIOPA LLP for the euro
        "ultimate_forward_rate": 0.033,
        "ufr_convergence_alpha": 0.10,
        "drift_tolerance_bp": 12.0,
        "min_liquid_tenors": 6,
    },
}

Two parameters deserve care. The UFR convergence speed (ufr_convergence_alpha) controls how quickly the extrapolated forward pulls toward the ultimate rate beyond the last liquid point; a value that is too aggressive suppresses genuine long-end signal, while one that is too slow leaves the tail rate-sensitive in a way the regulator’s methodology does not intend. The drift tolerance (drift_tolerance_bp) is the guardrail against silent curve movement: when a newly ingested curve deviates from the last validated curve by more than the configured basis-point band at any liquid tenor, the engine must route to a recalibration and review path rather than promote the curve automatically. That tolerance logic is the same adaptive control described in Dynamic Threshold Tuning for Assumption Drift, applied here to the interest rate axis.

Step-by-step walkthrough

  1. Enforce the contract. Parse the raw vendor payload into the YieldCurve model. A malformed curve raises a ValidationError here and never reaches the reserve engine.
  2. Fingerprint the input. Call fingerprint() and record the hash before any transformation, so the accepted raw curve is provable independent of the aligned output.
  3. Normalise the basis. Convert annual-compounded rates to continuous zeros so interpolation and discounting operate on one consistent convention.
  4. Interpolate the liquid range. Fit a shape-preserving monotone spline (PchipInterpolator) across the observed tenors up to the last liquid point; this preserves local convexity without the overshoot that plagues unconstrained cubic splines.
  5. Extrapolate under the regulatory rule. Beyond the last liquid point, converge the forward toward the Ultimate Forward Rate at the configured speed instead of extending the last observed slope.
  6. Assert no-arbitrage. Verify the discount function is strictly positive and non-increasing; a breach halts promotion of the curve.
  7. Check drift and promote. Compare the new curve against the last validated one at every liquid tenor; within tolerance, promote and version it, otherwise route to review.
  8. Reconcile into the filing. Map the aligned grid and its fingerprint to the statutory template so the discounted-cash-flow present value ties back to a specific, reproducible curve.

Validation and testing

Correctness here is proven, not assumed, and the tests below are the evidence a model validation report cites.

import numpy as np


def test_alignment_is_reproducible(sample_curve):
    a = align_to_grid(sample_curve, projection_months=600)
    b = align_to_grid(sample_curve, projection_months=600)
    # Bitwise-identical output is the property examiners reconcile against.
    assert np.array_equal(a["discount_factors"], b["discount_factors"])
    assert a["fingerprint"] == b["fingerprint"]


def test_discount_function_is_arbitrage_free(sample_curve):
    result = align_to_grid(sample_curve, projection_months=600)
    df = result["discount_factors"]
    assert np.all(df > 0.0)
    assert np.all(np.diff(df) <= 1e-12)  # monotone non-increasing


def test_liquid_knots_are_reproduced(sample_curve):
    result = align_to_grid(sample_curve, projection_months=600)
    grid, zeros = result["grid_years"], result["zero_rates"]
    for tenor, spot in zip(sample_curve.tenors_years, sample_curve.spot_rates):
        idx = int(np.argmin(np.abs(grid - tenor)))
        # The aligned curve must pass through its own observed points.
        assert abs(zeros[idx] - np.log1p(spot)) < 5e-4

Beyond unit tests, wrap the aligned grid in a data-quality checkpoint: assert no NaN discount factors (an assumption vector shorter than the horizon can broadcast a NaN that poisons every downstream month), assert the present value of a reference cash flow set under the aligned curve matches the prior period within a stated tolerance band, and assert the recorded fingerprint matches the curve stored in the audit log. Any breach quarantines the curve rather than filing it. Persisting those results — the fingerprint, the parameters, and the pass/fail record — into an append-only store is the interface to the Actuarial Audit Trail Architecture and the wider Regulatory Architecture & Compliance Mapping discipline.

Failure modes and gotchas

  • Linear extrapolation past the last liquid point. Extending the last observed slope beyond the liquid range is the single most common finding. It produces a long-end rate the regulator’s methodology does not sanction and can make a 30-year liability swing materially. Always converge to the prescribed Ultimate Forward Rate.
  • Mixed compounding and day-count bases. Discounting an annually compounded rate as if it were continuous, or blending ACT/360 and ACT/365 quotes, introduces a small but systematic present-value bias that no exception ever flags. Normalise to one basis at ingestion.
  • Non-monotone splines. Unconstrained cubic interpolation can overshoot between sparse knots and produce a non-monotone discount function — a silent negative-forward arbitrage. A shape-preserving monotone spline plus the explicit no-arbitrage gate closes this.
  • Non-deterministic fingerprints. If the payload is serialised with unsorted keys or a per-session hash, two identical curves fingerprint differently and the audit trail breaks. Canonicalise with sorted-key JSON and use hashlib.sha256, never Python’s salted built-in hash().
  • Silent gap-filling on missing curves. When a vendor feed is late, the pipeline must not improvise. Activate a deterministic fallback: Tier 1 reuses the last validated curve with a time-decay adjustment, Tier 2 applies a regulator-accepted proxy curve plus a fixed spread, and Tier 3 raises a hard-stop exception with an automated compliance notification. Every fallback activation is logged with a reason code, timestamp, and quantified impact.

Up a level: Assumption Validation & Rule Engine Design — the control plane that vets every mortality, lapse, and interest assumption.

For the authoritative discount-rate requirements behind this page, consult the IFRS 17 Insurance Contracts standard and NAIC Valuation Manual VM-20 Section 7.