Skip to content

Multiple Accounts

When One Account Isn't Enough

Some products track multiple accounts that interact 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 cannot be modelled as two independent rollforwards because the states reference each other within the same period — the guarantee needs to see the post-growth AV before it ratchets, the benefit base needs to see the pre-withdrawal AV to compute the proportional reduction.

Setup

The short examples below share one single-policy frame on a twelve-month monthly grid, carrying every column they reference (the full worked example further down builds its own frame):

from datetime import date

import polars as pl

from gaspatchio_core import (
    ActuarialFrame,
    RollforwardCollector,
    Schedule,
    compile_rollforward,
    when,
)

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()

af = ActuarialFrame(
    pl.DataFrame(
        {
            "policy_id": [1],
            "av_init": [100_000.0],
            "guarantee_init": [100_000.0],
            "fund_init": [100_000.0],
            "gmdb_init": [100_000.0],
            "premium": [[1_000.0] * n_periods],
            "me_rate": [[0.001] * n_periods],
            "fund_return": [[0.008] * n_periods],
            "rate": [[0.008] * n_periods],
            "anniv_mask": [anniv_mask],
        }
    )
)
af = af.projection.set(schedule=sched)

Declaring Multiple Accounts

Multiple states are declared as entries in the states dict:

b = af.projection.rollforward(
    states={
        "av": af["av_init"],
        "guarantee": af["guarantee_init"],
    },
)

Each entry's value is the per-policy initial-value expression — typically a column from the input frame. The schedule comes from the frame's projection (declared upstream with af.projection.set(...)). All accounts are tracked simultaneously and the steps run in declaration order across the chain.

Routing Steps to an Account

Index the builder by account name to retrieve a handle. All step methods chain on the handle and append to a single shared sequence:

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",
)

Steps execute in the exact order they were declared, regardless of which account they targeted. Two consecutive b["av"].grow(...) calls execute back-to-back; an interleaved b["guarantee"].ratchet(...) between them runs in the middle.

Cross-Account Reads

The expression pl.col("account@point") reads the live value of another account at a specified point within the current period. This is how one account's calculation can depend on another account's same-period value.

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"),       # reads fund's eop value, this period
    when=af["anniv_mask"],
    label="GMDB Ratchet",
)

Because b["fund"].grow(...) is declared first, the fund's eop value reflects the post-growth balance at the moment the ratchet evaluates. Reordering the declarations would change the semantics — the ratchet would see the pre-growth balance.

The point name must be one of the points declared on the builder. Default points are ("bop", "eop"); custom points let you capture intermediate values mid-chain (see Custom Points below).

Worked Example: VA with GMDB Ratchet

The canonical multi-state product: a variable annuity where the guaranteed minimum death benefit ratchets to the account value high-water mark on each anniversary. The full pattern below runs end-to-end:

from datetime import date

import polars as pl

from gaspatchio_core import (
    ActuarialFrame,
    RollforwardCollector,
    Schedule,
    compile_rollforward,
    when,
)

# Five-year monthly schedule. Anniversary fires every 12 months.
n_periods = 60
sched = Schedule.from_calendar_grid(
    start_date=date(2025, 1, 31),
    n_periods=n_periods,
    frequency="1M",
)

# Schedule produces the anniversary mask: True at every contract anniversary.
anniv_mask = sched.anniversary_mask()  # list[bool] of length n_periods

af = ActuarialFrame(
    pl.DataFrame(
        {
            "av_init": [100_000.0],
            "guarantee_init": [100_000.0],
            "premium": [[1_000.0] * n_periods],
            "me_rate": [[0.001] * n_periods],
            "fund_return": [[0.008] * n_periods],
            "anniv_mask": [anniv_mask],
        }
    )
)
af = af.projection.set(schedule=sched)

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")
af.guarantee = collector.expr_for("guarantee")
af.death_benefit = when(af.av > af.guarantee).then(af.av).otherwise(af.guarantee)

result = af.collect()
av = result.get_column("av").to_list()[0]
gtee = result.get_column("guarantee").to_list()[0]
db = result.get_column("death_benefit").to_list()[0]

print("Period  AV          Guarantee   Death Benefit")
for t in [0, 11, 12, 23, 24, 35, 36, 47, 48, 59]:
    print(f"  {t + 1:>2}  {av[t]:>12,.2f}  {gtee[t]:>12,.2f}  {db[t]:>12,.2f}")
