Choosing the Right Step¶
Worked Example: Three Steps in Sequence¶
The smallest non-trivial chain: a separate-account variable annuity where the account value grows with the fund return, has an M&E charge deducted, and is floored at zero. Three steps, one account, twelve monthly periods. Copy-paste runnable:
from datetime import date
import polars as pl
from gaspatchio_core import (
ActuarialFrame,
RollforwardCollector,
Schedule,
compile_rollforward,
)
n_periods = 12
sched = Schedule.from_calendar_grid(
start_date=date(2025, 1, 31),
n_periods=n_periods,
frequency="1M",
)
af = ActuarialFrame(
pl.DataFrame(
{
"av_init": [100_000.0],
"fund_return": [[0.01] * n_periods], # 1% monthly
"me_charge": [[0.0010] * n_periods], # 0.10% monthly
}
)
)
af = af.projection.set(schedule=sched)
b = af.projection.rollforward(
states={"av": af["av_init"]},
)
(
b["av"]
.grow(af["fund_return"], label="fund_return")
.charge(af["me_charge"], label="me_charge")
.floor(value=0.0)
)
compiled = compile_rollforward(b)
collector = RollforwardCollector(compiled)
af.av = collector.expr_for("av")
av = af.collect().get_column("av").to_list()[0]
period_factor = (1 + 0.01) * (1 - 0.0010)
print(f"Period factor: {period_factor:>12.6f}")
print(f"After {n_periods}M: {av[-1]:>12,.2f}")
print(f"Closed-form: {100_000.0 * period_factor**n_periods:>12,.2f}")
Period factor: 1.008990
After 12M: 111,337.73
Closed-form: 111,337.73
The chain reads top-to-bottom as the within-period calculation order: grow first, then charge, then clamp. Same chain, runnable as a script with assertions: bindings/python/gaspatchio_core/tutorials/rollforward-patterns/01_single_state_fund.py (Hardy 2003 §6.3).
The remainder of this page is a reference for picking the right step for each kind of within-period calculation.
Decision Tree¶
The most common question: "I have a charge or credit — which method do I use?"
Is the amount proportional to AV or absolute?
Proportional (e.g., "0.15% of account value")
Reducing AV .charge(rate)
Growing AV .grow(rate)
Rate clamped between a floor and cap (IUL)
.grow_capped(rate, floor=, cap=)
Absolute (e.g., "$15 per month")
Adding .add(expr)
Removing .subtract(expr)
Depends on AV and another variable (e.g., "rate * max(0, SA - AV)")
.deduct_nar(rate, death_benefit=)
Anniversary high-water mark on another state
.ratchet(to=pl.col("other@eop"), when=mask)
Bound the result
.floor(value=0.0)
Step Reference¶
Absolute¶
These add or remove a fixed amount, independent of the current account value.
| Method | Formula | Use When |
|---|---|---|
.add(expr) |
av += expr[t] |
Premium deposits, bonus additions |
.subtract(expr) |
av -= expr[t] |
Flat-dollar fees, withdrawals |
b = af.projection.rollforward(states={"av": af["av_init"]})
b["av"].add(af["premium"], label="Premium")
b["av"].subtract(af["admin_fee_dollar"], label="Admin Fee")
Rate¶
These apply a percentage charge or credit relative to the current account value.
| Method | Formula | Use When |
|---|---|---|
.charge(rate) |
av *= (1 - rate[t]) |
M&E charges, admin fee as % of AV |
.grow(rate) |
av *= (1 + rate[t]) |
Interest crediting, fund returns |
.grow_capped(rate, floor=, cap=) |
av *= (1 + clamp(rate[t], floor[t], cap[t])) |
IUL crediting with floor and cap |
# M&E charge then fund return on a separate-account VA
b["av"].charge(af["me_rate"], label="M&E Fee")
b["av"].grow(af["fund_return"], label="Fund Return")
# IUL with 0% floor and 12% annual cap
b["av"].grow_capped(
af["index_return"],
floor=af["floor_rate"],
cap=af["cap_rate"],
label="Index Credit",
)
floor and cap are themselves Polars expressions, so they can vary by period or by policy if your data carries them as list columns or scalar columns.
Account-Value-Dependent¶
The step's amount depends on the current account value — the defining characteristic of products where rollforward earns its keep.
| Method | Formula | Use When |
|---|---|---|
.deduct_nar(rate, death_benefit=) |
av -= rate[t] * max(0, death_benefit[t] - av) |
COI on net amount at risk |
The net amount at risk (NAR) is max(0, death_benefit - AV). When AV is close to the death benefit, NAR shrinks and the COI charge drops. This feedback loop is why COI cannot be pre-computed.
b["av"].add(af["premium"], label="Premium")
b["av"].deduct_nar(
af["coi_rate"],
death_benefit=af["sum_assured"],
label="COI",
)
b["av"].grow(af["interest_rate"], label="Interest")
Multi-Account¶
These read or modify a different account from within the chain. Available when you've declared more than one entry in states={...}.
| Method | Formula | Use When |
|---|---|---|
.ratchet(to=expr, when=mask) |
account = max(account, expr[t]) if mask[t] else account |
GMDB high-water mark, secondary guarantee step-up |
The to= expression typically uses the cross-account read syntax pl.col("other_account@eop") to capture another account's same-period value. The when= indicator is a per-period boolean — True triggers the ratchet, False leaves the account unchanged.
# A two-account builder: a fund that grows, and a GMDB that ratchets to it.
af.anniv_mask = pl.lit(sched.anniversary_mask()) # per-period boolean column
b_gmdb = af.projection.rollforward(
states={"fund": af["av_init"], "gmdb": af["av_init"]},
)
b_gmdb["fund"].grow(af["fund_return"], label="Fund Return")
b_gmdb["gmdb"].ratchet(
to=pl.col("fund@eop"),
when=af["anniv_mask"],
label="GMDB Ratchet",
)
Where anniv_mask is a per-period boolean column. The Schedule produces one for you — see Schedules for anniversary_mask() and anniversary_mask_expr().
See Multiple Accounts for the full pattern catalogue.
Bounds¶
| Method | Formula | Use When |
|---|---|---|
.floor(value=) |
av = max(av, value) |
Non-negative account-value constraint |
Most UL and VA contracts guarantee a non-negative account value at end of period. End the chain with .floor(value=0.0) unless the product spec says otherwise.
b["av"].add(af["premium"], label="Premium")
b["av"].deduct_nar(af["coi_rate"], death_benefit=af["sum_assured"], label="COI")
b["av"].grow(af["interest_rate"], label="Interest")
b["av"].floor(value=0.0)
Builder-Level Configuration¶
Some behaviour belongs on the builder constructor rather than as a chained step.
track_increments=True¶
Reserves per-step cash-flow tracking. The builder accepts the flag and the compile pass enforces that every step that takes a label has one set, but the projection itself does not emit the per-period cash-flow series. See Step Cash Flows.
b = af.projection.rollforward(
states={"av": af["av_init"]},
track_increments=True,
)
lapse_when_all_non_positive=("av",)¶
Names accounts that — when all go to zero or below at end-of-period — terminate the projection. Every subsequent period writes zero across every account.
b = af.projection.rollforward(
states={"av": af["av_init"]},
lapse_when_all_non_positive=("av",),
)
The lapse-period account value itself is not clamped — if a subtraction overshoots into negatives, that negative value appears in the output for the lapse period. Only subsequent periods are zeroed. Pair with .floor(value=0.0) if you want both: clamp the lapse-period value AND zero subsequent periods.
For coordinated lapse across multiple accounts (e.g., a secondary guarantee that holds the contract alive while the fund is exhausted), name them all. The lapse fires only when every named account is non-positive simultaneously.
contract_boundary=expr¶
A boolean pl.Expr indicator whose first True value marks the period the contract leaves force. From that period onwards every state writes zero. Use for hard policy-term limits — for example, a 20-year term with no extension, or a paid-up-by date.
b = af.projection.rollforward(
states={"av": af["av_init"]},
contract_boundary=af["expired_mask"],
)
Worked example — fund grows at 1%/month for the first three periods, then the contract expires at period 4:
from datetime import date
import polars as pl
from gaspatchio_core import (
ActuarialFrame,
RollforwardCollector,
Schedule,
compile_rollforward,
)
sched = Schedule.from_calendar_grid(
start_date=date(2025, 1, 31), n_periods=6, frequency="1M"
)
af = ActuarialFrame(
pl.DataFrame(
{
"init": [100.0],
"rate": [[0.01] * 6],
# True at t=3 → contract is out of force from period 4 onwards.
"expired_mask": [[False, False, False, True, False, False]],
}
)
)
af = af.projection.set(schedule=sched)
b = af.projection.rollforward(
states={"av": af["init"]},
contract_boundary=af["expired_mask"],
)
b["av"].grow(af["rate"])
compiled = compile_rollforward(b)
collector = RollforwardCollector(compiled)
af.av = collector.expr_for("av")
print(af.collect().get_column("av").to_list())
[[101.0, 102.01, 103.0301, 0.0, 0.0, 0.0]]
The boundary indicator must be passed as a single column reference (af["name"]) — the rollforward captures a single column slot, not a re-evaluated expression. To derive the indicator from policy data (per-policy term end-dates, age-based termination, etc.), materialize it onto the frame first, then reference that column in the rollforward call. The most common pattern is to use the af.projection.contract_boundary() accessor:
af.expired_mask = af.projection.contract_boundary(end_date_column="term_end_date")
b = af.projection.rollforward(
states={"av": af["av_init"]},
contract_boundary=af["expired_mask"],
)
The intermediate column name (expired_mask here) is yours to pick — any non-conflicting name works. This also lets the same chain run across a heterogeneous portfolio: each policy's per-row indicator zeroes its own state at its own term end-date.
contract_boundary and lapse_when_all_non_positive both terminate the projection but for different reasons: the boundary is a time-driven hard cutoff (the policy term simply ended), the lapse is a balance-driven termination (the fund went to zero). They can be combined — whichever fires first wins.
Step Order¶
Steps execute in declaration order within each period. The order matters: .charge then .grow is not the same as .grow then .charge. The convention for separate-account products is to apply charges first, then growth, then bound:
b["av"].charge(af["me_rate"], label="M&E") # 1. fee on opening balance
b["av"].grow(af["fund_return"], label="Return") # 2. apply return on net
b["av"].floor(value=0.0) # 3. clamp non-negativity
Some product specs reverse charge and growth — match what your spec says. If unsure, compiled.explain() prints the chain in declaration order so you can verify it against the spec (Inspection).
See Multiple Accounts for full VA + GMDB and GMWB examples.