Skip to content

Rollforward

The Problem

Universal Life, Variable Annuity, and Indexed UL products share a structural challenge: charges at time t depend on the account value at t, which depends on those charges. Cost of Insurance is charged on max(0, death_benefit - AV) — but AV itself is moving as COI is deducted. Tiered fees depend on which AV band the policy sits in. IUL crediting clamps to a floor and cap applied to the running balance.

These dynamics can't be precomputed. Each period's calculation has to run in order, on the running balance, with earlier-period values feeding the later ones.

The rollforward API lets you write that within-period calculation once — as a chain of named steps — and runs it across every policy in your portfolio, period-by-period, while keeping a clear audit trail of what happened at each step.

Not every account-value calculation needs this

If your product's per-period cashflows can be precomputed before the loop (no charge depends on the running balance), use the simpler accumulate() primitive — it's cheaper to write, just as fast, and right for term-life reserves, fixed-charge UL, and any pure linear recurrence.

A Complete Example

from datetime import date
import polars as pl
from gaspatchio_core import (
    ActuarialFrame,
    RollforwardBuilder,
    RollforwardCollector,
    Schedule,
    compile_rollforward,
)

af = ActuarialFrame(
    pl.DataFrame(
        {
            "av_init": [1_000.0, 5_000.0],
            "premium": [[100.0] * 12, [500.0] * 12],
            "coi_rate": [[0.001] * 12, [0.002] * 12],
            "sum_assured": [[50_000.0] * 12, [100_000.0] * 12],
            "admin_rate": [[0.01] * 12, [0.01] * 12],
            "interest_rate": [[0.004] * 12, [0.003] * 12],
        }
    )
)
sched = Schedule.from_calendar_grid(
    start_date=date(2025, 1, 31), n_periods=12, frequency="1M"
)
af = af.projection.set(schedule=sched)

b = af.projection.rollforward(
    states={"av": af["av_init"]},
)
(
    b["av"]
    .add(af["premium"], label="Premium")
    .deduct_nar(af["coi_rate"], death_benefit=af["sum_assured"], label="COI")
    .charge(af["admin_rate"], label="Admin")
    .grow(af["interest_rate"], label="Interest")
    .floor(value=0.0)
)

compiled = compile_rollforward(b)
collector = RollforwardCollector(compiled)
af.av = collector.expr_for("av")

