Constructing IFRS 17 Discount Curves in Python

Constructing an IFRS 17 discount curve in Python means turning a handful of observed risk-free rates into a full set of monthly discount factors — adding an illiquidity premium, interpolating onto the projection grid, and extrapolating the tail toward an ultimate forward rate — so a valuation engine can present-value liability cash flows reproducibly. This focused walkthrough supports IFRS 17 Discount Rate Unlocking within the wider IFRS 17 & LDTI Filing Automation architecture.

The Problem

Market data arrives as a sparse set of risk-free spot rates at standard tenors — say 1, 2, 3, 5, 10, 20, and 30 years — but an IFRS 17 measurement projects fulfilment cash flows on monthly nodes stretching decades past the last liquid quote. The bottom-up route of paragraph B80 requires that a liquidity premium be added to the risk-free basis to reflect the illiquidity of insurance liabilities, and the far end beyond observable market points must be extrapolated on a defensible, reproducible basis. Building this by hand in a spreadsheet is slow and impossible to audit; the goal is a small, deterministic function that takes dated inputs and returns a discount-factor vector aligned to the exact node set the projection uses.

A Minimal Working Example

The function below builds a bottom-up curve end to end: it interpolates the observed risk-free spots onto monthly nodes, adds an illiquidity premium, extrapolates beyond the last liquid tenor toward an ultimate forward rate, and returns discount factors. It depends only on NumPy and runs as-is.

from __future__ import annotations

import numpy as np


def build_ifrs17_curve(
    obs_tenor_years: np.ndarray,   # observed liquid tenors, e.g. [1, 2, 5, 10, 30]
    obs_spot_rates: np.ndarray,    # annual risk-free spot at each observed tenor
    illiquidity_premium: float,    # flat B80 add-on, e.g. 0.0035 for 35 bps
    projection_months: int,        # number of monthly nodes to build
    last_liquid_year: float,       # last credible market tenor
    ultimate_forward_rate: float,  # UFR the tail converges to
    convergence_years: float = 40.0,
) -> np.ndarray:
    """Return monthly discount factors for an IFRS 17 bottom-up curve."""
    nodes = np.arange(1, projection_months + 1)          # 1..T months
    node_years = nodes / 12.0

    # 1. Interpolate observed spot rates onto the monthly node grid.
    interpolated = np.interp(node_years, obs_tenor_years, obs_spot_rates)

    # 2. Extrapolate past the last liquid tenor toward the UFR.
    liquid = node_years <= last_liquid_year
    anchor = interpolated[liquid][-1]
    excess = node_years[~liquid] - last_liquid_year
    weight = np.minimum(excess / convergence_years, 1.0)
    interpolated[~liquid] = anchor + weight * (ultimate_forward_rate - anchor)

    # 3. Add the illiquidity premium to obtain the IFRS 17 spot curve.
    spot = interpolated + illiquidity_premium

    # 4. Convert annual spot rates to monthly discount factors.
    return (1.0 + spot) ** (-node_years)


if __name__ == "__main__":
    factors = build_ifrs17_curve(
        obs_tenor_years=np.array([1, 2, 5, 10, 20, 30], dtype=float),
        obs_spot_rates=np.array([0.041, 0.043, 0.045, 0.047, 0.048, 0.048]),
        illiquidity_premium=0.0035,
        projection_months=600,          # 50 years of monthly nodes
        last_liquid_year=30.0,
        ultimate_forward_rate=0.045,
    )
    print(factors[:12].round(6))        # first year of discount factors
Building an IFRS 17 bottom-up discount curve from sparse market data Left to right: observed risk-free rates feed an interpolation-to-monthly-nodes step, which adds an illiquidity premium, extrapolates the tail toward an ultimate forward rate, and finally converts the annual spot curve into monthly discount factors. Risk-free observed nodes Interpolate monthly nodes Add premium illiquidity B80 Extrapolate to UFR tail Discount factors v(t)
Sparse market quotes become a full monthly discount-factor vector in four deterministic steps.

How It Works, Block by Block

The node grid is defined first, before any rate is touched. np.arange(1, projection_months + 1) fixes the monthly tenors the whole function will work on, and node_years = nodes / 12.0 expresses them in years so every rate operation shares one axis. Anchoring on the node set up front is what lets the resulting factors align exactly to the projection’s cash-flow vector — mismatched grids are the single most common source of a wrong present value.

Interpolation fills the gaps between liquid quotes. np.interp(node_years, obs_tenor_years, obs_spot_rates) performs piecewise-linear interpolation of the observed spot rates onto every monthly node. Linear interpolation on spot rates is transparent and reproducible; a smoother spline is defensible but adds parameters that must themselves be justified in the disclosures, so a linear baseline is a sound default.

