Automating CSM Roll-Forward in Python

This guide builds one focused, runnable Python class that computes a single period’s contractual service margin roll-forward for a group of contracts, taking opening margin, accretion rate, new-business margin, the future-service split of a fulfilment-cash-flow change, and a coverage-unit pair, and returning the closing margin with a full movement breakdown. It is the engineering companion to Contractual Service Margin Automation within the IFRS 17 & LDTI Filing Automation discipline.

The Problem

The IFRS 17 margin roll-forward reads as five short movements in paragraph 44, but coding it correctly hinges on three things the prose leaves implicit: the accretion must use the rate locked in at initial recognition and not the current curve, the margin has a hard floor at zero that spills into a loss component, and the release is a proportion of the balance only after every addition and adjustment has landed. A single class that enforces that ordering, refuses malformed coverage units, and hands back an itemised breakdown turns a fragile spreadsheet formula into a deterministic, testable unit — the smallest piece of automation that a reviewer can read end to end and trust.

A Minimal Working Example

The whole calculation fits in one class with no framework dependencies. It advances one group by one period and returns both the new balance and the movement lines an auditor will ask for.

from __future__ import annotations

from dataclasses import dataclass


@dataclass(frozen=True)
class RollForwardResult:
    closing_csm: float
    accretion: float
    csm_released: float
    loss_component: float
    onerous: bool


class CSMPeriod:
    """Compute one IFRS 17 period CSM roll-forward for a single cohort."""

    def __init__(self, cohort_id: str, locked_in_rate: float) -> None:
        if locked_in_rate < -1.0:
            raise ValueError("locked_in_rate below -100% is not economic")
        self.cohort_id = cohort_id
        self.locked_in_rate = locked_in_rate  # frozen at initial recognition, B72(b)

    def advance(
        self,
        opening_csm: float,
        new_business_csm: float,
        fcf_change_future_service: float,
        coverage_units_current: float,
        coverage_units_future: float,
        opening_loss_component: float = 0.0,
    ) -> RollForwardResult:
        if coverage_units_current < 0.0 or coverage_units_future < 0.0:
            raise ValueError("coverage units cannot be negative")

        # 1. Interest accretion at the locked-in rate (para 44(b)).
        accretion = opening_csm * self.locked_in_rate

        # 2. New contracts recognised this period (para 44(a)).
        balance = opening_csm + accretion + new_business_csm

        # 3. Future-service FCF change adjusts the CSM, floored at zero (para 44(c), B96).
        balance += fcf_change_future_service
        loss_component = opening_loss_component
        if balance < 0.0:
            loss_component += -balance   # spill the shortfall into the loss component
            balance = 0.0

        # 4. Release on coverage units provided this period (para 44(e), B119).
        total_units = coverage_units_current + coverage_units_future
        fraction = coverage_units_current / total_units if total_units > 0.0 else 1.0
        csm_released = balance * fraction

        # 5. Closing CSM is the residual.
        closing_csm = balance - csm_released
        return RollForwardResult(
            closing_csm=closing_csm,
            accretion=accretion,
            csm_released=csm_released,
            loss_component=loss_component,
            onerous=loss_component > 0.0,
        )


if __name__ == "__main__":
    period = CSMPeriod(cohort_id="2025:US:profitable", locked_in_rate=0.032)
    result = period.advance(
        opening_csm=2_400_000.0,
        new_business_csm=180_000.0,
        fcf_change_future_service=-60_000.0,
        coverage_units_current=1_250.0,
        coverage_units_future=11_800.0,
    )
    print(result)
Internal flow of the advance method from inputs to closing margin Four boxes left to right: recorded inputs, accrete and add new business, apply future-service change and floor at zero, allocate coverage-unit release, ending at the highlighted closing margin and breakdown box. Recorded inputs opening, rate, units Accrete + add new business locked-in rate Apply FCF, floor at zero loss component Allocate release coverage units Closing + breakdown
The advance method applies each paragraph-44 movement in a fixed, non-commutative order.

How It Works, Block by Block

The constructor freezes the rate. CSMPeriod takes the locked-in rate once, at construction, and never accepts a current-curve rate afterwards. This is a design decision, not a convenience: paragraph B72(b) fixes the accretion rate at initial recognition, so binding it to the object and keeping it out of the advance signature makes it structurally impossible to slip a current rate into the interest step. The guard against a rate below negative one hundred percent catches an obviously non-economic input before it silently produces a nonsensical balance.

