Calculating IFRS 17 Coverage Units in Python
Computing IFRS 17 coverage units in Python means turning a cohort’s benefit quantities and expected in-force pattern into a per-period vector and the exact fraction of the contractual service margin that becomes insurance revenue each period, under paragraph B119. This focused walkthrough sits inside Risk Adjustment & Coverage Units and the wider IFRS 17 & LDTI Filing Automation reference, and hands the release fractions straight to Automating CSM Roll-Forward in Python.
The Problem
Paragraph B119 says the contractual service margin is recognised in profit or loss by allocating it over the coverage period in proportion to coverage units, defined as the quantity of benefits provided multiplied by the expected coverage duration — but it does not hand you a formula, a discounting convention, or a rule for what happens when a cohort lapses faster than expected. The engineering task is to produce, from a cohort’s projected benefit amounts and expected in-force weights, a numerically stable per-period coverage-unit vector and a release fraction that recognises exactly the whole margin across run-off, honours the entity’s discounting policy choice, and degrades gracefully when the remaining coverage collapses to zero.
A Minimal Working Example
The function below takes a cohort’s benefit quantities and expected in-force weights, builds the coverage-unit vector, and returns the release fraction for each period as a NumPy array. It runs as-is and prints a fully worked release schedule for a five-year cohort.
from __future__ import annotations
import numpy as np
def coverage_unit_vector(
benefit_quantity: np.ndarray,
expected_in_force: np.ndarray,
annual_discount_rate: float | None = None,
) -> np.ndarray:
"""Per-period coverage units for a cohort under IFRS 17 B119.
Coverage units = quantity of benefit x expected coverage duration, where
duration enters through the expected in-force weight. Discounting is an
accounting-policy choice: pass a rate to discount units, omit it to leave
a unit of coverage equally weighted whenever it is provided.
"""
if benefit_quantity.shape != expected_in_force.shape:
raise ValueError("benefit_quantity and expected_in_force must align")
units = benefit_quantity * expected_in_force
if annual_discount_rate is not None:
periods = np.arange(units.size)
discount = (1.0 + annual_discount_rate) ** -periods
units = units * discount
return units
def csm_release_schedule(coverage_units: np.ndarray) -> np.ndarray:
"""Fraction of the opening CSM released in each period.
Each period's fraction is its coverage units over the units still to be
provided from that period onward. The fractions rise through run-off and
the final period always releases 1.0 of whatever balance remains.
"""
remaining = np.cumsum(coverage_units[::-1])[::-1] # units from t onward
fractions = np.zeros_like(coverage_units, dtype=float)
nonzero = remaining > 0.0
fractions[nonzero] = coverage_units[nonzero] / remaining[nonzero]
return fractions
if __name__ == "__main__":
# A five-year term cohort: level death benefit, decaying persistency.
benefit_quantity = np.array([250_000.0] * 5)
expected_in_force = np.array([1.00, 0.92, 0.85, 0.79, 0.74])
units = coverage_unit_vector(
benefit_quantity, expected_in_force, annual_discount_rate=0.035
)
fractions = csm_release_schedule(units)
opening_csm = 40_000.0
balance = opening_csm
for period, fraction in enumerate(fractions):
released = balance * fraction
balance -= released
print(f"period {period}: units={units[period]:>12.2f} "
f"fraction={fraction:6.4f} released={released:>9.2f} "
f"balance={balance:>9.2f}")
How It Works, Block by Block
coverage_unit_vector encodes B119 literally. Multiplying benefit_quantity by expected_in_force is the “quantity of benefits provided times expected coverage duration” of the standard, with duration entering through the in-force weight rather than a separate factor — a cohort still fully in force contributes its whole benefit, one that has lost a quarter of its policies contributes three-quarters. The shape guard is deliberate: a benefit vector and an in-force vector of different lengths is a data-alignment bug, not a number to silently broadcast, so it raises instead.
Discounting is opt-in, matching the policy choice. The annual_discount_rate parameter defaults to None, so the base behaviour is undiscounted units. Passing a rate applies per period, weighting earlier coverage more heavily. Making the discounting an explicit argument rather than an always-on step mirrors the accounting reality that whether to discount coverage units is a policy election under B119, and it keeps the undiscounted path free of any float noise from an exponentiation that should not have happened.
csm_release_schedule builds the denominator by reverse cumulative sum. The release fraction for period is that period’s units over all units still to be provided from onward, which is exactly np.cumsum(coverage_units[::-1])[::-1] — reverse the vector, accumulate, reverse back. This vectorised form computes the whole schedule in one pass and guarantees the fractions telescope: applied successively to an opening balance, they recognise the entire margin, with the final period’s denominator equal to its own units and therefore a fraction of 1.0.
Written out, the fraction released in period is
where is the benefit quantity, the expected in-force weight, and the discount term is present only under a discounting policy. The __main__ block threads these fractions through a live balance so the released amount and running balance are visible period by period — the same numbers the Automating CSM Roll-Forward in Python roll-forward consumes.
Edge Cases and Production Hardening
Zero remaining coverage units divide by zero. If a cohort’s in-force weights all reach zero — full lapse, or a projection that runs past the coverage period — the denominator for the tail periods is zero and a naive division returns nan, which then poisons the margin balance. The nonzero = remaining > 0.0 mask in the schedule handles this by leaving those fractions at zero, but a cohort that fully lapses mid-projection also strands any unreleased margin. The correct treatment is to recognise the whole remaining balance in the last period with positive coverage:
import numpy as np
def release_with_full_lapse_guard(coverage_units: np.ndarray) -> np.ndarray:
"""Release schedule that never strands CSM when coverage ends early."""
fractions = csm_release_schedule(coverage_units)
active = np.flatnonzero(coverage_units > 0.0)
if active.size:
fractions[active[-1]] = 1.0 # last active period clears the balance
return fractions
The discounting policy choice must be applied consistently. Discounting one cohort’s coverage units and not another’s, or switching the convention between valuations, changes the pattern of profit emergence even though the total margin recognised over run-off is unchanged. Pin the discounting flag and the rate basis as recorded configuration for the whole book, and reconcile the release pattern against the prior close so a flipped convention surfaces as a reconciliation break rather than a quiet restatement.
Lapse-driven decay must come from the in-force projection, not a shortcut. The expected in-force weight should be the actual persistency projection for the cohort — survival times the complement of lapse — not a flat linear taper. Understating lapse leaves too much coverage in later periods and defers margin; overstating it accelerates recognition. The persistency vector belongs to the assumption engine described in Policy Lapse & Surrender Assumption Engines, and the coverage-unit calculation should consume its output rather than approximate it.
Compliance Note
The coverage-unit allocation is the mechanism IFRS 17 paragraph B119 prescribes for recognising the contractual service margin as services are provided, and paragraph B119A clarifies that the quantity of benefits reflects the coverage the entity expects to provide, considering the likelihood of insured events only to the extent they affect that coverage. Because the release pattern directly determines reported insurance revenue, the coverage-unit basis, the benefit-quantity definition, and the discounting election are all judgements the entity must document and apply consistently, and an auditor will re-derive them from the same in-force projection. Keeping the calculation deterministic and version-pinned — the same cohort inputs always producing the same release schedule — is what lets that re-derivation reconcile, and it ties directly to the disclosure obligations set out in Risk Adjustment & Coverage Units.
Frequently Asked Questions
What exactly is a coverage unit under IFRS 17?
A coverage unit is the quantity of benefit a contract provides in a period multiplied by its expected coverage duration, defined in paragraph B119 as the basis for allocating the contractual service margin to profit. In code it is the benefit quantity for the period times the expected in-force weight, giving a single figure per period whose relative size sets how much unearned profit is recognised then.
How is the CSM release fraction computed from coverage units?
The fraction released in a period is that period’s coverage units divided by the total coverage units still to be provided from that period onward. Computing the denominator as a reverse cumulative sum makes the fractions telescope, so applying them successively to the opening balance recognises the entire margin and the final period releases whatever remains.
Do coverage units have to be discounted?
No. Discounting coverage units for the time value of money is an accounting-policy choice under B119 that must be applied consistently. Passing a discount rate weights earlier coverage more heavily and accelerates recognition; omitting it treats a unit of coverage as equally valuable whenever provided. Either way the choice must match the entity’s disclosures.
What happens when a cohort fully lapses before the end of the projection?
If every remaining in-force weight reaches zero, the plain release fractions for those periods are zero and any unreleased margin would be stranded. Guard against it by forcing the last period with positive coverage units to a fraction of 1.0, so the whole remaining balance is recognised in the final period the cohort actually provides coverage.
Related
- Risk Adjustment & Coverage Units — how coverage units and the risk adjustment together drive insurance revenue.
- Automating CSM Roll-Forward in Python — the roll-forward that applies these release fractions to the margin balance.
- Policy Lapse & Surrender Assumption Engines — the persistency projection that supplies the expected in-force weights.
- IFRS 17 & LDTI Filing Automation — the reference architecture this coverage-unit calculation lives within.
Up a level: Risk Adjustment & Coverage Units · IFRS 17 & LDTI Filing Automation
Regulatory references: IFRS 17 Insurance Contracts, paragraphs B119 and B119A (IFRS Foundation).