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 vialapse_when_all_non_positive(Milevsky/Salisbury 2006)
Each script asserts internally against a closed-form expectation; running them is the success signal.