result = af.collect()
print(result.select(["av_init", "av"]))
shape: (2, 2)
┌─────────┬─────────────────────────────────┐
│ av_init ┆ av                              │
│ ---     ┆ ---                             │
│ f64     ┆ list[f64]                       │
╞═════════╪═════════════════════════════════╡
│ 1000.0  ┆ [1044.751356, 1089.276895, … 1… │
│ 5000.0  ┆ [5273.66367, 5545.946964, … 81… │
└─────────┴─────────────────────────────────┘

Terminal account values after twelve months: 1,522.36 and 8,194.37. The chain reads top-to-bottom as the within-period calculation order — each step operates on the current account value after all preceding steps. The label argument is how you refer back to a step later (see Inspection).

What You Can Do

Capability What It Solves Learn More
Step vocabulary Premium deposits, rate charges, COI on net amount at risk, IUL floor/cap, anniversary ratchets Choosing Steps
Multi-account VA + GMDB ratchet, GMWB run-off, secondary guarantees Multi-State
Variants Build product variants from shared helper functions without duplicating code Building Variants
Inspection Step-by-step rendering and fingerprinting for model governance Inspection
Recipes Ready-to-adapt within-period orderings for common products Product Recipes

Three Concepts to Know First

Schedule

Every rollforward sits on an explicit projection — how many periods, what frequency, which calendar, which day-count. Declare it on the frame with af.projection.set(...) before calling rollforward. Anniversary masks, year-fractions, and period dates all derive from the same source and feed into the chain wherever they're needed.

af = af.projection.set(
    valuation_date=date(2025, 1, 31),
    until="term_months",
    until_value=240,
    frequency="monthly",
)

For grids you want to share across frames or persist for audit, build a typed Schedule and pass it via af.projection.set(schedule=sched). See Schedules for from_inception, business-day conventions, and joint-calendar support.

Multiple Accounts

For a single-account product, write b["av"] and chain steps. For products with interacting accounts (a fund and a guarantee, an account value and a shadow), declare multiple accounts in states={...} and switch between them by indexing — b["fund"] for one, b["guarantee"] for the other. Steps execute in the order they're declared, regardless of which account they target.

Compile, Then Collect

The builder is mutable while you're declaring steps. compile_rollforward(b) finalises it and runs validation. A RollforwardCollector then exposes the per-period account values as Polars expressions you assign onto an ActuarialFrame. Execution is deferred until .collect() — the projection runs once for all the values you ask for.

compiled = compile_rollforward(b)
collector = RollforwardCollector(compiled)
af.av = collector.expr_for("av")  # eop value of av across all periods
result = af.collect()

By default the collector exposes each account's end-of-period value. To read a value mid-chain — between two steps within the period — declare a named point on the builder and target a step at it; that point then becomes retrievable with collector.expr_for("av", point="post_charge"). See Multi-State for the full pattern.

Step Vocabulary at a Glance

Family Methods When to Use
Absolute .add(expr), .subtract(expr) Premium deposits, flat-dollar fees, withdrawals
Rate .charge(rate), .grow(rate), .grow_capped(rate, floor=, cap=) M&E charges, interest, IUL crediting with floor/cap
Actuarial .deduct_nar(rate, death_benefit=) COI on net amount at risk
Multi-account .ratchet(to=, when=) GMDB high-water mark, secondary guarantee step-up
Bounds .floor(value=0.0) Non-negative account-value clamp

See Choosing Steps for the decision tree, formulas, and worked examples.

Multi-State — When One Account Isn't Enough

For products with interacting accounts, declare both and switch between them by indexing:

b = af.projection.rollforward(
    states={
        "fund": af["fund_init"],
        "gmdb": af["gmdb_init"],
    },
)
b["fund"].grow(af["rate"], label="Fund Return")
b["gmdb"].ratchet(
    to=pl.col("fund@eop"),
    when=af["anniv"],
    label="GMDB Ratchet",
)

The pl.col("fund@eop") notation reads the fund's end-of-period value within the same period — useful when the GMDB needs to ratchet to the post-growth fund balance, for example. No precomputed column is needed; the value is read live.

See Multi-State for VA + GMDB, GMWB run-off, and stop-condition patterns.

Inspection and Governance

After compilation you can render the model as plain text suitable for audit reports, get a SHA-256 fingerprint that changes when the model structure changes, or read the structured form as a dict:

compiled = compile_rollforward(b)
print(compiled.explain())        # human-readable summary
print(compiled.fingerprint())    # 'sha256:...'
print(compiled.canonical_form()) # machine-readable structure

See Inspection for the full set of inspection helpers and common mistakes.

What This API Does Not Do

  • No Python functions inside the projection. Every step is declared up front; you can't drop in arbitrary Python mid-projection. This is what makes the model auditable, fingerprintable, and fast.
  • No automatic step reordering. Steps run in the order you wrote them. Inspect the chain to verify the order matches your product spec.
  • No conditional step dispatch. If different products need different chains, build each one explicitly — see Building Variants. To turn a single step on or off per period, use .ratchet(when=mask) for ratchets or build a rate column that's zero where the step shouldn't fire.

Runnable Examples

Three minimal patterns live in the source tree and are runnable as scripts:

  • gaspatchio_core/tutorials/rollforward-patterns/01_single_state_fund.py — grow / charge / floor on one state (Hardy 2003 §6.3)
  • gaspatchio_core/tutorials/rollforward-patterns/02_multistate_ratchet.py — fund + GMDB anniversary ratchet (Bauer/Kling/Russ 2008)
  • gaspatchio_core/tutorials/rollforward-patterns/03_lapse_stop.py — withdrawal-driven termination via lapse_when_all_non_positive (Milevsky/Salisbury 2006)

Each script asserts internally against a closed-form expectation; running them is the success signal.