advance validates coverage units first. Before any arithmetic, the method rejects negative coverage units. A negative unit count has no meaning — coverage provided cannot be less than nothing — and letting one through would invert the release fraction and hand revenue back to the margin. Failing fast here converts a data-quality problem upstream into a loud exception rather than a quietly wrong reserve.

Accretion and new business come before any adjustment. The interest is computed on the opening balance alone, then new-business margin is added. The ordering matters because accreting after adding new business would earn a full period of interest on contracts that were only recognised partway through, overstating the unwind.

The floor spills into a loss component. After the future-service fulfilment-cash-flow change is applied, a negative balance is clamped to zero and the shortfall is added to the loss component. This is where a profitable group tips into being onerous: the margin cannot absorb a loss, so the excess is carried separately and, in the full model, recognised immediately in profit or loss. The class surfaces this with an onerous flag so the caller never has to infer it.

The release is a fraction of the post-adjustment balance. Only once the balance is settled does the method allocate the release, using current coverage units over total remaining units. When total units are zero — a fully run-off group in its final step — the fraction defaults to one so the entire remaining margin is released rather than dividing by zero. The coverage-unit inputs themselves come from Calculating IFRS 17 Coverage Units in Python, which quantifies the benefit-weighted service in each period.

Edge Cases and Production Hardening

An onerous group must not resurrect a positive margin. If a group is already carrying a loss component and a later favourable change arrives, the naive fix of simply adding it back to the margin double-counts: the reversal of a loss component is recognised in profit or loss, and only the residual re-establishes a margin. Handle the reversal explicitly before rebuilding the balance.

def apply_favourable_change(balance: float, loss_component: float,
                            favourable: float) -> tuple[float, float]:
    """Reverse an existing loss component first, then rebuild the CSM."""
    reversal = min(loss_component, favourable)     # P&L reversal, not CSM
    remaining = favourable - reversal
    return balance + remaining, loss_component - reversal

Negative or zero coverage units at run-off. A group in its terminal period may report zero future units, and a buggy feed can emit a negative current-unit figure. The constructor-side validation rejects negatives outright, but the zero-total case needs a deliberate policy: the example releases the whole balance rather than raising, because a group with no remaining service has, by definition, provided all of it. Document that choice so it is not mistaken for a divide-by-zero slip.

Locked-in versus current rate leakage. The most damaging production defect is a caller that reaches for the current discount curve to accrete the margin. Because the rate is frozen in the constructor, the only way this happens is constructing a fresh CSMPeriod each period with the wrong rate. Guard against it by sourcing the locked-in rate from the recorded initial-recognition value — see Constructing IFRS 17 Discount Curves in Python for how the locked-in and current curves are kept as separate objects so one cannot be substituted for the other.

Compliance Note

The class is a mechanical expression of IFRS 17 paragraph 44 and its application guidance at B96 to B119: the accretion follows B72(b), the future-service adjustment follows B96, and the coverage-unit release follows B119. Because the movement breakdown returned by advance itemises accretion, release, and loss component separately, it maps directly onto the reconciliation of the contractual service margin that paragraph 101 requires an insurer to disclose. Keeping the roll-forward as a pure, deterministic method is also what lets the appointed actuary demonstrate, under the model-documentation expectations that govern the wider filing, that the same recorded inputs always reproduce the same closing balance.

Frequently Asked Questions

Should the accretion rate be passed to the advance method or fixed on the object?

Fix it on the object. Binding the locked-in rate in the constructor and keeping it out of the per-period call makes it structurally impossible to accrete the margin at the current curve, which is exactly the mistake paragraph B72(b) is meant to prevent.

How does the class handle a group that becomes onerous mid-period?

When the future-service change drives the balance below zero, the class clamps the margin to zero and adds the shortfall to the loss component, then sets the onerous flag. The caller reads that flag rather than inferring onerousness from a zero balance, which could otherwise be confused with a fully released group.

What happens if total coverage units are zero?

The release fraction defaults to one, so the entire remaining margin is released in that period. This is the correct treatment for a group in run-off with no remaining service, and it avoids a divide-by-zero; the behaviour is deliberate and should be documented as policy rather than left implicit.

Where do the coverage-unit inputs come from?

They are produced separately by the coverage-unit calculation, which weights each period’s expected benefits and duration. The roll-forward consumes the current-period and remaining-future unit counts and does not itself decide how service is quantified.

Up a level: Contractual Service Margin Automation · IFRS 17 & LDTI Filing Automation


Regulatory references: IFRS Foundation, IFRS 17 Insurance Contracts, paragraph 44, paragraph 101, and application guidance B72, B96, B119.