Skip to content

Multi-State Rollforwards

When One Account Value Isn't Enough

Some products track multiple account values that interact with each other during the projection:

  • VA + GMDB: Account value and a guaranteed minimum death benefit that ratchets up when AV grows
  • VA + GMWB: Account value and a benefit base that reduces proportionally on withdrawal
  • UL + secondary guarantee: Account value and a shadow account that determines lapse eligibility

These products can't be modelled as two independent rollforwards because the states reference each other mid-period — the guarantee needs to see the post-growth AV before it ratchets, and the benefit base needs to see the pre-withdrawal AV to compute the proportional reduction.

Declaring Multiple States

Pass named keyword arguments instead of initial= to create a multi-state rollforward:

# Single-state (one account)
af.av = af.projection.rollforward(initial=af.av_init)

# Multi-state (two interacting accounts)
rf = af.projection.rollforward(av=af.av_init, guarantee=af.guarantee_init)

Each keyword becomes a named state. The rollforward tracks all states simultaneously, executing steps in the order declared.

.on() — Switching Between States

After declaring states, use .on(state_name) to set which account subsequent steps operate on:

rf = (
    af.projection.rollforward(av=af.av_init, guarantee=af.guarantee_init)

    .on("av")                                    # following steps target AV
    .add(af.premium, "Premium")
    .charge(af.me_rate, "M&E Fee")
    .grow(af.fund_return, "Fund Return")
    .floor(0)

    .on("guarantee")                             # switch to guarantee
    .ratchet_to("av", "GMDB Ratchet")
    .grow(af.roll_up_rate, "Roll-up")
)

.on() is sticky — once set, all subsequent steps target that state until you call .on() again. Before the first .on() call, the default target is the first declared state (in this case, "av").

You can also be explicit on every line if you prefer:

rf = (
    af.projection.rollforward(av=af.av_init, guarantee=af.guarantee_init)
    .on("av").add(af.premium, "Premium")
    .on("av").charge(af.me_rate, "M&E Fee")
    .on("av").grow(af.fund_return, "Fund Return")
    .on("av").floor(0)
    .on("guarantee").ratchet_to("av", "GMDB Ratchet")
    .on("guarantee").grow(af.roll_up_rate, "Roll-up")
)

Both styles produce identical results.

Extracting States

Multi-state rollforwards return a builder that you index by state name:

af.av = rf["av"]
af.guarantee = rf["guarantee"]
result = af.collect()

Each state is a List<Float64> column — the same type as a single-state rollforward result.

VA with GMDB Ratchet

The most common multi-state product: a variable annuity where the guaranteed minimum death benefit ratchets up to the account value high-water mark.

rf = (
    af.projection.rollforward(av=af.av_init, guarantee=af.guarantee_init)

    # Account value: premiums, charges, fund returns
    .on("av")
    .add(af.premium, "Premium")
    .charge(af.me_rate, "M&E Fee")
    .grow(af.fund_return, "Fund Return")
    .floor(0)

    # Guarantee: ratchets to AV, then grows at roll-up rate
    .on("guarantee")
    .ratchet_to("av", "GMDB Ratchet")
    .grow(af.roll_up_rate, "Roll-up")

    .lapse_when(all_non_positive=["av", "guarantee"])
)

af.av = rf["av"]
af.guarantee = rf["guarantee"]
result = af.collect()

# The death benefit at each period is the maximum of AV and guarantee
from gaspatchio_core import when
af.death_benefit = when(af.av > af.guarantee).then(af.av).otherwise(af.guarantee)

.ratchet_to("av") sets the guarantee to max(guarantee, av). Because the AV steps execute first (premium, M&E, fund return), the ratchet sees the post-growth AV — matching the contractual anniversary reset logic.

.lapse_when(all_non_positive=["av", "guarantee"]) zeroes all states for remaining periods when both accounts are non-positive. This is checked at the end of each timestep, after all steps have executed.

GMWB Proportional Reduction

When a policyholder withdraws from a GMWB product, the benefit base reduces by the same proportion as the withdrawal was to the pre-withdrawal account value. If AV was $1,000 and the withdrawal is $200 (20%), the benefit base also drops by 20%.

rf = (
    af.projection.rollforward(av=af.av_init, benefit_base=af.bb_init)

    .on("av")
    .capture("av_pre_withdrawal")              # snapshot AV = 1000
    .subtract(af.withdrawal, "Withdrawal")     # AV = 800

    .on("benefit_base")
    .pro_rata_with("av_pre_withdrawal", af.withdrawal, "Proportional Reduction")
    # benefit_base *= (1 - 200 / 1000) = benefit_base * 0.80

    .on("av")
    .grow(af.fund_return, "Fund Return")
)

af.av = rf["av"]
af.benefit_base = rf["benefit_base"]

.capture("av_pre_withdrawal") snapshots the current AV before the withdrawal. This value is used by .pro_rata_with() as the reference denominator.

.pro_rata_with(capture_ref, amount) computes state *= (1 - amount / ref_value). If the reference value is zero (no AV to withdraw from), the state is left unchanged.

Interleaving Steps Across States

Steps execute in declaration order, regardless of which state they target. This lets you interleave operations where timing matters:

rf = (
    af.projection.rollforward(av=af.av_init, benefit_base=af.bb_init)

    .on("av").charge(af.rider_fee, "Rider Fee")
    .on("av").capture("av_post_charge")
    .on("benefit_base").pro_rata_with("av_post_charge", af.partial_withdrawal, "Partial WD")
    .on("av").grow(af.fund_return, "Fund Return")
)

The rider fee is charged to AV first, then the post-charge AV is captured, then the benefit base adjusts proportionally, then AV grows by fund return. The order is exactly as written — no implicit reordering.

Cross-State Lapse

.lapse_when(all_non_positive=[...]) is a top-level configuration, not a step. It's checked at the end of each timestep after all steps have executed. When all named states are ≤ 0, all states are zeroed for remaining periods.

Rules: - At most one lapse_when per rollforward - Only valid in multi-state mode - For single-state lapse, use .lapse_if_zero() instead (which IS a step) - Only states named in the list are checked — unlisted states continue projecting normally

Increment Tracking in Multi-State

track_increments=True works identically in multi-state mode. Each labeled step records its dollar impact on whichever state it targets:

rf = (
    af.projection.rollforward(
        av=af.av_init, guarantee=af.guarantee_init, track_increments=True,
    )
    .on("av")
    .add(af.premium, "Premium")
    .charge(af.me_rate, "M&E Fee")
    .grow(af.fund_return, "Fund Return")
    .on("guarantee")
    .ratchet_to("av", "GMDB Ratchet")
    .grow(af.roll_up_rate, "Roll-up")
)

af.av = rf["av"]
af.guarantee = rf["guarantee"]

# Access increments by label — same pattern as single-state
af.me_charged = af.av.increments["M&E Fee"]
af.fund_growth = af.av.increments["Fund Return"]
af.ratchet_amount = af.guarantee.increments["GMDB Ratchet"]

result = af.collect()

Increments are keyed by label regardless of which state they target. The "M&E Fee" increment is the dollar impact on AV; the "GMDB Ratchet" increment is the dollar impact on the guarantee.