Skip to content

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.