Period  AV          Guarantee   Death Benefit
   1    101,706.19    100,000.00    101,706.19
  12    121,280.31    121,280.31    121,280.31
  13    123,135.29    121,280.31    123,135.29
  24    144,416.40    144,416.40    144,416.40
  25    146,433.16    144,416.40    146,433.16
  36    169,570.13    169,570.13    169,570.13
  37    171,762.76    169,570.13    171,762.76
  48    196,917.44    196,917.44    196,917.44
  49    199,301.28    196,917.44    199,301.28
  60    226,649.63    226,649.63    226,649.63

What this output shows:

  • Month 1: AV grows from 100,000 to 101,706 (premium + return − M&E fee, then floored). Guarantee unchanged at 100,000. Death benefit = max(AV, guarantee) = AV.
  • Month 12 (first anniversary): the ratchet fires. Guarantee jumps to AV's eop value (121,280). Death benefit equals both.
  • Month 13: AV continues to grow; guarantee holds at the locked-in 121,280 level.
  • Pattern repeats every 12 months — guarantee steps up to the high-water mark, then holds until the next anniversary.

The pattern is also available as a runnable script with closed-form assertions: bindings/python/gaspatchio_core/tutorials/rollforward-patterns/02_multistate_ratchet.py. That script asserts the fund grows geometrically and the GMDB matches the fund value at every anniversary.

Custom Points for Mid-Chain State

The default points ("bop", "eop") capture state at start and end of period. To read a state's value between two steps in the chain — for example, to ratchet to AV after charges but before growth — declare an additional point and target it explicitly.

b = af.projection.rollforward(
    states={
        "av": af["av_init"],
        "guarantee": af["guarantee_init"],
    },
    points=("bop", "post_charge", "eop"),
)

# Steps up to "post_charge" land on that point
b["av"].between("bop", "post_charge").charge(af["me_rate"], label="M&E Fee")

# Steps after default to "eop"
b["av"].grow(af["fund_return"], label="Fund Return")

# Guarantee reads AV at the post_charge point — pre-growth, post-fee
b["guarantee"].ratchet(
    to=pl.col("av@post_charge"),
    when=af["anniv_mask"],
    label="GMDB Ratchet",
)

Declared point order matters: bop must come first, eop last. Custom points sit between them and are filled as steps complete. Anything that should land on a custom point must use .between(p1, p2) to say so explicitly; by default a step targets eop.

Coordinated Lapse Across Accounts

When a contract should terminate only if all tracked balances are exhausted simultaneously (e.g., a fund + secondary-guarantee shadow account), name them in lapse_when_all_non_positive:

b = af.projection.rollforward(
    states={"av": af["av_init"], "shadow": af["shadow_init"]},
    lapse_when_all_non_positive=("av", "shadow"),
)

The lapse fires at end-of-period when every named state is ≤ 0. From that period onwards, both states write zero. States not listed in the tuple do not participate in the lapse check; they continue normally.

For a single-state termination pattern (the usual GMWB run-off), see bindings/python/gaspatchio_core/tutorials/rollforward-patterns/03_lapse_stop.py — fully runnable with hand-computed assertions.

Same-Period Arithmetic — What's Allowed

Step arguments (to=, rate=, expr=, when=) accept a single column reference — either af["col"] for an input column or pl.col("av@eop") for a cross-account read. Compound expressions like af["withdrawal"] / pl.col("av@post_grow") aren't evaluated inside the chain.

Where the actuarial spec needs a derived rate (for example, a GMWB proportional reduction 1 − withdrawal / av_pre_withdrawal), compute the rate as a regular column before the rollforward, then feed the materialised column to .charge(...) or wherever it belongs. The arithmetic stays auditable in the Polars query plan and the rollforward chain stays focused on the within-period sequence.

See Also

  • Choosing Steps — full step reference and decision tree
  • Inspection — verifying the chain order matches your spec
  • bindings/python/gaspatchio_core/tutorials/rollforward-patterns/02_multistate_ratchet.py — runnable Bauer/Kling/Russ (2008) GMDB reference
  • bindings/python/gaspatchio_core/tutorials/rollforward-patterns/03_lapse_stop.py — runnable GMWB-style lapse stop pattern