Implementation Notes#
Status: Draft, 2026-08-14. Built from
products/immediate_annuity/technical-notes.md;
the product it implements is specified in
product-spec.md.
This is a mechanics demonstration, not a pricing or reserving result. The contractual elements — the five payout forms, the two survivor-reduction triggers, the fixed compound COLA, the cash-refund and installment-refund definitions, and the 8%-to-0% commutation surrender-charge scale — are sourced from the composite’s product documents. Everything else is a std standardization introduced for the reference implementation. In particular no insurer publishes payout factors or the pricing basis for a fixed SPIA, so the initial annual income
B(1)is an exogenous input, not a model output, and the $6,000-per-$100,000 level shipped here is a round arithmetic anchor that makes the worked example exact — deliberately generous against the COLA-adjusted illustrations, which imply nearer 4.5% for this cell. Replace it with a real quote, and the mortality tables with company data, before drawing any conclusion from the numbers.
Run it#
python products/immediate_annuity/run.py
python products/immediate_annuity/run.py 8 # the same cell, probability-weighted
Three lines to the same thing:
import modelx as mx
model = mx.read_model("products/immediate_annuity/SPIA_US_S")
model.Projection[1].result_cf()
Projection takes a point_id; Projection[1] is the worked-example anchor cell.
result_cf() returns a tidy DataFrame indexed by the 0-based policy month t
(0 … proj_len() - 1) with one column per
cash flow line; result_pols() returns the survival probabilities and payment factors
behind it, its survival columns read at the end of each month.
The model and its Projection Space both carry docstrings — model.doc describes the
product and the projection basis, and model.Projection.doc holds the full mapping
between the technical notes’ symbols and the cells names.
Monthly, not annual#
t counts policy months from the annuity date and is 0-based, running
0 … proj_len() - 1 — lifelib’s own convention, the one every model in this library
follows. The notes set the grid (months from the annuity date) and the product forces it:
instalments fall monthly on the anchor cell, the COLA steps at
month 12, 24, …, and deaths are decremented at end of month, so a survivor reduction that
takes effect “from the instalment due at the end of the month of death” needs a monthly
grid to be expressible at all. The annuity date is collapsed onto the issue date, so
there is no deferral period; any nonzero deferral turns this product into the
deferred-income-annuity chassis. The contractual policy year is the derived 1-based
label t // 12 + 1 and is never used to index the frame.
pols_if(t) is a closing measure here, deliberately. It is the notes’ own end-of-month
obligation indicator IF(t) = max(C(t), l_alive(t+1)), scaled by pols_if_init(), and not
the library’s usual count in force at the opening of period t. So pols_if(0) is not
pols_if_init() — on model point 8 it is 0.99999986 against 1.0 — because the month’s
mortality has already been taken out of it. pols_if_init() is the scaling constant the
indicator multiplies, not the frame’s first row. The alternative, an opening count, would
carry the maintenance expense on a measure the notes do not use and would move every
expenses(t) figure the notes’ table pins.
Two cells are indexed by a time point, not by a month, and their arguments do not move
with the frame. lives_if(k, life) is survival at time k and cum_annuity_pp(k)
the instalments scheduled before time k, both with k = 0 at the annuity date —
lives_if(0, life) = 1 and cum_annuity_pp(0) = 0, which is the notes’ own l(0) = 1
and G(0) = 0. Month t opens at k = t and closes at k = t + 1, so:
Reading |
Expression |
|---|---|
survival at the arrears payment point of month |
|
survival at the advance payment point of month |
|
deaths during month |
|
refund balance for a death in month |
|
the same on advance, in a payment month |
|
That is why the notes’ G(13) = 12 × 500.00 + 515.00 and their l(14) = 0 for a death in
the fourteenth month read unchanged in the model, while every month-indexed figure moves
down by one: the worked example’s rows are t = 0, 11, 12, 13, 14, 23, 24.
proj_len() is the number of projected months — the exclusive end of the frame, so
the last month is proj_len() - 1. It runs to the notes’ age stop rule on the
youngest covered life — 696 months on the anchor cell’s 65/62 pair, t = 0 … 695 —
or to the end of the effective certain period if that is later. Stopping on the primary’s
age alone would truncate the joint annuitant’s tail; the notes say so explicitly, and a
test pins it. Counts of months are counts throughout: certain_mths(),
certain_mths_refund() and certain_mths_eff() are numbers of months, so a 10-year
certain period covers t = 0 … 119 and certain_floor(t) = 1{t < n_eff}.
That rule stops the projection one month before any life attains ω = 120: age(t, life)
advances on policy anniversaries, so at t = 695 the younger life is 119, and the q = 1
row of the mortality table is never reached inside the run. The notes’ other stop test —
IF(t) < 1e-6 — is therefore not subsumed by it, contrary to what an earlier draft of
this file and the model’s own docstrings claimed: model point 8 ends at
pols_if(695) = 3.41e-06, 3.4 times the threshold. The age rule is the one
implemented, because it keeps proj_len() independent of the projection it bounds, and
the truncated tail — one further month, after which the attained age is 120, q = 1 and
every flow is zero — is worth 3.41e-06 of a contract. A test pins the residual so the
claim cannot quietly become false again.
Inputs are external files#
The four input CSVs live in this directory, beside run.py — not inside the model
folder. SPIA_US_S/ holds nothing but formulas:
products/immediate_annuity/
model_point_table.csv <- inputs live here
mort_table.csv
improvement_scale.csv
surr_charge_table.csv
run.py
README.md
SPIA_US_S/ <- formulas only
__init__.py (model docstring)
_system.json
Data/__init__.py (reads the CSVs, once per model)
Projection/__init__.py (the by-contract projection)
This follows lifelib’s annuallife/TradLife_A, which keeps input.xlsx beside the
model and reads it at run time. It is the opposite of basiclife/BasicTerm_S, which
stores its inputs inside the model through modelx’s IOSpec machinery — hence no
_data/ directory and no embedded values here at all.
Read once, in Data#
Projection is parameterized by point_id, so every Projection[N] is a separate
ItemSpace with its own cells cache. Readers placed there would re-read every file for
every contract. They live instead in an unparameterized Data Space, which
Projection references as data — so each file is read once per model no matter how
many contracts are projected.
Data.input_dir() resolves the location from _model.path.parent when the model is
read, so it works wherever the repository is checked out. Each table has a filename
Reference and a reader Cells, both on Data:
Reference |
Cells |
File |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
The trade-off: the model is not portable on its own. Copy SPIA_US_S/
without the CSVs and it will read fine, then fail on first evaluation. What you gain is
that a diff of the model shows logic changes only, and an input can be swapped in place —
point Data.mort_table_file at another same-schema file and the projection follows, with
no formula change. That is also how the notes’ first sensitivity is run: scale
improvement_scale.csv to 50% or 150% of tabulated improvement and re-read.
File |
Contents |
Provenance |
|---|---|---|
|
Fifteen contracts on one anchor configuration, varying only what the worked example varies, plus two that hold open a case the notes under-specify (14: quarterly in advance; 15: |
anchor cell std, technical notes “Worked example” |
|
Base annuitant mortality |
std illustrative Gompertz–Makeham curve — not the 2012 IAM Basic table, and not any published table |
|
Generational improvement rates by age and sex |
std illustrative G2-shaped scale — not Projection Scale G2 |
|
Commutation surrender charge by contract year, 8% in year 2 to 0% from year 10 |
sourced [S1] — the only published SPIA surrender-charge schedule found |
Time-like columns in the inputs. model_point_table.csv carries two, death_mth_1
and death_mth_2, and both are points on the frame’s month axis: they shifted with
the frame when the model moved to the 0-based index, so the notes’ death “during the
fourteenth month, t = 13” is death_mth = 13 (and 3 → 2, 30 → 29). A blank still means
the life never dies, but the
sentinel death_mth() returns for it is now -1, because month 0 is a projectable month
of death like any other. certain_months is a count of months and did not move;
annuity_year is a calendar year and did not move. surr_charge_table.csv is keyed by
policy_year, the contract’s own 1-based label, so the file is unchanged and the reader
maps into it through policy_year(t) = t // 12 + 1. mort_table.csv and
improvement_scale.csv are keyed by (age, sex) — not a time axis at all.
On the mortality tables. The technical notes prescribe the 2012 IAM Basic table
projected with Projection Scale G2, applied generationally, with a ×1.084 A/E factor
from the 2020–2024 SOA/LIMRA payout study. The notes also forbid embedding it: “The model
must load them; it cannot hard-code them”, and the Basic table is in any case not printed
in any source this library holds — A-821 prints the loaded Period Table only. The two
files shipped here are illustrative stand-ins carrying the right shape and the right
schema, marked std in their provenance columns. Swap in a licensed basis by
replacing the files. Keep the two objects separate: the loaded Period Table is a
valuation object and the Basic table a best-estimate one, and they must not be
interchanged.
Naming#
Cells follow lifelib’s basiclife/BasicTerm_S and savings/CashValue_SE wherever those
models have an analogue: pols_* for contract counts, plural nouns for cash flows,
*_rate for rates, *_pp for per-policy amounts, claims(t, kind) with an uppercase
kind, result_cf(), plus model_point, age_at_entry, sex, age, duration,
duration_mth, policy_year, proj_len, mort_rate, mort_rate_mth, expenses,
inflation_rate, surr_charge, surr_charge_rate and net_cf. A test asserts that
shared set is present, so the models cannot drift apart silently.
Four names are the library-wide spelling rather than this product’s own shorthand, so that the twelve reference models can be read against each other:
mort_ae_factoris the mortality A/E deviation factor — the notes’AE, 1.084 std.omega_ageis the limiting age ω = 120.check_lives_roll_fwd()andcheck_payment_factor()take no argument and return a bool covering every projected month, which is what lets one test call the same check across every model. The signed per-month residual — the more useful object once a check fails — ischeck_lives_roll_fwd_resid(t)andcheck_payment_factor_resid(t), and each bool is implemented in terms of its own residual.net_cfis income-positive in every model in the library, soliability_cfcarries the notes’ outgo-positiveCF(t)verbatim and both are published as columns ofresult_cf()(below).
The technical notes use compact actuarial symbols instead; the full mapping lives in the
Projection Space docstring. Five cases needed care:
Notes |
Cells |
Why |
|---|---|---|
|
|
The notes’ two symbols differ only by case — and the first is indexed by time, the second by month |
|
|
Same letter, unrelated quantities |
|
|
Same letter, unrelated quantities |
|
|
Not a separate quantity — see below |
|
|
The worked example’s “CF” column is not the notes’ |
d_term(t) is defined in the notes as d₁ on a single-life contract and d_last on a
joint one. Because lives_if_last(k) returns l₁ when the contract is single-life, the
two collapse: lives_death_last is d_term in both cases, and no second cells is
needed.
The worked example is a scenario, not a table run — so the model ships both#
The notes’ verification anchor is not a probability-weighted projection. It is a
scenario: “the joint (secondary) annuitant dies during month 14” — the fourteenth
month, t = 13 — “the primary survives
throughout”, evaluated at l₁ = 1, l₂ = 0. But the same notes say model points are
“projected on an expected (probability-weighted) basis”. Both readings are shipped, and
which one applies is a model point column, mort_basis:
mort_basis = "table"runs the notes’ generational recursion offmort_table.csvandimprovement_scale.csv. Model points 7, 8, 9, 12 and 15.mort_basis = "scenario"std replaceslives_if(k, life)with the deterministic step function1{k ≤ death_mth(life)}— alive at every time point up to the start of the month of death, dead from its end — with a blankdeath_mth(returned as-1) meaning the life survives the whole projection. Model points 1–6, 10, 11, 13 and 14 — the worked example’s runs.
The scenario switch is a std modelling device, not a product feature. It exists because retuning assumptions until a probability-weighted run happened to land on 500.00 / 515.00 / 343.33 would be dishonest, and because the notes’ trace is only meaningful as a scenario: it is checking the payment factor algebra, not the mortality basis. Model point 8 is the anchor cell on the table basis and is what a reader should look at for a realistic cash flow shape.
The worked example’s “CF” column is not the notes’ CF(t)#
The notes define CF(t) = E[ANN(t)] + E[CR(t)] + E[COMM(t)] + E[EXP(t)] and, separately,
a maintenance expense of $60 p.a. paid monthly. But the worked-example table’s “CF” column
reads 500.00 at t = 0, not the 505.00 that definition gives once the $5.00
monthly expense is added. The column is the annuity instalment alone.
This is not resolvable from the notes, so the model names both and the tests assert both.
annuity_payments(t) is E[ANN(t)] and is what the worked-example rows are asserted
against; liability_cf(t) is the notes’ CF(t), outgo positive, and is asserted at
505.00. net_cf(t) = −liability_cf(t) keeps Term_US_S’s sign convention (income less
outgo) so the two models’ cash flow statements read the same way, even though this product
has no projected income at all. Both are columns of result_cf() — the notes’ stream
survives verbatim under the name the notes give it, and the library-wide income-positive
convention is available under the name every other model uses, so neither has to stand
for the other.
max(C, L) — the certain period is a floor, not a second stream#
payment_factor(t) = max(certain_floor(t), payment_factor_life(t)) is the single most
important line in the model, and the notes’ first-listed pitfall:
During the certain period the instalment is certain — do not also weight it by survival.
max(C, L)prevents paying1 + L; an additive construction silently doubles the guarantee.
Two consequences fall out for free, with no extra flag:
Because the floor pays the full, unreduced instalment, a survivor reduction inside a certain period is automatically deferred to the end of that period — one carrier’s published rule [S5]. Model point 5 kills the joint annuitant in month
t = 13under a 10-year certain period and pays the full instalment untilt = 119, dropping to δ only att = 120— the certain period’s 120 months beingt = 0 … 119.A commutation reduces certain-period instalments only, because
θ_cumis multiplied byC(t): att = 120the life-contingent tail is paid in full. Applyingθ_cumto that tail contradicts every retrieved contract, and model point 9 pins it.
Model point 13 is the harder case: both annuitants die inside the certain period, so
L = 0 while C = 1. The full instalment continues, pols_if stays at 1 because a
payment obligation is still open, and everything — annuity, expense, in-force — goes to
zero together at t = 120.
certain_only — where the notes’ expense formula outlives the contract#
The notes carry the maintenance expense on IF(t) = max(C(t), l_alive(t+1)) and, in the
same assumption table, describe it as “$60 per contract p.a., paid monthly while any
payment obligation remains”. On the four life-contingent forms the formula and the
prose say the same thing. On certain_only they do not, because the notes also set
L(t) ≡ 0 there: the last instalment falls in month n_eff - 1 and nothing whatever is owed
afterwards, but l_alive is a survival probability, not a payment factor, and stays
positive for the annuitant’s whole remaining lifetime.
Taken literally the formula keeps billing a contract that ended years earlier. On model
point 15 — certain_only, 10-year certain, single male 65, table basis — that is
1,539.83 of expense spread over the 540 months after the last payment, still charging
5.63 in month t = 239 against a pols_if of 0.7046 on a contract whose final instalment
was paid ten policy years before.
pols_if follows the prose: the life-contingent leg is dropped on certain_only, the one
form that carries no life contingency at all, so IF(t) = C(t) there and
IF(t) = max(C(t), l_alive(t+1)) on every other form. Nothing on any other form or model
point moves — no shipped point used certain_only before, which is exactly why the notes’
internal inconsistency went unnoticed. Model point 15 exists to hold the case open, and a
test asserts pols_if(120) = 0 and zero expense from t = 120 on.
Two timing conventions, each worth one instalment#
The notes flag both as pitfalls, and they are wired to the same model point switch,
timing:
Survival measurement. An arrears instalment falling at the end of month
trequires survival to timet + 1; an advance instalment falls at the start of montht, which is timet, so survival is measured a full month earlier — at every frequency.payment_surv_mth(t)returns that time point,t + 1ort. Using end-of-period survival for advance payments understates the liability by about one period’s mortality per payment. Model points 6 (arrears) and 10 (advance) are the same single life dying in the fourteenth month,t = 13: the arrears run stops paying att = 13, the advance run att = 14.The notes write that advance point as
t + 1 − 12/m, “one full payment period earlier” than the arrears pointt + 1. That is measured from the arrears month of the same instalment, which falls one payment period later than the advance month;is_payment_mthindexes an advance instalment by the month it actually falls in (t = 0, 3, 6, …atm = 4), so the two readings coincide only atm = 12— the only frequency the notes spell out, and the only one every other shipped model point uses. Takingt + 1 − 12/mliterally atm = 4reads survival two months too early and pays the quarterly instalment due at the start of month 3 to a life the projection has already killed off in month 2. Model point 14 is that contract: quarterly in advance, single male 65, dying in montht = 2. It pays $1,500.00 in the first year, not $3,000.00, and a test asserts both the payment months0, 3, 6, 9, 12and the survival times behind them.Refund balance. The cash refund nets instalments already paid:
G(t)on arrears — withGindexed by time, that is everything paid before monthtopened — butG(t+1)in an advance payment month, because an instalment paid at the start of the month of death has been paid. Model point 6 pays $93,485.00 and model point 11 pays $92,970.00 — exactly one instalment apart, and using the arrears balance on advance would overstate the liability by that instalment.
n_R is derived, never hard-coded#
The installment-refund certain period is n_R = min{t + 1 : t ∈ T, G(t+1) ≥ P}, a count
of months whose last certain month is t = n_R − 1 — payments continue
until cumulative payments equal the premium, which is the cited premium ÷ annualized income
rule rounded up to a payment date [S5]. On the anchor it lands on the notes’ 200 months,
and the temptation is to write 200 down. certain_mths_refund() searches the schedule
instead, so it moves with B(1): model point 12 is the same contract at $4,800 of annual
income and returns 250 months. The notes’ “hard-coding it breaks every sensitivity run on
B(1)” is a test.
The final-instalment trim the notes prescribe — P − G(n_R − 12/m) std — is
implemented literally, and is a no-op wherever P is an exact multiple of the instalment,
which is the shipped case. One caveat the notes do not address: strictly the trim belongs
to the payment made under the guarantee, not to one made because the annuitant is alive.
The flat trim is what the notes specify and what is implemented.
Standardizations used#
Everything in this list is std: the monthly grid and the arrears default; the
mort_basis scenario switch; the shipped mortality table and improvement scale (neither
is a published table); the A/E factor of 1.084 on the projected basis; the monthly
mortality conversion q_m = 1 − (1 − q)^(1/12); joint-life independence in
l_last = l₁ + l₂ − l₁l₂; the limiting age ω = 120 on the best-estimate side; the initial
income level B(1) = $6,000; premium tax τ = 0; maintenance expense $60 p.a. inflating at
2.5% and carried by IF(t) = max(C(t), l_alive(t+1)), with the l_alive leg dropped on
certain_only so the expense ends with the contract (above); the
anniversary-of-annuity-date COLA rule
(the cited “one year after the first income payment” alternative [S5] is not implemented); the
installment-refund trim; and the whole commutation basis — the 4.00% base discount rate,
the compound convention and the flat cmt10_shift are std and unverified,
since no fixed SPIA issuer publishes a commutation discount formula.
Commutation utilization is switched off by default (commute_util_base = 0) so the
payment engine is exercised in isolation, which is what the notes’ base run specifies. Two
warnings travel with it: any run with commutation_enabled inherits an unsupported
discount assumption, and a u(y) vector fed into a reserve calculation is not a
conservative approximation of CARVM — commutation is an elective benefit under AG 33,
whose incidence rates must be maximised over rather than assumed REG-R151.
Not implemented, and named in the model docstring for the same reason: the
qualified-money overlay (the qualified column is inert); the rate-driven dynamic take-up
u(y, t); a 10-year CMT path; the ALB/ANB conversion; the exclusion-ratio tax split,
which is a policyholder computation generating no insurer cash flow; and every valuation
layer — CARVM, VM-22 CTE70, and the 2012 IAR valuation table with its
no-compound-rounding rule.
Tests#
tests/test_immediate_annuity_us.py asserts every row and column of the worked-example
table to the cent on both trigger columns, the income levels behind it, and each of the
notes’ five traces: the trigger split at t = 13 with L = 0.6667 against L = 1, the
reversed death on which the two triggers coincide at 343.33, the COLA continuing after the
reduction, the 10-year certain period deferring the reduction to t = 120, the
$93,485.00 cash refund, and the 200-month derived refund period. It then works through the
notes’ “Known modeling pitfalls” list one test at a time — certain-period double counting,
the A/E factor on the wrong base, period versus generational, survival-measurement timing
at m = 12 and at m = 4 in advance, refund-balance timing, a hard-coded n_R,
joint-life independence, commutation applied to the wrong slice, and exclusion-ratio tax
modelled as a cash flow — plus the product invariants: no lapse decrement, no premium
income, no discounting, the stop rule on the younger life and the residual IF it leaves,
the certain_only expense stopping with the last payment, the in-force roll-forward,
result_cf() shape — the frame is t = 0 … proj_len() - 1, asserted row by row — and
that every model point projects.
The roll-forward test is not the usual tautology. check_lives_roll_fwd_resid rebuilds
each life’s survival from the monthly rates rather than telescoping lives_death — which
is defined as l(t) − l(t+1) and so closes for any lives_if whatever — and one test
proves the difference by replacing lives_if with a misindexed recursion in a throwaway
model instance and asserting the residual moves off zero and the no-argument
check_lives_roll_fwd() goes False with it.
python -m pytest tests/test_immediate_annuity_us.py -q
Verifying this copy#
tests/test_immediate_annuity_us.py asserts this model against the worked example in
technical-notes.md, and it ships inside this library — so it runs
against the copy you are holding, including any changes you have made to it:
python -m pytest tests/test_immediate_annuity_us.py -q
The whole suite, all twelve models and the shared conventions, is python -m pytest tests -q.
If you change an assumption and a test goes red, the worked example in the notes and the
model have parted company — which is the question this library exists to let you ask.