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 policy month t with one column per
cash flow line; result_pols() returns the survival probabilities and payment factors
behind it.
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, 1-based, running
1 … proj_len(). The notes set the grid (t = 1, 2, … months from the annuity date) and
the product forces it: instalments fall monthly on the anchor cell, the COLA steps at
month 13, 25, …, 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.
Note the contrast with lifelib’s CashValue_SE, whose months are 0-based. Here
t = 0 is not a projected month: it is the base case of each recursion
(lives_if(0, life) = 1, cum_annuity_pp(0) = 0), matching the notes’ own 1-based index.
proj_len() runs to the notes’ age stop rule on the youngest covered life — 696
months on the anchor cell’s 65/62 pair — 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.
That rule stops the projection one month before any life attains ω = 120: age(t, life)
advances on policy anniversaries, so at t = 696 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(696) = 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 |
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 |
|
|
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(t) 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 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(t, life)with the deterministic step function1{t < death_mth(life)}, a blankdeath_mthmeaning 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 = 1, 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_A’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 — NYL’s published rule S5. Model point 5 kills the joint annuitant in month 14 under a 10-year certain period and pays the full instalment until
t = 120, dropping to δ only att = 121.A commutation reduces certain-period instalments only, because
θ_cumis multiplied byC(t): att = 121the 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 = 121.
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)) 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 at n_eff 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 240 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)) 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(121) = 0 and zero expense from t = 121 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 the end of montht; an advance instalment falls at the start of montht, which is the end of montht − 1, so survival is measured att − 1— at every frequency. 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 month 14: the arrears run stops paying att = 14, the advance run att = 15.The notes write that advance point as
t − 12/m, “one full payment period earlier”. 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 = 1, 4, 7, …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 − 12/mliterally atm = 4reads survival three months too early and pays the quarterly instalment due at the start of month 4 to a life the projection has already killed off in month 3. Model point 14 is that contract: quarterly in advance, single male 65, dying in month 3. It pays $1,500.00 in the first year, not $3,000.00, and a test asserts both the payment months1, 4, 7, 10, 13and the survival monthst − 1behind them.Refund balance. The cash refund nets instalments already paid:
G(t−1)on arrears, butG(t)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 usingG(t−1)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 ∈ T : G(t) ≥ P} — payments continue
until cumulative payments equal the premium, which is NYL’s 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, l_alive), with the l_alive leg dropped on
certain_only so the expense ends with the contract (above); the
anniversary-of-annuity-date COLA rule
(NYL’s “one year after the first income payment” alternative 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 = 14 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 = 121, 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, 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−1) − l(t) 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.