Product Recipes¶
Ready-to-adapt rollforward patterns for common life-insurance and annuity products. Each recipe shows the standard within-period calculation order. Adapt the column names to match your data.
All recipes assume a Schedule has been built and an ActuarialFrame carries the input columns. The setup below builds a small three-policy frame carrying every column the recipes reference, on a twelve-month monthly grid. Each recipe reuses this af:
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"
)
anniv_mask = sched.anniversary_mask() # list[bool], True at each anniversary
af = ActuarialFrame(
pl.DataFrame(
{
"policy_id": [1, 2, 3],
# Initial balances
"av_init": [10_000.0, 25_000.0, 0.0],
"guarantee_init": [10_000.0, 25_000.0, 0.0],
"reserve_init": [0.0, 0.0, 0.0],
# Cash flows (per-period list columns)
"premium": [[1_200.0] * n_periods, [2_400.0] * n_periods, [600.0] * n_periods],
"dividend": [[150.0] * n_periods] * 3,
"withdrawal": [[1_500.0] * n_periods, [3_000.0] * n_periods, [800.0] * n_periods],
"net_premium": [[900.0] * n_periods, [1_800.0] * n_periods, [450.0] * n_periods],
"expected_claims": [[700.0] * n_periods, [1_400.0] * n_periods, [350.0] * n_periods],
# Rates
"admin_rate": [[0.0010] * n_periods] * 3,
"me_rate": [[0.0012] * n_periods] * 3,
"interest_rate": [[0.0030] * n_periods] * 3,
"valuation_rate": [[0.0025] * n_periods] * 3,
"fund_return": [[0.0050] * n_periods] * 3,
"index_return": [[0.0080] * n_periods] * 3,
"floor_rate": [[0.0] * n_periods] * 3,
"cap_rate": [[0.0100] * n_periods] * 3,
"coi_rate": [[0.0020] * n_periods] * 3,
"insurance_charge_rate": [[0.0015] * n_periods] * 3,
# Benefits and masks
"sum_assured": [[250_000.0] * n_periods, [500_000.0] * n_periods, [100_000.0] * n_periods],
"anniv_mask": [anniv_mask] * 3,
"in_force_mask": [[True] * n_periods] * 3,
}
)
)
af = af.projection.set(schedule=sched)
Whole Life¶
Premiums, admin charges, guaranteed interest. No net-amount-at-risk because COI is bundled into the premium structure.
b = af.projection.rollforward(
states={"av": af["av_init"]},
)
b["av"].add(af["premium"], label="Premium")
b["av"].charge(af["admin_rate"], label="Admin")
b["av"].grow(af["interest_rate"], label="Interest")
Universal Life¶
The standard UL chain. COI is charged on the net amount at risk; AV is floored at zero at end of period.
b = af.projection.rollforward(
states={"av": af["av_init"]},
)
b["av"].add(af["premium"], label="Premium")
b["av"].deduct_nar(
af["coi_rate"], death_benefit=af["sum_assured"], label="COI"
)
b["av"].charge(af["admin_rate"], label="Admin")
b["av"].grow(af["interest_rate"], label="Interest")
b["av"].floor(value=0.0)
Order matters: premium is added first (increasing AV and reducing NAR before COI is calculated), admin is charged after COI (so the fee applies to the post-COI balance), and interest credits the final balance.
Indexed UL (IUL)¶
Same as UL but with a floor-and-cap on the crediting rate. The policyholder participates in index returns but is protected on the downside and capped on the upside.
b = af.projection.rollforward(
states={"av": af["av_init"]},
)
b["av"].add(af["premium"], label="Premium")
b["av"].deduct_nar(
af["coi_rate"], death_benefit=af["sum_assured"], label="COI"
)
b["av"].charge(af["admin_rate"], label="Admin")
b["av"].grow_capped(
af["index_return"],
floor=af["floor_rate"], # typically 0.0
cap=af["cap_rate"], # e.g. 0.12 annual
label="Index Credit",
)
b["av"].floor(value=0.0)
The floor and cap are themselves Polars expressions, so they can vary by period or by policy if your product spec uses tiered crediting bands.
Variable UL (VUL)¶
Like UL but with a mortality-and-expense (M&E) charge and fund-based returns instead of guaranteed interest.
b = af.projection.rollforward(
states={"av": af["av_init"]},
)
b["av"].add(af["premium"], label="Premium")
b["av"].deduct_nar(
af["coi_rate"], death_benefit=af["sum_assured"], label="COI"
)
b["av"].charge(af["me_rate"], label="M&E Fee")
b["av"].charge(af["admin_rate"], label="Admin")
b["av"].grow(af["fund_return"], label="Fund Return")
b["av"].floor(value=0.0)
M&E is charged before the fund return so the fee reduces the base on which returns are calculated — matching how separate-account charges work in practice.
Variable Annuity (Accumulation Phase)¶
VA accumulation with no COI (no death-benefit risk charge during accumulation). M&E and fund return only.
b = af.projection.rollforward(
states={"av": af["av_init"]},
)
b["av"].add(af["premium"], label="Premium")
b["av"].charge(af["me_rate"], label="M&E Fee")
b["av"].grow(af["fund_return"], label="Fund Return")
b["av"].floor(value=0.0)
For VA products with guaranteed benefits (GMDB, GMWB), see Multiple Accounts.
VA + GMDB Ratchet¶
VA with a guaranteed minimum death benefit that ratchets to the AV high-water mark on each anniversary.
b = af.projection.rollforward(
states={
"av": af["av_init"],
"guarantee": af["guarantee_init"],
},
)
b["av"].add(af["premium"], label="Premium")
b["av"].charge(af["me_rate"], label="M&E Fee")
b["av"].grow(af["fund_return"], label="Fund Return")
b["av"].floor(value=0.0)
b["guarantee"].ratchet(
to=pl.col("av@eop"),
when=af["anniv_mask"],
label="GMDB Ratchet",
)
The setup above built anniv_mask with sched.anniversary_mask() (a materialised list[bool]); sched.anniversary_mask_expr() is the lazy-expression equivalent if you'd rather assign the column with af.anniv_mask = sched.anniversary_mask_expr().
GMWB Run-Off¶
A withdrawal contract that terminates when the fund is exhausted. The lapse stop-condition zeroes subsequent periods.
b = af.projection.rollforward(
states={"av": af["av_init"]},
lapse_when_all_non_positive=("av",),
)
b["av"].subtract(af["withdrawal"], label="Withdrawal")
b["av"].grow(af["fund_return"], label="Fund Return")
The lapse-period state value retains its (possibly negative) computed value; subsequent periods are zero. Pair with .floor(value=0.0) if you want both clamping and zeroing.
Term Life¶
Term contracts typically have no account value — they accrue reserves rather than fund balances. If you need a rollforward for term reserves, treat the reserve as the state and apply the per-period change:
b = af.projection.rollforward(
states={"reserve": af["reserve_init"]},
contract_boundary=af["in_force_mask"],
)
b["reserve"].add(af["net_premium"], label="Net Premium")
b["reserve"].subtract(af["expected_claims"], label="Expected Claims")
b["reserve"].grow(af["valuation_rate"], label="Interest")
The contract_boundary indicator zeroes periods past the term end-date — useful when the same model runs across mixed durations.
Credit Life¶
Reducing-balance coverage where the benefit amount decreases with the loan balance. Simple charge-and-growth, no COI on NAR.
b = af.projection.rollforward(
states={"av": af["av_init"]},
)
b["av"].charge(af["insurance_charge_rate"], label="Insurance Charge")
b["av"].grow(af["interest_rate"], label="Interest")
Participating Whole Life¶
Whole life with annual dividends added to the account value.
b = af.projection.rollforward(
states={"av": af["av_init"]},
)
b["av"].add(af["premium"], label="Premium")
b["av"].add(af["dividend"], label="Dividend")
b["av"].charge(af["admin_rate"], label="Admin")
b["av"].grow(af["interest_rate"], label="Interest")
Pulling Out the Result¶
After defining the chain, compile and collect. Here we use the VA + GMDB chain from above (it carries both an av and a guarantee state) so the multi-state extraction below has something to read:
b = af.projection.rollforward(
states={
"av": af["av_init"],
"guarantee": af["guarantee_init"],
},
)
b["av"].add(af["premium"], label="Premium")
b["av"].charge(af["me_rate"], label="M&E Fee")
b["av"].grow(af["fund_return"], label="Fund Return")
b["av"].floor(value=0.0)
b["guarantee"].ratchet(
to=pl.col("av@eop"),
when=af["anniv_mask"],
label="GMDB Ratchet",
)
compiled = compile_rollforward(b)
collector = RollforwardCollector(compiled)
af.av = collector.expr_for("av")
result = af.collect()
For multi-state recipes, request each state explicitly:
af.av = collector.expr_for("av")
af.guarantee = collector.expr_for("guarantee")
Per-step cash-flow extraction via track_increments=True is API-stable but the projection does not emit per-step series — see Step Cash Flows. To isolate a single step's contribution, run the chain twice (once with the step, once without) and difference.