Implementation Notes#
Status: Draft, 2026-08-14. Built from
products/deferred_income_annuity/technical-notes.md;
the product it implements is specified in
product-spec.md. The income
phase is the payout chassis of
products/immediate_annuity/technical-notes.md,
implemented in SPIA_US_S — this model carries that
model’s names for every shared concept.
This is a mechanics demonstration, not a pricing or reserving result. The contractual elements — the paid-up income slice each premium buys at then-current rates, the 100% return-of-premium deferral death benefit, the one-time ±5-year income start date adjustment, six months of payment acceleration, commutation with the life-contingent tail preserved, the QLAC restriction set and its $210,000 2026 premium limit, the $50 maintenance expense escalated at 2.5%, and the absence of any lapse or annuitization decrement — are sourced. The entire pricing kernel is not. No purchase-rate table exists: the Insurance Compact expressly relieves the insurer of disclosing the deferral-period mortality and interest basis R13 §1.B(1)(a), so the 4.75% pricing rate, the 6.0% expense and profit load, the mortality table, the illustrative payout and return-of-premium factors, the 100 bp repricing spread and the 50 bp commutation margin are all std constructions. Replace them with company data before drawing any conclusion from the numbers.
Run it#
python products/deferred_income_annuity/run.py
python products/deferred_income_annuity/run.py 5 # life with installment refund
Three lines to the same thing:
import modelx as mx
model = mx.read_model("products/deferred_income_annuity/DIA_US_S")
model.Projection[1].result_annual()
Projection takes a point_id; Projection[1] is the worked-example anchor cell —
Female 60 ANB, nonqualified, Life with Cash Refund, $100,000 at issue plus $50,000 at
the start of policy year 6, income start at attained age 80. result_cf() returns a
DataFrame of monthly cash flows indexed by t; result_pols() the survival
probabilities and payment factors behind it; and result_annual() the technical notes’
own annual display grid, which is what the worked example prints.
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, and t is 0-based#
t counts policy months from issue, running 0, 1, 2, … proj_len(). Monthly is
what the notes set (“Projection frequency. Monthly, indexed t = 0, 1, 2, … from
issue”) and what the product forces: the modal payment frequency is monthly, and both
the 13-month minimum deferral and the 13-month premium cut-off are expressed in months.
The 0-based index is a deliberate departure from Term_US_A and
SPIA_US_S, which are 1-based. The reason is that in these notes T is a
month index, not a count: the anchor cell’s income start month is T = 240 and its
premiums fall at months 0 and 60. Renumber to a 1-based grid and T = 240 silently
becomes the 241st month, moving every option window, premium date and payment date by
one. Month 0 is a real projected month here — the first premium arrives at its start,
deaths occur during it, the maintenance expense accrues in it — not a recursion base
case.
Two consequences worth holding on to:
|
|
|
|---|---|---|
|
survival over |
the same — survival over |
|
|
|
The survival cells means exactly the same thing in both models; only the death density
shifts, because month t spans elapsed [t−1, t) there and [t, t+1) here.
proj_len() runs to the limiting age ω = 120 of the youngest covered life — the
Reference omega_age, 719 months on the anchor cell’s age 60 — or to the end of the
guarantee period if that is later.
Inputs are external files#
The six input CSVs live in this directory, beside run.py — not inside the model
folder. DIA_US_S/ holds nothing but formulas:
products/deferred_income_annuity/
model_point_table.csv <- inputs live here
premium_schedule.csv
mort_table.csv
improvement_scale.csv
payout_factor_table.csv
rop_factor_table.csv
run.py
README.md
DIA_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.
Reference |
Cells |
File |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
The trade-off: the model is not portable on its own. Copy
DIA_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 edited or swapped in place — point Data.mort_table_file at another
same-schema file and the projection follows, with no formula change.
File |
Contents |
Provenance |
|---|---|---|
|
Seventeen contracts. Point 1 is the worked-example anchor cell; points 2 and 3 are the two readings the notes leave open; point 4 is the death-benefit fork; 5–10 walk the payout forms, joint triggers and COLA; 11–12 a compliant and a breaching QLAC; 13–15 the three in-force options; 16 generational mortality; 17 advance timing |
anchor cell std, technical notes “Worked example” |
|
One row per premium slice, keyed by |
anchor schedule std |
|
Annuitant mortality by age and sex. Ages 60–84 female are band-constant rates that reproduce the notes’ five-year survival anchors exactly; everything else is a geometric continuation, and male rates are the female curve set forward three years |
std, not the 2012 IAM Basic table — see below |
|
Generational improvement by age and sex, 1% grading to 0 at age 105 |
std illustrative, not Projection Scale G2 |
|
|
worked example std; the other rows computed from |
|
|
worked example std; the other rows the mid-band approximation of equation (3) |
The mortality table deserves a sentence of its own. The notes forbid embedding licensed
tables and prescribe the 2012 IAM Basic table with Projection Scale G2 — which is in any
case printed in no source this library holds, A-821 printing the loaded Period Table
only REG-R153. What is shipped instead is an illustrative curve chosen so that the
model reproduces the notes’ worked example: over ages 60–84 its rates are the constant
annual rates implied by the notes’ five-year survival anchors (0.975, 0.920, 0.838,
0.715 from age 60, and 0.780 over 80–85), so _5p_60, _20p_60, _15p_65 and every
monthly value between them come out exactly. Past age 85 the notes give nothing, so the
curve continues geometrically at a rate calibrated so that equation (2) at the income
start age returns 8.600 — the notes’ own illustrative payout factor. That is what the
notes mean by calling their factors “mutually consistent”; a test pins it.
Naming#
Cells follow lifelib’s basiclife/BasicTerm_S and savings/CashValue_SE where those
have an analogue, and — for the whole income phase — they follow SPIA_US_S
exactly, because a DIA in payment is a SPIA. payment_factor, payment_factor_life,
certain_floor, certain_mths_eff, certain_mths_refund, annuity_pp_sched,
annuity_pp, annuity_pp_paid, cum_annuity_pp, lives_if, lives_death,
lives_if_last, lives_death_last, payment_surv_mth, commute_frac_cum,
commuted_value, commutations, claim_pp(t, kind), claims(t, kind=None),
result_cf, result_pols are all the chassis’ names with the chassis’ meanings.
The technical notes use compact actuarial symbols; the full mapping lives in the
Projection Space docstring. Eight cases needed care:
Notes |
Cells |
Why |
|---|---|---|
|
|
Survival probability and payment factor differ only by case |
|
|
|
|
|
Indexed by month, not policy year: a DIA’s income changes when a premium is paid |
|
|
Three different quantities the notes all call |
|
|
Inherited from the immediate-annuity notes: |
(no symbol) |
|
The weight the chassis has no need for — see below |
(no symbol) |
|
Its mirror image at the commutation date — see below |
|
|
A SPIA’s annuity date is its issue date; here they are twenty years apart, so the generational anchor is named for the one that matters |
Three names are the library’s rather than this model’s, and are spelled its way here:
the mortality A/E deviation factor is mort_ae_factor, the limiting age of the
mortality table is omega_age, and every self-check is a no-argument cells
returning a bool over all projected t — check_lives_roll_fwd(),
check_income_roll_fwd(), check_payment_factor(), check_commutation_value() — each
with a check_*_resid(t) companion returning the signed float residual at one t. The
bool is what a cross-model test calls; the residual is what a debugging session reads
after it fails, and the bool is defined in terms of it so the two cannot disagree. There
is no check_av_roll_fwd() here, because there is no account value to roll forward.
surv_to_payout and commute_weight — the chassis formulas that do not carry across#
SPIA_US_S writes the payment factor as Φ(t) = max(C(t), L(t)) with C(t) a
bare 0/1 indicator, and the DIA notes inherit that wording without adjusting: “L_pay(t)
is the form-specific payment-survivorship weight defined in the immediate-annuity notes:
l(t) for a life-only payment, certain inside a guarantee period, …”.
A SPIA is already in payment, so its guarantee is certain full stop. A DIA’s is not.
The guarantee period only begins if the contract reaches the income start date; an
annuitant who dies during the deferral takes the return-of-premium benefit and no
instalment is ever paid. On an expected basis the certain instalments therefore carry
l_last(T), and this model writes
payment_factor(t) = max(certain_floor(t) * surv_to_payout(), payment_factor_life(t))
On the anchor configuration l_last(240) = 0.715, so copying the chassis formula across
unchanged would put the payment factor at 1.000 instead of 0.715 on every guaranteed
instalment of a period-certain or installment-refund contract — a 39.9% overstatement
of the guaranteed leg. It is the same weight equation (1) applies to
the pricing side through _d p_x, and the immediate-annuity notes’ own warning is not to
carry their recursions across unexamined. A test pins Φ = 0.715 inside the guarantee on
both such model points.
This does not touch the anchor cell, whose cash-refund form has no certain floor at all.
commute_weight is the same idea one date later. A commutation is exercised by an
owner who is alive at t_c S4, so it can only extinguish the guaranteed instalments
of the l_last(t_c) sub-cohort — while the guarantee itself belongs to the whole
l_last(T) cohort that reached the income phase. On point 15 those weights are
0.680338 and 0.715000. Suppressing the guarantee with the second and paying the
commuted value with the first cancels 4.85% more guaranteed liability than the commuted
value buys — 6,898.37 of present value at i_c on that cell — so the model splits the
cohort explicitly:
E[income](t) = pols * [ inst(t) * (Φ(t) − w_c(t)) not commuted
+ inst_paid(t) * w_c(t) ] commuted
The economics is concrete: a contract whose annuitant died between T and t_c is
paying its period-certain guarantee to a beneficiary and could not have commuted at all.
check_commutation_value() asserts the exchange closes — the present value at i_c of
the instalments suppressed equals the commutation paid — and a test pins it.
The joint deferral death benefit is priced on the curve it is paid on#
Equation (3) writes A_rop on a single life x, and the notes never restate it for a
joint contract even though equations (15) and (16) both carry a P·A_rop term. This
model resolves that gap by pricing the benefit it actually projects.
On a joint contract the return of premium attaches to the last death — “if one
annuitant dies in deferral the contract continues on the option chosen at issue” S2, so
the benefit falls due only when the contract ends, and claims(t, "DEATH") is weighted by
lives_death_last(t). rop_factor_calc therefore runs the mid-band approximation over
lives_if_last, not over the primary life’s curve. On point 8 (F60 + M63) the two
differ by a factor of two and a half:
Exposure |
20-year probability |
|
Joint income |
|---|---|---|---|
primary life dies |
0.285000 |
0.161605 |
42,358.20 |
last death (what is projected) |
0.128767 |
0.064820 |
47,711.31 |
Pricing the primary life’s density while projecting the last death’s would charge for
two and a half times the exposure and understate the joint income by 11.2% — which is
the notes’ own listed pitfall (“its cost belongs in the purchase rate (4) and in the
projected cash flows”) read strictly, as requiring the same benefit in both places. A
test reconciles the present value of the projected death claims against
Σₖ Pₖ·v^tₖ·l_last(tₖ)·A_rop(tₖ), to the accuracy of the five-year mid-band
approximation. On a single-life contract l_last is the primary curve, so nothing in
the worked example moves.
The alternative reading — pay the deferral death benefit on the first death and price it there — is available in the notes through the spousal-continuation switch, and is not taken: the base is 100% election of the death benefit on a contract that S2 says continues. Both the pricing and the projection are std on this point.
Cash refund prices as certain-and-life and projects as life-contingent#
n_g is three different things in the notes and conflating them double-counts the
guarantee:
guarantee_mths_pricing()— the guarantee fed into the pricing kernel by equation (5),n_g = CP(T)/B. It putsBon both sides, so it is resolved by fixed-point iteration from a life-only start, the notes’ own prescription and their listed pitfall (“failing to iterate leaves a systematic bias”). 40.669 months on the anchor cell.guarantee_yrs()— the same quantity onceBis known, the 3.3891 years the worked example quotes, exhausting the cash-refund guarantee at attained age ≈ 83.4.certain_mths_eff()— the projection’s certain floor, which is zero on a cash refund. A cash refund is a lump-sum shortfall paid at death, not a stream of guaranteed payments. Equation (5) treats it as certain-and-life for pricing only — the notes say so outright, offering equation (6) as the exact alternative — and carrying that into the projection would pay the guarantee twice.
Installment refund is the form that really does pay a certain stream, and there
certain_mths_eff() is certain_mths_refund(), searched from the instalment schedule:
41 months on this cell, because 40 instalments fall short of the 150,000 of cumulative
premiums and 41 overshoot it. std — the monthly grid rounds the guarantee up to a whole
instalment rather than trimming the last one, which is what the notes’ “exactly a
certain-and-life annuity with n_g = CP(T)/B” framing implies. SPIA_US_S
trims instead; that is a divergence between the two notes files, not a bug in either
model.
The A_rop(60, 20) divergence is shipped, not resolved#
The notes state that their illustrative return-of-premium factors were “computed from
the same survival anchors with a mid-band death-timing approximation”, and print
A_rop(60, 20) = 0.157900 and A_rop(65, 15) = 0.180200. Run that recipe on those
anchors and:
Slice |
Notes print |
Recipe gives |
Agreement |
|---|---|---|---|
age 65, 15-year deferral |
0.180200 |
0.180241 |
to four decimals |
age 60, 20-year deferral |
0.157900 |
0.161605 |
not to two |
One of the two is arithmetically consistent with the stated recipe and the other is not.
Rather than choose, the model ships both: rop_factor_table.csv carries the printed
factors and model point 1 reads them, reproducing the worked example; model point 3 is
identical except that factor_basis = "formula" computes them, and its income comes out
at 44,106.53 against the worked example’s 44,259.65. A test pins the gap open in both
directions. Neither value is “right” — both are std.
Note what this isolates. The payout factor agrees on both bases: the shipped mortality
curve is calibrated so that equation (2) returns the notes’ 8.60, exactly as the notes’
claim of mutual consistency requires. A_rop(60, 20) is the one place left over.
The purchase-rate rounding is worth 3.6 cents#
The worked example computes B₁ = 100,000 × 0.321765 = $32,176.50 from a purchase rate
printed to six decimals. At full precision pr₁ = 0.3217647 and the same premium buys
$32,176.47; over both slices the difference is 3.6 cents on a $44,259.65 income —
enough to fail a cent-level assertion on the two E[income] rows.
The model point column purchase_rate_dp carries the convention: 6 on point 1, blank
(full precision) on point 2. Point 1 reproduces the notes to the cent, point 2 shows what
the rounding is worth, and a test asserts both. Purchase rates really are administered to
a finite precision, so neither reading is unreasonable — but the choice is a std
convention and should not be invisible.
What the in-force options implement, and what they do not#
All three options are implemented as deterministic single exercises named on the model point and off by default:
Income start date adjustment — equations (10) and (11), actuarial equivalence at the exercise month on
i_e = Baa − 100 bp. Point 13 defers the start by five years at month 120 and the income rises by a factor of 1.880. The two refinements the disclosed recipe does not mention — that the ROP exposure changes with the deferral length and thatCPis unchanged so the derived guarantee period shifts — are flagged rather than modeled, as the notes require.Payment acceleration — six monthly payments in one sum, then five months without, with equation (12)’s cost reported by
accel_cost(). It is a timing shift, expressly “not a liquidity feature”; modelling it as a withdrawal is a listed pitfall. The six instalments are all carried at one payment factor — the lump sum’s own date, so the five pulled forward are unconditional on surviving to their scheduled dates, which is exactly the mortality element equation (12) prices. The test asserts the lifetime invariant rather than the block: point 14 is point 3 with the acceleration and nothing else, and their whole-of-payout income outgo differs by 153.52 on a 372,585 base —inst × Σⱼ [l(t_a+1) − l(t_a+1+j)]exactly. Equation (12)’s interest element does not appear because the projection is undiscounted, which is why 153.52 sits belowaccel_cost() = 435.11rather than on top of it. Paying those five instalments to the whole initial cohort would put 6,079.84 there — fourteen times (12)’s cost, and a withdrawal in all but name.Commutation (extended case) — equations (13) and (14), one-sided in rates per the Compact’s stated intent. After a 100% commutation the guaranteed payments of the commuting cohort stop and, if the annuitant is alive when the would-be guarantee period ends, income resumes until death: point 15’s income drops from 1,640.68 to 79.54 a month over months 252–359 and returns to 919.16 at month 360. The 79.54 is not a rounding residue — it is the guarantee still running to the beneficiaries of contracts whose annuitant died between
Tandt_c, the cohort that could not have commuted.
What is not implemented is the incidence and selection layer — the notes’ 1.5% p.a.
adjustment take-up h_adj with its 60/40 direction split and M_def/M_adv rate
multipliers, the 0.90/1.10 health-selection multiplier sel_mult, the 2% p.a.
acceleration take-up h_acc and the 1.5% p.a. commutation take-up h_com. Each of them
splits the model point into an exercised and an unexercised cohort, which needs cohort
tracking this model does not carry. They are also, per the notes, the assumptions with
no experience behind them at all — and AG 33 prohibits experience-based elective
incidence in a CARVM run, so feeding them into a reserve is a defect rather than a
refinement. The model docstring lists them, along with equation (6)’s exact cash-refund
factor, equation (16)’s convertible-joint pricing, spousal continuation, premium
admissibility limits and every valuation layer.
The QLAC overlay generates no cash flows#
It caps premiums, restricts forms, constrains T, disables features and raises
compliance flags — nothing more. qlac_room(t) is the 2026 limit of $210,000 less
cumulative premiums less premiums paid to any other intended QLAC, and qlac_flags()
returns the conditions the contract fails. There is no percentage-of-account-balance
test: SECURE 2.0 § 202(a)(1) directed its elimination and the codified text has no
percentage rule, so a 25% test, a $125,000 or $130,000 cap and an RMD age of 70½ are all
superseded arithmetic that pre-2023 insurer guides still print. Point 11 sits exactly on
the limit and flags nothing; point 12 pays $250,000 and flags the breach — and projects
identically apart from the premium itself, which is the point.
Standardizations used#
Everything in this list is std: the pricing rate i_p = 4.75% and the expense and
profit load L = 6.0%; the mortality table and its post-age-85 continuation, the
improvement scale, and the A/E factor mort_ae_factor of 1.00; the illustrative payout and
return-of-premium factors and the choice to round the purchase rate to six decimals; the
equation (5) approximation for both refund forms; arrears timing and the end-of-month
death benefit; the deferral death benefit attaching to the last death on a joint
contract and A_rop priced on that same last-death curve, equation (3) being written
on a single life and never restated for a joint contract; the acceleration lump sum
carrying the payment factor of its own date on all six instalments, and the commutation
extinguishing only the l_last(t_c) cohort’s guarantee; the 100 bp repricing spread and
the assumption of no Baa movement since issue;
the 50 bp commutation margin (also unverified); six accelerated payments; the
maintenance expense weighted by l(t) rather than by the chassis’ max(C, l_alive);
rounding the installment-refund guarantee up to a whole instalment; and the anchor cell
itself. Sourced, by contrast: the income-slice mechanic and its purchase-rate rule
R13 §3.B(1)(b) S3, the 100% return-of-premium deferral death benefit R13 §3.I(1)(a),
the ±5-year adjustment and its disclosed repricing inputs S1 S2, the six-month
acceleration S1 S4, commutation with the tail preserved S4 S5, the whole QLAC
restriction set R1 R2 R3, the $50 maintenance expense escalated at 2.5% R9, and the
absence of lapse and annuitization decrements R9.
There is no lapse decrement, and adding one is a defect. “Not conservatism — there is
no surrender benefit to elect. A nonzero lapse assumption in a DIA model is a defect, not
a margin.” There is no lapse_rate, no pols_lapse, no surrender cash flow row, no
account value and no credited rate anywhere in this model, and a test asserts that no
cells or Reference name even looks like one.
Tests#
tests/test_deferred_income_annuity_us.py asserts all nine rows and every column of the
notes’ projection table, the slice pricing of equation (4) against each printed
intermediate factor, the derived guarantee period, the 21.2% death-benefit fork, the
policy-year-6 trace, the survival anchors, both shipped divergences, the arrears payment
timing and the full-CP(T) refund in the first payment month, the certain floor’s
conditioning on reaching the income phase, the installment-refund derived period, the
joint triggers, the joint A_rop reconciliation against the projected death claims, the
COLA, all three in-force options — including acceleration’s lifetime-outgo invariant and
the commutation exchange identity — the QLAC overlay, the generational construction, the
in-force and income roll-forwards, that the cross-model names (mort_ae_factor,
omega_age, the no-argument check_*() bools and their check_*_resid(t) companions)
are the library’s canonical ones, and that every model point projects.
python -m pytest tests/test_deferred_income_annuity_us.py -q
Verifying this copy#
tests/test_deferred_income_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_deferred_income_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.