Aligning Yield Curves to LICAT Prescribed Scenarios
Aligning a yield curve to the OSFI LICAT prescribed scenarios means taking your own month-end risk-free curve and mechanically transforming it into the exact set of shocked interest-rate paths the capital formula expects, with every tenor reconciled to the prescribed grid and every scenario indexed so the capital engine consumes them without guessing. This page shows the focused transform — from a base par curve to a floored, prescribed scenario set — and sits inside the wider Economic Scenario Mapping & Yield Curve Alignment discipline within Assumption Validation & Rule Engine Design.
The Problem
OSFI’s Life Insurance Capital Adequacy Test computes an interest-rate risk requirement by revaluing liabilities under prescribed movements of the risk-free curve, not under whatever curve an actuary happens to hold. The trouble is that the base curve you build for valuation almost never lands on the tenors, the shock convention, or the floor treatment the capital calculation assumes. Your curve might be quoted at 3-month, 2-year, 7-year and 30-year points; the prescribed scenario set expects a dense, standardized tenor grid with an up-shock and a down-shock applied term by term, then a negative-rate floor enforced after the shock rather than before. If the alignment is done by hand, or by a spreadsheet that silently linearly interpolates where it should not, the interest-rate component of the capital requirement is computed on a curve that does not match the prescribed one — and that is a reconciliation finding waiting to happen. The technique below turns the alignment into a single deterministic function that interpolates to the prescribed tenors, applies the prescribed shock term structure, floors the result, and emits a scenario set indexed the way the capital engine reads it.
A Minimal Working Example
The whole pattern is: interpolate the base curve onto the prescribed tenor grid, apply each prescribed scenario’s term-dependent shock, floor the shocked rates, and return a scenario set keyed by a stable scenario identifier. It has no framework dependencies beyond NumPy and runs as-is:
from __future__ import annotations
import numpy as np
# The prescribed tenor grid (years) the LICAT interest-rate calc reads.
PRESCRIBED_TENORS = np.array([0.25, 0.5, 1, 2, 3, 5, 7, 10, 20, 30], dtype=float)
# Prescribed negative-rate floor applied AFTER the shock, in decimal.
RATE_FLOOR = -0.0010
# Prescribed shock term structure (basis points) per scenario. Short and
# long ends move less than the belly, mirroring the prescribed shape.
SHOCK_BPS = {
"base": np.zeros_like(PRESCRIBED_TENORS),
"up": np.array([90, 100, 120, 150, 160, 150, 140, 130, 110, 100]),
"down": np.array([-70, -80, -100, -130, -140, -130, -120, -110, -90, -80]),
}
def interpolate_to_prescribed(
base_tenors: np.ndarray, base_rates: np.ndarray
) -> np.ndarray:
"""Log-linear interpolation of the base curve onto the prescribed grid."""
order = np.argsort(base_tenors)
t, r = base_tenors[order], base_rates[order]
# Interpolate on continuously compounded (log-discount) space for stability.
log_disc = -r * t
interp_log_disc = np.interp(PRESCRIBED_TENORS, t, log_disc)
return -interp_log_disc / PRESCRIBED_TENORS
def build_prescribed_scenarios(
base_tenors: np.ndarray, base_rates: np.ndarray
) -> dict[str, np.ndarray]:
aligned = interpolate_to_prescribed(base_tenors, base_rates)
scenarios: dict[str, np.ndarray] = {}
for scenario_id, shock in SHOCK_BPS.items():
shocked = aligned + shock / 10_000.0
scenarios[scenario_id] = np.maximum(shocked, RATE_FLOOR)
return scenarios
if __name__ == "__main__":
valuation_date = "2026-06-30"
base_tenors = np.array([0.25, 2.0, 7.0, 30.0])
base_rates = np.array([0.0385, 0.0402, 0.0431, 0.0448])
scenario_set = build_prescribed_scenarios(base_tenors, base_rates)
for scenario_id in ("base", "up", "down"):
rates = scenario_set[scenario_id]
print(scenario_id, np.round(rates * 100, 3))
How It Works, Block by Block
PRESCRIBED_TENORS and SHOCK_BPS are the prescribed contract, not free parameters. The tenor grid and the term structure of the shocks are fixed by the capital instructions, so encoding them as module constants — rather than passing them in per call — makes the alignment auditable. Anyone reviewing the pipeline can read the exact grid and the exact basis-point movement applied at each tenor, and a change to the prescribed shocks is a single, version-controlled edit rather than a scattered set of magic numbers. The up and down shocks are stored as full vectors, one entry per prescribed tenor, because the prescribed movement is not a flat parallel shift: the belly of the curve moves more than the short and long ends, and a scalar shock would misstate the interest-rate requirement.
interpolate_to_prescribed interpolates in log-discount space, not on raw rates. Linearly interpolating zero rates across wide tenor gaps introduces small but systematic pricing errors, because the quantity that prices a cash flow is the discount factor, not the rate. Converting each base rate to a continuously compounded log-discount factor, interpolating that linearly, and converting back keeps the implied discount factors monotone and internally consistent — which is what the capital revaluation actually consumes. Sorting the base tenors first means the function tolerates a curve handed in whatever order the valuation system emits it.
build_prescribed_scenarios applies the shock, then floors, then keys the result. The order matters: the negative-rate floor is applied after the shock, so a down-scenario that pushes a low short rate below the prescribed floor is clamped rather than allowed to run negative past the prescribed limit. Each scenario is stored under a stable string identifier — "base", "up", "down" — so the capital engine reads scenarios by name and never by positional index, which is exactly the indexing consistency the reconciliation depends on. The base scenario carries a zero shock vector so that it flows through the identical code path as the shocked scenarios and cannot diverge from them by construction.
Edge Cases and Production Hardening
Extrapolation beyond the quoted curve is silent and dangerous. np.interp clamps to the endpoint value outside the range of the base tenors, so if your quoted curve stops at 30 years but the prescribed grid asked for a 40-year point, you would silently receive a flat extension rather than a modelled long-end rate. Because the prescribed grid here ends at the longest quoted tenor this is safe, but the moment the prescribed grid extends past your data you must decide the extrapolation rule explicitly — a constant-forward extension is usually defensible — rather than accept the flat clamp. Guard it:
def assert_no_extrapolation(base_tenors: np.ndarray) -> None:
longest_base = float(np.max(base_tenors))
longest_grid = float(np.max(PRESCRIBED_TENORS))
if longest_grid > longest_base:
raise ValueError(
f"prescribed grid extends to {longest_grid}y beyond quoted "
f"{longest_base}y; define an explicit long-end extrapolation rule"
)
Flooring before the shock produces the wrong capital number. A tempting simplification is to floor the base curve once and reuse it, but flooring is scenario-dependent: only the down-shock can drive a rate through the floor, and applying the floor to the base curve would suppress rates that the up and base scenarios never touch. Keep the floor inside the per-scenario loop, after aligned + shock, so each scenario is floored on its own shocked rates and the base scenario is left unfloored where it belongs.
Scenario indexing must stay consistent across the whole capital run. If the alignment step emits scenarios keyed by name but a downstream revaluation reads them by list position, a reordering of SHOCK_BPS silently misassigns the up requirement to the down path. Consume the returned dictionary by explicit key everywhere, freeze the scenario identifiers, and treat any unrecognized identifier as a hard failure. This is the same discipline enforced in Interest Rate Assumption Governance, where the assumption set that feeds a curve is versioned so a scenario can be traced back to the inputs that produced it.
Compliance Note
The prescribed movements applied here are the interest-rate risk shocks of OSFI’s Life Insurance Capital Adequacy Test, which requires the interest-rate component of the capital requirement to be computed by revaluing liabilities under prescribed changes to the risk-free curve rather than under a company-selected path. Because those shocks feed a model whose output drives a regulatory capital number, the alignment function is itself in scope of OSFI Guideline E-23 on model risk management: it must be documented, independently reviewable, and reproducible, with the base curve, the interpolation rule, and the shock vectors all recorded so the capital figure can be reconstructed on demand. The same discounting mechanics recur when Constructing IFRS 17 Discount Curves in Python, where a base curve is transformed for a different regulatory purpose but under the same requirement that the transform be traceable end to end.
Frequently Asked Questions
Why interpolate the curve in log-discount space instead of on rates?
Because the quantity that prices a liability cash flow is the discount factor, not the rate, and linearly interpolating rates across wide tenor gaps distorts the implied discount factors. Interpolating the continuously compounded log-discount factor and converting back keeps the implied factors monotone and consistent with what the capital revaluation actually consumes.
Should the negative-rate floor be applied before or after the prescribed shock?
After. The floor is scenario-dependent, and only the down-shock can push a low short rate below the prescribed limit. Flooring the base curve first would suppress rates the base and up scenarios never touch, so the floor belongs inside the per-scenario loop on each scenario’s already-shocked rates.
Why are the shocks stored as a vector per tenor rather than a single parallel shift?
Because the LICAT prescribed interest-rate movement is not a flat parallel shift: the belly of the curve moves more than the short and long ends. A scalar shock would misstate the shape of the shocked curve and therefore the interest-rate component of the capital requirement, so each scenario carries a full term structure of basis-point moves.
How do I keep scenarios from being misassigned in the capital run?
Key every scenario by a stable string identifier and consume it by that key everywhere, never by list position. If a downstream step reads scenarios positionally, reordering the shock table silently swaps the up and down requirements, so freeze the identifiers and treat any unrecognized one as a hard failure.
Related
- Economic Scenario Mapping & Yield Curve Alignment — the reconciliation discipline this transform plugs into.
- Interest Rate Assumption Governance — versioning the assumption set behind each curve so a scenario is traceable.
- Constructing IFRS 17 Discount Curves in Python — the same discounting mechanics applied under a different regime.
- Assumption Validation & Rule Engine Design — the wider engineering discipline these curve transforms live within.
Up a level: Economic Scenario Mapping & Yield Curve Alignment · Assumption Validation & Rule Engine Design
Regulatory references: OSFI Life Insurance Capital Adequacy Test (LICAT), interest rate risk and OSFI Guideline E-23, Model Risk Management.