Skip to content

Increment Tracking

The Problem: Where Did the Money Go?

IFRS 17 requires analysis of change — breaking down the movement in the contractual service margin, risk adjustment, and best estimate liability into its component drivers. Profit testing requires understanding which charges generate margin and which are breakeven. Model validation requires reconciling the account value at each step.

Without increment tracking, the only way to isolate the impact of a single assumption is to run the model twice — once with the assumption and once without — and take the difference. For a rollforward with 5 steps, that's 6 model runs to get a complete decomposition.

Single-Run Decomposition

Enable track_increments=True and every labeled step records its dollar impact at each timestep:

af.av = (
    af.projection.rollforward(initial=af.av_init, track_increments=True)
    .add(af.premium, "Premium")
    .deduct_nar(af.coi_rate, death_benefit=af.sum_assured, label="COI")
    .charge(af.admin_rate, "Admin")
    .grow(af.interest_rate, "Interest")
    .floor(0)
)

# Extract per-step dollar amounts
af.coi_amount = af.av.increments["COI"]
af.interest_credited = af.av.increments["Interest"]
af.admin_charged = af.av.increments["Admin"]
af.premium_added = af.av.increments["Premium"]

result = af.collect()

Each increment is the dollar change caused by that step:

  • Premium increment = +100 (added to AV)
  • COI increment = -3.90 (deducted from AV)
  • Admin increment = -10.96 (percentage charge reduces AV)
  • Interest increment = +54.35 (growth increases AV)

Positive increments mean the step increased AV. Negative increments mean the step decreased AV.

The Reconciliation Invariant

Increments always sum exactly to the total change in account value:

# This invariant always holds for every policy and every period:
# AV[t] - AV_init == Premium[t] + COI[t] + Admin[t] + Interest[t] + Floor[t]

This is not an approximation — it's exact. The kernel records state_after - state_before for each step, so the telescoping sum always equals the total change. This makes it suitable for regulatory reporting where reconciliation to the penny matters.

Floor and Cap Increments

When .floor(0) fires (AV was negative before the floor), the floor increment is positive — it shows how much the floor pushed AV up. This is the signal that the non-negative guarantee was exercised:

af.floor_impact = af.av.increments["Floor(0)"]
# Positive values = floor fired, AV was pushed up to 0
# Zero values = floor did not fire, AV was already non-negative

Captures

.capture(name) snapshots the account value at a specific point in the calculation. This is useful for downstream calculations that need an intermediate value:

af.av = (
    af.projection.rollforward(initial=af.av_init, track_increments=True)
    .add(af.premium, "Premium")
    .capture("av_after_premium")
    .deduct_nar(af.coi_rate, death_benefit=af.sum_assured, label="COI")
    .grow(af.interest_rate, "Interest")
    .floor(0)
)

# The pre-COI account value, per period
af.av_post_premium = af.av.captures["av_after_premium"]

Captures are also used in multi-state rollforwards for cross-state references like GMWB proportional reductions.

Labels Drive Everything

The label you give a step is the key for accessing its increment. If you use explicit labels, you get readable names:

.charge(af.admin_rate, "Admin")          # → af.av.increments["Admin"]
.deduct_nar(af.coi_rate, ..., label="COI")  # → af.av.increments["COI"]

If you omit the label, an auto-generated one is used:

.charge(af.admin_rate)                   # → af.av.increments["Charge(admin_rate)"]

For models where you plan to access increments, explicit labels are strongly recommended.