Implementation Notes#
Status: Draft, 2026-08-14. Built from
products/fixed_deferred_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 4.45% declared rate, the 0.25% GMIR, the 2.80% GMSV rate, the 9/8/7/6/5 surrender charge, the 10% free withdrawal, the linear-duration MVA and its symmetric cap, and the NAIC Model #805 floor — are sourced from the composite specimen. Every behavioural and expense assumption is a std standardization introduced for the reference implementation, and the shipped mortality table is illustrative, not a published basis. Replace them with company data before drawing any conclusion from the numbers.
This is the deferred annuity base chassis of the library. The fixed-indexed annuity, variable annuity and registered index-linked annuity models reference the surrender benefit composition order and the Model #805 floor construction implemented here rather than restating them.
Run it#
python products/fixed_deferred_annuity/run.py
python products/fixed_deferred_annuity/run.py 2 # the stress model point
Three lines to the same thing:
import modelx as mx
model = mx.read_model("products/fixed_deferred_annuity/MYGA_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_av() returns the account value and Model #805 floor table — the
first five of its columns are the notes’ worked example — and result_pols() the
in-force movements.
The model and both of its Spaces carry docstrings. model.doc describes the product and
the projection basis, model.Projection.doc holds the full mapping between the technical
notes’ symbols and the cells names, and model.Data.doc the input arrangement.
Monthly, not annual#
t counts policy months from 0: t = 0 is the first policy month, month t runs from
time t to time t + 1, and the frame is t = 0 … proj_len() − 1. The contract year is
the 1-based label policy_year(t) = t // 12 + 1, derived from t and never indexed by;
anniversaries fall at the start of t = 12, 24, …, so the closing account value at the
first anniversary is av_pp(11), and the five-year guarantee period covers t = 0 … 59.
Monthly is the library-wide grid, and it is the right one here on its own merits: the
contract credits interest daily against a quoted annual effective rate, while surrender
charges and the MVA step on contract-year boundaries, and monthly is the coarsest grid
that resolves both. It hits every
anniversary exactly and puts the guarantee-period-end window and the shock-lapse boundary
within one step. Finer grids buy nothing on a book-value chassis with no daily-valued
index.
Twelve monthly factors (1 + i_cr)^(1/12) reproduce the declared annual effective rate
exactly — av_pp(11) == 100,000 × 1.0445 to the twelfth significant figure — so the
discretization moves interest only within a month. Do not compound daily as well; a test
pins this.
Within a month, the notes’ processing order is: BOM — roll the free-withdrawal counters, apply the guarantee-period boundary, take the elective withdrawal, take annuitization elections, update the IRC §72 tax basis; EOM — credit interest, roll the Model #805 floor, apply decrements in the order annuitization, mortality, surrender std, with every decrement benefit valued on the post-crediting account value.
Inputs are external files#
The seven input CSVs live in this directory, beside run.py — not inside the model
folder. MYGA_US_S/ holds nothing but formulas:
products/fixed_deferred_annuity/
model_point_table.csv <- inputs live here
mort_table.csv
surr_charge_table.csv
surr_charge_age_cap.csv
rate_scenario.csv
withdrawal_table.csv
mva_factor_table.csv
run.py
README.md
MYGA_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
model point. 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, and Projection[1].data is Projection[2].data.
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 MYGA_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 |
|---|---|---|
|
Seven contracts, all on the anchor cell M60 ANB / NQ / $100,000 / 5-year period, differing only in the switches the notes make first-class parameters. Point 1 is the worked-example anchor; point 2 is the same contract on the 10% stress reference yield; points 3–7 carry Camp B, the registered-contract conventions, the net-of-charges MGSV convention with its interest-only free withdrawal and interest-credited cap [S5] [S6] [S8] [S9], the asymmetric cap and the declared-differential MVA |
contract terms sourced [S10] [S11]; behavioural switches std |
|
Annual mortality by attained age 40–120 and sex, with a |
std illustrative Makeham annuitant curve. Not a published table. The prescribed basis is 2012 IAM Basic with Projection Scale G2 and the VM-22 Table 6.7 factors R2 §6.B.8 R9, which may not be redistributed here — swap it in by repointing |
|
The initial and renewal schedules, keyed by |
initial 9/8/7/6/5 sourced [S10]; renewal 5/4/3/2/1 sourced [S2], adoption std |
|
The attained-age cap on the renewal charge, 4% at 94 down to 0% at 98–100 |
sourced [S1] [S2] |
|
Three deterministic scenarios keyed by |
std; the index is a state-filed variable [S8] [S12], so the model takes a scalar series rather than hard-coding one |
|
Three withdrawal programmes keyed by |
worked example [S10] [S11]; the variant std |
|
The declared-differential duration factors |
specimen table [S14] |
Time-keyed columns under the 0-based index#
Two input files carry a column literally named t, and in both it is the model’s own
policy-month index, so it moved with the frame when the model went 0-based:
withdrawal_table.csv, columnt— the month a scheduled withdrawal is taken, an exact-key lookup inwd_scheduled_pp(t). The worked example’s $4,000 was re-keyed from the old month 13 tot = 12and thechargedprogramme’s $20,000 from 30 tot = 29(the first month ist = 0, so every projected month moved down by one). Thenoneprogramme’s placeholder row, which sat at the old issue instant 0 with a zero amount, stays att = 0: the instant merged into month 0, and a $0 row is inert.rate_scenario.csv, columnt— the month from which a row’s rates hold, read as a step function inscenario_rate(t, name). All three shipped scenarios are a single row att = 0, which under the merge is stillt = 0; a row at a later old monthmwould have moved tom − 1.
The remaining keys are not time indices and were left alone: surr_charge_table.csv is
keyed by the contractual 1-based contract_year, reached through
surr_charge_year(t) = policy_year(t) − n (gp_index(t) − 1); mva_factor_table.csv by
years_remaining, reached through mva_term(t); surr_charge_age_cap.csv and
mort_table.csv by attained age; and model_point_table.csv carries no time column —
every point is new business, opening the frame at t = 0.
Every model point projects to completion, and a test asserts it. Between them they exercise
all three MVA formula families, all five cap rules, both renewal architectures, both
Model #805 withdrawal conventions, both free-withdrawal rules and both settings of
free_wd_mva_exempt — the notes’ own headline finding is that the cross-carrier
divergence lives in exactly those switches, so none of them is dead code.
Naming#
Cells follow lifelib’s basiclife/BasicTerm_S and savings/CashValue_SE wherever those
models have an analogue: pols_* for policy counts, av_* for account values, plural nouns
for cash flows, *_rate for rates, *_pp for per-contract amounts, *_at(t, timing) for a
quantity read at a point inside the month, plus model_point, age_at_entry, sex,
policy_term, proj_len, duration_mth, duration, age, net_cf, result_cf,
check_av_roll_fwd.
The technical notes use compact actuarial symbols instead; the full mapping lives in the
Projection Space docstring. Six cases needed care, and every one of them is a collision the
notes themselves carry:
Notes |
Cells |
Why |
|---|---|---|
|
|
|
|
|
|
|
|
The excess of a withdrawal over the allowance and the excess of the whole account value over it are both written |
|
|
|
|
|
|
|
|
One concept, three labels across the sources; MGSV is the library’s term |
Two further names carry a library-wide convention that does not follow the notes’ indexing, and in both cases the notes’ own quantity survives under a second name rather than being dropped:
Notes |
Cells |
Why |
|---|---|---|
|
|
|
|
|
|
The roll-forward self-checks follow CashValue_SE: check_av_roll_fwd() and
check_pols_roll_fwd() take no argument and return a bool over every projected month,
so one test can call the same check across every model in the library. The signed per-month
residual — genuinely more useful once a check has failed — is check_av_roll_fwd_resid(t)
and check_pols_roll_fwd_resid(t), and the bools are defined in terms of them against the
tolerances check_tol_av (1e-6) and check_tol_pols (1e-12).
pols_maturity and the projection horizon — the notes give none#
The technical notes state no projection length, and under the rollover architecture there
is nothing in the contract that ends it: the guarantee period renews indefinitely and the
notes write the base-lapse pattern as a repeating five-year cycle with no terminal date.
The model runs to the contract anniversary at attained age 100 std —
proj_len() = 12 × (maturity_age − age_at_entry()) projected months, 480 on the anchor
cell, so the frame is t = 0 … 479 and the last month ends at that anniversary. Age 100
is not arbitrary: it is the last attained age in the sourced cap band on the renewal
surrender charge — 4% at 94, 3% at 95, 2% at 96, 1% at 97, 0% at 98–100 [S1] [S2]. The cap
reaches zero at 98; 100 is where the sourced band stops, so past it the model would be
extrapolating a schedule no source states. It is also well past the Model #805 §8 deemed
maturity date R1. The survivors at that anniversary are annuitized out through
pols_maturity(t), zero in every month but the last, so that
pols_if(t) − pols_if(t+1) = pols_annuitization(t) + pols_death(t) + pols_lapse(t) + pols_maturity(t)
closes for every t — pols_if(t) is the start-of-month count, so it opens the row, the
four exits are taken during the month, and the next month opens on what is left. Including
the last month, where the block would otherwise appear to lose
lives with no cause. This is bookkeeping determined by the horizon, not a new assumption; the
name and the construction follow BasicTerm_S.pols_maturity and Term_US_S. It matters
little in practice: with a 90% shock lapse every five years, in-force falls by roughly a
factor of ten per cycle — at the end of the fifth contract year pols_if_at(59, "AFT_DECR")
= 0.9077, at the end of the tenth (t = 119) 0.0808, at the end of the twentieth
(t = 239) 5.6e-4 — so what enters the horizon month, pols_if(479), is 3.0e-9 of the
contract and the choice of horizon moves nothing
material. Under annual_redeclare the shock happens once, so it matters more; run
both architectures before quoting a duration, as the notes’ first sensitivity says.
The issue instant is not a row#
The notes’ cash flow ledger indexes the single premium, the acquisition commission and the
premium tax “at t = 0”, and the opening values P, 0.875 × P and 1 seed the three
recursions. In the model those three flows are beginning-of-month flows of the first
policy month, t = 0, and the opening values are read through the opening timings —
av_pp_at(0, "BEF_WD") == av_pp_init(), pols_if(0) == pols_if_init() — while
av_pp(0) and mgsv_pp(0) are the closing balances one month of crediting later. There
is no separate row for the instant: result_cf() has one row per projected month, t = 0
to proj_len() - 1, and net_cf(0) on the anchor cell is the +98,000 deposit net of
commission less month 0’s own claims and maintenance expense. The account value
roll-forward treats the deposit as the premium in term of month 0, opening from an empty
block, so check_av_roll_fwd() closes there too. This is the library-wide frame; the
account value, floor and in-force reported at any t are the same numbers the notes
give at that t.
The 30-day window sits after the boundary, not before it#
The product spec describes the free-out window as “the 30 days before each guarantee
period ends”; the technical notes’ processing order places it in the month after the
period closes — “if the previous month ended a guarantee period (t ≡ 0 mod 12n, t > 0)”.
On a monthly grid the two readings differ by one step and cannot both be implemented.
The technical notes are the specification, so the model follows them: in_gp_window(t)
is true at t = 60, 120, 180, …, the first month of each renewal period. This is also the
reading that makes the rest of the notes consistent — the annuitization rule is stated as
“only in a 30-day window, t ≥ 12”, and the prescribed base lapse puts the 75% shock in
contract year 6, which is months t = 60 … 71. Under annual_redeclare there is
exactly one window, t = 60, the month after the initial term.
The annuitization transfer is valued at BOM, though the notes name SV(t)#
The notes say annuitization “transfers AV'(t) (in the window) or SV(t)” out of the
accumulation block, and they exclude the MVA from it. But SV(t) = AV(t) + M(t) − C(t) is
composed on the post-crediting account value, while the same notes make annuitization an
elective BOM transaction. On a monthly grid the two readings differ by one month’s
interest and cannot both be implemented.
The model annuitizes at BOM std: annuitization_pp(t) is AV'(t) in the window and
AV'(t) − sc(y) × E(AV'(t)) outside it, and claim_from_av_pp(t, "ANNUITIZATION")
releases the matching AV'(t). Paying the literal SV(t) against a BOM exit would hand
the annuitant (AV(t) − AV'(t)) × (1 − sc(y)) of interest the block never credited to
those contracts, and that would show up as a positive claims_over_av with no binding
Model #805 floor behind it.
It is inert on the shipped model points — a(t) is 1.0% in the window and 0% everywhere
else, so the non-window branch never carries a contract — but it is the branch a user
switching on mid-term annuitization would land in first, so a test pins it open. If your
admin system settles annuitizations at EOM instead, return av_pp(t) − surr_charge_pp(t)
there and move the claim’s release basis with it.
The shock lapse is spread across the shock contract year#
The notes give the base lapse annually by contract year and convert it with
w_base_m = 1 − (1 − w_base_annual)^(1/12). Applied to the 90% shock year that is 17.5% per
month for twelve months, not a point event at the window. Only the first of those months —
the window itself, t = 60 — is free of surrender charge and MVA; months t = 61 … 71
carry the fresh 5% renewal charge while still lapsing at 17.5% a month.
That is what the notes prescribe and the model implements it literally, but it is worth knowing before quoting a liability duration: a design that concentrates the shock in the window month would pay out sooner and would pay the full account value on all of it. The architecture switch, not this discretization, is the first-order decision — but this is the second-order one.
T(t) and the geometric tau are the same number here#
The notes define the linear-duration T(t) as (days to the end of the current contract year
÷ 365) + whole years remaining in the MVA period, and the geometric tau as days to
maturity ÷ 365.25 — two different day-count conventions, from two different carriers. On a
monthly grid the first collapses algebraically to (12·n·k − (t + 1))/12 — the time from
the end of month t to the end of the current MVA period, k = gp_index(t) — which is
exactly the second on exact twelfths. mva_term(t) therefore serves both branches, and a test asserts
the identity. Reconciling to an admin system that runs actual days will reintroduce the
difference; that is a discretization consequence, not a modelling choice.
av_initial and mgsv_initial are derived, not input#
The notes list av_initial, mgsv_initial and tax_basis_initial as model point
attributes. Two of the three are fixed by rules the notes also state: 100% of premium is
credited with no front-end load [S5] [S10] [S16], and the Model #805 floor starts at 87.5% of
gross consideration R1 §4.A(2). Shipping them as data would let a model point silently
violate the statute. They are computed instead, from the References load_prem_rate (0.0)
and net_consideration_ratio (0.875). Only tax_basis_initial stays a column, because it
genuinely varies with tax status.
free_wd_mva_exempt = False moves the surrender charge too#
In the market the flag is about the MVA: the two registered contracts apply the
adjustment to free-amount withdrawals, the retail MYGAs do not
[S2] [S3] [S4] [S9] [S10] [S15] [S16]. The technical notes are explicit that setting it
False gives E(t) = AV(t) and collapses the surrender benefit to
max(AV(t) × (1 + μ(t) − sc(y)), MGSV(t)) — the multiplicative form — which moves the
surrender charge onto the whole account value as well.
The model implements the notes’ reading, because that identity is the one the notes use for
their dimensional-consistency check and a test pins it on model point 4. If you need the
market split — MVA on the free amount, charge not — split surr_excess_pp into two bases;
nothing else has to change.
What is not implemented#
Named here so the gaps cannot be mistaken for oversights; the model docstring carries the same list.
The RMD module. The notes give the charge and MVA exemption but no RMD amount formula.
Nursing-home and terminal-illness waiver withdrawals. No incidence basis is given.
VM-22 Table 6.2 partial-withdrawal rates. The retrieved table is the Qualified column only and its 80-and-over row was truncated; the notes say not to present it as a non-qualified assumption. The base run withdraws 0% std, and the free-withdrawal utilization variant
free_wd_utilis the switch that is implemented.The
greatest_offree-withdrawal rule. Described, no formula.The gross-up solve for a stated net check.
wd_pp(t)is gross by construction.Model #805 §6’s paid-up annuity leg. Noted but not implemented in the notes themselves.
check_margin(). The product carries no contract charges and the model projects no asset side, so there is no charge-versus-cost decomposition to check.check_av_roll_fwd()andcheck_pols_roll_fwd()are implemented — no argument, returningTrueover every projected month — and both close to floating point, withcheck_av_roll_fwd_resid(t)/check_pols_roll_fwd_resid(t)giving the signed residual.Stochastic scenarios. The reference yield and competitor rate are read from a deterministic scenario table.
Standardizations used#
Everything in this list is std: the monthly grid and the BOM/EOM placement of each
step; the decrement order annuitization → mortality → surrender; the mapping of VM-22
Table 6.5 onto a five-year architecture (1% inside the period, 75% at expiry, repeating
under rollover); the 0.35 best-estimate Φ_MVA against VM-22’s prescribed 0; the renewal
declaration rule max(GMIR, MR − s_ren) with s_ren = 0; the 1.0% annuitization take-up at
each window against VM-22’s prescribed 0%; the 0% base partial withdrawal; the 2.00%
acquisition commission; the $50 per contract per year maintenance expense inflating at 2.5%;
the 0% premium tax; the exogenous reference-yield and competitor-rate scenarios and the
i0 = 5.00% issue lock; the illustrative mortality table; and the attained age 100
projection horizon. Non-qualified tax status and the pct_av / sym_sc /
linear_duration / rollover / gross switch settings on the anchor cell are also
std picks from a genuinely split market — the model point table carries the
alternatives.
Tests#
tests/test_fixed_deferred_annuity_us.py asserts all seven rows and five columns of the
notes’ worked example table to the cent, at the notes’ own 0-based months t = 0, 1, 2, 11, 12, 23, 29; the notes’ own exactness checks (AV(11) = 100,000 × 1.0445, AV(23) = 100,450 × 1.0445, MGSV(23) = (89,950 − 4,000) × 1.028) to the twelfth significant figure; the
month-29 surrender trace line by line, cap and floor both inactive; the month-5 stress
trace, where the symmetric cap bites at −8,298.07 and the Model #805 floor then adds
3,111.90; one registered contract’s geometric-branch factors 1.01897 and 0.96944 and its
−2.06% expense-adder case [S4]; both roll-forwards at every month t = 0 … proj_len() − 1
and the frame itself (proj_len() rows, t = 0 to proj_len() − 1); and
one test per entry in the notes’ “Known modeling pitfalls” list — composition order, the
free-amount/MVA interaction, gross versus net withdrawals, the two Model #805 withdrawal
conventions, the 15 bp floor (and that the statute states a minimum, not a cap), the
surrender-charge clock on renewal, and the mortality plumbing.
Two tolerances are worth knowing. Money is asserted to 0.006 rather than 0.005 because
the notes round half-up for display and AV(23) = 104,920.025 sits exactly on the boundary.
And E(29) is asserted to 0.01, because the notes state that “the surrender traces below
are computed from the cent-rounded values shown”: they print
107,229.09 − 10,492.00 = 96,737.09 where full precision gives 96,737.0843. Everything
else in the traces agrees to well under a cent.
python -m pytest tests/test_fixed_deferred_annuity_us.py -q
Verifying this copy#
tests/test_fixed_deferred_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_fixed_deferred_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.