Extrapolation governs the far end. Everything past last_liquid_year is not observed, so the function converges the extrapolated spot from the last credible market rate toward the ultimate_forward_rate. The convergence weight = min(excess / convergence_years, 1.0) rises linearly from zero at the last liquid point to one after convergence_years, so the curve grades smoothly into the UFR rather than jumping. Because the tail dominates the present value of long-duration annuity cash flows, this block, not the observed section, decides most of the liability.

The illiquidity premium turns a risk-free curve into an IFRS 17 curve. Adding illiquidity_premium to every node implements the bottom-up construction of paragraph B80. The present value of a fulfilment cash-flow vector c\mathbf{c} then follows directly from the returned factors:

PV  =  t=1Tct(1+st)t/12,st=rt+π,\text{PV} \;=\; \sum_{t=1}^{T} c_t \,(1 + s_t)^{-t/12}, \qquad s_t = r_t + \pi,

where rtr_t is the interpolated risk-free spot, π\pi is the illiquidity premium, and the exponent carries the monthly node into years. The final line returns exactly those factors so the caller can dot them against any cash-flow vector.

Edge Cases and Production Hardening

Extrapolation beyond the last liquid point must be a filed parameter, not a default. The tail of the curve is judgement, and two engineers using different convergence_years or a different UFR will produce materially different liabilities for a 40-year annuity. Persist the extrapolation triple — last liquid tenor, ultimate forward rate, and convergence period — alongside the curve, and refuse to build a curve without them:

def validate_extrapolation(last_liquid_year, ultimate_forward_rate, convergence_years):
    if convergence_years <= 0:
        raise ValueError("convergence period must be positive")
    if not 0.0 <= ultimate_forward_rate <= 0.10:
        raise ValueError("UFR outside a plausible 0-10% band; check the input")
    if last_liquid_year <= 0:
        raise ValueError("last liquid tenor must be positive")

Negative rates break the naive discount-factor formula only if you assume positivity. In a low- or negative-rate environment, obs_spot_rates can legitimately be below zero, and (1 + s_t) ** (-t/12) handles that correctly as long as 1 + s_t stays positive — a spot of −0.5% gives a discount factor above one, which is economically right. The failure appears only if a downstream check hard-codes an assumption that factors are below one; audit those assertions and allow factors greater than one when rates are negative.

Node misalignment silently corrupts the present value. If the projection emits cash flows on a different grid than the curve — annual versus monthly, or offset by half a period — np.dot(cash_flows, factors) still returns a number, just the wrong one. Guard the boundary by asserting the lengths and the node convention agree before discounting, and build the curve from the projection’s own node count rather than a hard-coded horizon so the two can never drift apart.

Compliance Note

Building the curve this way is the mechanical basis for the discount-rate requirements of IFRS 17 paragraph 36 and the bottom-up guidance of paragraphs B80 and B78, which call for a rate that reflects the time value of money and the liquidity characteristics of the contracts. Recording the illiquidity premium, the interpolation method, and the extrapolation parameters as versioned inputs is what makes the curve reproducible under audit, and it is the same discipline the general model needs to keep the locked-in and current curves reconcilable, as set out in IFRS 17 Discount Rate Unlocking. The curve produced here feeds both the balance-sheet remeasurement and the CSM roll-forward in Python, while the risk-free inputs themselves are reconciled through Economic Scenario Mapping & Yield Curve Alignment.

Frequently Asked Questions

How do you interpolate a discount curve onto monthly nodes in Python?

Define the monthly node grid in years and call numpy.interp with the observed tenors and spot rates. This performs piecewise-linear interpolation of the spot rate at every monthly node, giving one rate per projection period that aligns exactly with the cash-flow vector the valuation discounts.

What is the illiquidity premium in an IFRS 17 bottom-up curve?

It is a spread added to the risk-free spot rate to reflect that insurance liabilities are far less liquid than traded bonds, following paragraph B80. Adding it to every node converts a plain risk-free curve into the discount curve IFRS 17 requires under the bottom-up approach; the premium and its calibration should be recorded as inputs.

How should the curve be extrapolated beyond the last liquid market point?

Converge the extrapolated spot rate from the last credible market rate toward an ultimate forward rate over a fixed convergence period, so the tail grades smoothly rather than jumping. Because long-duration cash flows are dominated by this tail, the last liquid tenor, the ultimate forward rate, and the convergence period must be stored as versioned, filed parameters.

Do negative interest rates break the discount factor calculation?

No, as long as one plus the spot rate stays positive. A negative spot simply produces a discount factor above one, which is economically correct. Problems arise only if a downstream check hard-codes an assumption that discount factors are always below one, so those assertions should allow factors greater than one.

Up a level: IFRS 17 Discount Rate Unlocking · IFRS 17 & LDTI Filing Automation


Regulatory references: IFRS 17 Insurance Contracts, paragraphs 36 and B78–B85 (discount rates, bottom-up and top-down).