Implementation Notes#
Status: Draft, 2026-08-14. Built from
products/fixed_indexed_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 0% index credit floor, the surrender charge and bonus vesting schedules, the
b/(1+b)clawback, the ratio-form MVA, the 5.00%/2.00% guaranteed rollup, the 150% stacking factor, the 0.95% rider charge on the benefit base, the payout percentage bands and the Model #805 floor — are sourced from the composite specimen. Every behavioural and expense assumption is a std standardization introduced for the reference implementation, because the tables that would calibrate them sit behind paid subscriptions. Replace them with company data before drawing any conclusion from the numbers.
Run it#
python products/fixed_indexed_annuity/run.py
Three lines to the same thing:
import modelx as mx
model = mx.read_model("products/fixed_indexed_annuity/FIA_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 contract year t with one column per
cash flow line; result_pols(), result_av() and result_glwb() carry the in-force
movements, the account-value and surrender trace, and the rider state.
Its columns are pols_if, premiums, withdrawals, wd_guar, wd_excess,
income_payments, claims_death, claims_lapse, claims_maturity, commissions,
expenses, premium_taxes and net_cf, with net_cf income-positive as everywhere in
this library. withdrawals is the library-wide total for partial withdrawal payments and
the three columns after it partition it into the notes’ own ledger categories — they are
published alongside the total rather than instead of it, so summing every column of
the frame double-counts the withdrawal.
The model and both 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.
Annual, not monthly#
Contract year t runs entry_year() … proj_len(), and each t is simultaneously the
anniversary that ends contract year t. The library’s product assignment table
records this product as monthly; its own technical notes state annual, and the notes
govern:
Projection frequency: annual std, with the contract anniversary as the single event date. Every mechanic in the composite is annual — annual point-to-point crediting, the rider charge at the end of each contract year, the annual benefit base update, and the annual lifetime withdrawal. A monthly grid is needed only for excluded variants.
Contrast MYGA_US_S, whose t counts
months: that chassis credits interest daily and needs a grid fine enough to resolve a
30-day guarantee-period-end window. Here a monthly grid would buy nothing but the variants
the notes exclude — monthly-sum crediting, Athene’s monthly charge deduction, daily interim
values and mid-year withdrawal crediting.
age(t) = age_at_entry() + t is the attained age at anniversary t, which is the
notes’ own definition and the age that reads the lifetime-withdrawal percentage table.
Mortality over contract year t consequently reads the table one year lower, at
age(t − 1).
Inputs are external files#
The seven input CSVs live in this directory, beside run.py — not inside the model
folder. FIA_US_S/ holds nothing but formulas:
products/fixed_indexed_annuity/
model_point_table.csv <- inputs live here
mort_table.csv
surr_charge_table.csv
rollup_table.csv
payout_rate_table.csv
rate_scenario.csv
withdrawal_table.csv
run.py
README.md
FIA_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, 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 FIA_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 |
|---|---|---|
|
Nine contracts on the anchor configuration M62 / NQ / $100,000 / GLWB at issue. Point 1 is the worked-example anchor, entered in force at anniversary 7 |
|
|
Annual mortality by attained age and sex, ages 40–120 |
std illustrative Makeham annuitant curve, not a published table; shared with |
|
The surrender charge percentage 9.1% → 0% and the bonus vesting percentage 0% → 100%, both by contract year |
sourced S5 |
|
Three guaranteed simple rollup schedules: blended 5.00%/2.00%/0%, Nassau 3.00% flat, and none |
sourced S2 / S9; the |
|
Lifetime withdrawal percentages by attained-age band, single and joint |
sourced S3; the 80+ band extends S3’s single “80” row std, supported by S4 |
|
Two scenarios of index level and MVA reference yield. |
worked example; the |
|
Ad hoc gross withdrawals by schedule and anniversary |
std variants |
Mortality is the one table worth a second look. The notes prescribe the 2012 IAM Basic /
2012 IAR generational family with Projection Scale G2, q_x^(2012+n) = q_x^(2012) × (1 − G2_x)^n, with rounding applied from the 2012 period rate each time and never by
compounding an already-rounded rate. That table may not be redistributed here, so the
shipped curve is illustrative and generational projection is not implemented. Swap the
real basis in by repointing Data.mort_table_file.
Naming#
Cells follow MYGA_US_S — the deferred
annuity chassis this product sits on — and through it lifelib’s basiclife/BasicTerm_S and
savings/CashValue_SE: 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 anniversary. The technical notes use compact actuarial
symbols instead; the full mapping lives in the Projection Space docstring.
Six cases needed care:
Notes |
Cells |
Why |
|---|---|---|
|
|
|
|
|
The chassis calls the same Model #805 floor |
|
|
On the chassis |
|
|
The chassis’s |
|
|
The age at the anniversary, not at the start of the period as in |
|
(absorbed) |
The notes state the shock as three absolute rates, not as a multiplier; see below |
|
|
|
Three shared names are spelled the library’s way rather than the notes’: the
free-allowance portion of a withdrawal is wd_free_pp, the chassis name; the mortality
A/E deviation factor is mort_ae_factor; and the roll-forward self-checks are the
no-argument booleans check_av_roll_fwd() and check_pols_roll_fwd(), with the signed
per-anniversary residual behind each kept as check_av_roll_fwd_resid(t) and
check_pols_roll_fwd_resid(t) for when one of them fails.
One name is reused deliberately: credit_rate(t) is the chassis’s declared effective annual
rate i_cr(t) and here the index credit rate cr(t) = max(f, min(c, R(t))). Different
formulas, same concept — the rate at which interest is credited for the period — so the
name is kept rather than split.
pols_if(t) opens the contract year; the notes’ l(t) closes it#
The technical notes define l(t) as the in-force probability at the end of contract
year t. Across this library pols_if(t) means the other thing: the number in force at the
start of period t — Term_US_A has pols_if(1) == pols_if_init(), and lifelib’s
savings/CashValue_SE has pols_if(t) equal to pols_if_at(t, "BEF_MAT"). Both quantities
are needed and neither is discarded, so they are kept under different names:
Cells |
Notes symbol |
|
|---|---|---|
in force entering contract year |
|
|
in force leaving it |
|
|
The reason this matters is not tidiness. Every cash flow at anniversary t — the guaranteed
withdrawal, the excess, the post-depletion income, the maintenance expense — is weighted by
the count entering the year, so with pols_if carrying the closing count the pols_if
column of result_cf() did not reconcile with the cash flows printed beside it. It does
now: divide any cash flow on a row by its per-contract amount and the pols_if figure on
that same row comes back, which a test asserts on every model point.
The roll-forward identity reads
pols_if(t) − pols_if(t+1) = pols_death(t) + pols_lapse(t) + pols_maturity(t), and
result_pols() prints pols_if as the first column and pols_if_aft_decr as the last, so
each row opens and closes on the page.
MGV is MGSV, but the recursion is not the chassis’s#
Both products/fixed_indexed_annuity/product-spec.md and the chassis notes say the same
thing from opposite directions: the Model #805 floor is called MGV here after S10
and MGSV on the chassis after its own specimen, and it must not be modeled as two
quantities. The model therefore uses mgsv_pp throughout, and a test asserts the
Projection docstring explains the bridge.
The arithmetic is genuinely different, and that is deliberate. This product accretes and then deducts; the chassis deducts and then accretes:
FIA MGV(t) = max(0, MGV(t−1) × (1 + i_nf) − G(t))
chassis MGSV(t) = [MGSV(t−1) − d(t) − c(t)] × (1 + i_nf)^(1/12)
The worked example pins the FIA ordering to the cent — 93,811.84 × 1.01 − 10,144.16 = 84,605.80 — and the chassis notes explicitly warn against carrying their recursions across
unexamined. Same name, same concept, different arithmetic, on purpose.
wd_excess_pp means something different here than on the chassis#
On the chassis E(t) is the amount exposed to the surrender charge and the MVA, and is
named wd_excess_pp / surr_excess_pp. In these notes E(t) is the excess withdrawal
— the part of a gross withdrawal above the guaranteed lifetime amount. That is not a naming
nicety: it is the quantity that permanently reduces the guarantee pro rata and, if it
exhausts the account value, destroys the income stream entirely. It is the product’s
headline concept and it keeps the name wd_excess_pp.
The chargeable amount, the notes’ X(t), is wd_charge_base_pp on the withdrawal path and
surr_charge_base_pp on the surrender path. Reading a chassis formula across without
renaming would silently charge a surrender charge on the wrong base — on the whole
guaranteed withdrawal rather than on the excess above the free amount — which is exactly
the error S9 warns about when it says withdrawals up to the annual benefit amount carry no
charge “even if greater than the Free Withdrawal Amount”.
The worked example is an in-force cell, not a new issue#
The notes’ worked example opens at anniversary 7 on stated balances — AV(7) = 128,000.00,
BB(7) = 180,000.00, RB(7) = 100,000.00, MGV(7) = 93,811.84 — described as
“illustrative balances, broadly consistent with a seven-year deferral at these parameters
std”. They are not derived from a projection, and no index path reproduces all four
simultaneously; contriving one would be retuning assumptions to force a match.
So model point 1 carries entry_year = 7 and those balances, and every recursion bottoms
out at t <= entry_year(). result_cf() is indexed from entry_year(), and an in-force
model point pays no premium and incurs no acquisition expense — both are behind it. Model
point 2 is the same cell issued at t = 0, which exercises the notes’ Initialisation block
instead: AV(0) = P × (1 + b) = 107,000, BB(0) = RB(0) = P = 100,000 with the bonus
excluded, MGV(0) = 0.875 × P = 87,500 with the bonus excluded again. Rolling point 2’s
floor forward seven years reproduces the worked example’s 93,811.84 exactly, which is the
one place the two model points meet — and a test asserts it.
The MVA collar on a partial withdrawal is shipped both ways#
The notes limit the adjustment to |MVA| ≤ max(0, G(t) − SC(t) − CB(t) − MGV(t)), and
justify it as ensuring that “a negative MVA combined with charges never reduces the
surrender value below the guaranteed minimum value”. Those two halves do not sit together
on a partial withdrawal: the limit is written on the gross withdrawal while its stated
purpose is a surrender-value test, and the worked example only ever exercises it at a full
surrender, where G(t) = AV(t) and the distinction vanishes.
Read literally, a partial withdrawal smaller than the nonforfeiture floor produces a non-positive limit and therefore no adjustment at all — a $20,000 withdrawal against an $84,605.80 floor gets an MVA of exactly zero. Read as a test on the contract, it gets the adjustment its rate produces up to what the remaining floor allows.
Neither reading can be preferred on the evidence, so both ship behind mva_collar_basis:
Value |
Limit measured against |
Effect |
|---|---|---|
|
|
a small partial withdrawal carries no MVA |
|
|
a small partial withdrawal carries its MVA |
They agree on the surrender path, so the worked example reproduces under either, and a test pins the gap open rather than closing it in either direction. Model point 8’s $60,000 withdrawal is deliberately large enough that the literal collar does not bind it to zero, so the withdrawal-path MVA is live in the shipped default run.
The net proceeds line is a cent-rounding artefact, and both readings are asserted#
The surrender trace’s components all reproduce exactly to the cent — SC = 3,606.30,
CB = 2,359.26, MVA = −1,158.64, limit = 32,294.48 — but the notes’ net proceeds of
115,741.64 is the sum of those displayed figures. The clawback (2,359.26168) and the MVA
(−1,158.64384) each carry fractions of a cent, so carrying full precision — which the notes’
own convention asks for, “full precision internally, cents on reported cash flows std”
— gives 115,741.6345, 0.55 cents lower. The model keeps full precision and the test pins
both figures rather than tuning either away.
M_shock is absorbed into lapse_rate_base#
The notes write w(t) = min(0.35, w_base(t) × M_shock(t) × M_money(t)) but state the shock
as three absolute rates rather than as multipliers on the 6% ultimate:
w_shock = 0.33 no GLWB rider
w_shock = 0.10 GLWB in force but not activated
w_shock = 0.05 GLWB activated (phase = INCOME)
Carrying M_shock as a separate factor would require inventing a denominator, so
lapse_rate_base(t) returns shock_lapse_rate(t) in the year the surrender charge expires
and lapse_rate(t) multiplies only by lapse_moneyness_factor(t). The name
shock_lapse_rate follows Term_US_A. Model points 1, 9 and 6 exercise the three rates
respectively, and the notes call this “the single most important behavioral fact in the
product”.
The four phases, and why the cause of depletion matters#
ACCUM is deferral. INCOME starts at the first lifetime withdrawal. DEPLETED is what
the product is sold for: the account value is gone, the insurer pays LW from its own funds
for the rest of the covered life, no rider charge is deducted, no index credit is computed,
the surrender value and death benefit are zero, and lapse is impossible.
TERMINATED is the same exhaustion with the guarantee destroyed — reached when the account
value is driven to zero by an excess withdrawal, a surrender charge or a negative MVA rather
than by guaranteed withdrawals and rider charges. depletion_cause(t) carries the
attribution and is evaluated before the depletion test, exactly as the notes require. A
model testing only AV ≤ 0 would either give the guarantee away after an excess withdrawal
or destroy it after a legitimate one. Model points 1 and 7 are the same cell withdrawing
100% and 105% of the maximum; they reach exhaustion in the same year and end in opposite
states.
The attribution has to reach the cash, not just the phase label#
On the exhaustion anniversary itself the withdrawal requested is larger than the balance left to meet it, and the two branches fund that gap differently:
point 1, |
point 7, |
|
|---|---|---|
account value after the rider charge |
1,038.38 |
765.92 |
withdrawal requested |
10,144.16 |
7,856.61 |
shortfall |
9,105.78 |
7,090.69 |
of which unpayable |
0.00 |
7,090.69 |
cash paid |
10,144.16 |
765.92 |
On the left the insurer funds the whole shortfall from its own funds — that stream is the product. On the right it funds none of it: the balance is gone and the rider that would have covered the rest was destroyed by the very withdrawal being paid, S5 treating the contract “as well as the rider” as Surrendered at that point. Paying the request in full on both branches would honour the guarantee in the year the excess withdrawal kills it, which is precisely the failure the notes’ attribution test exists to prevent — reached one step further downstream than the phase label.
The cap is on the payment, not on the withdrawal. wd_pp(t) still carries the amount
requested, so the excess still sets depletion_cause, still drives ρ to 1 and still takes
the benefit base to zero; only wd_payment_pp and the three ledger lines are net of
wd_unfunded_pp. Within the payment the account value is consumed in the order the notes
compose the withdrawal — the first LW dollars are the guaranteed portion and the rest is
the excess S9 — so the excess goes unpaid first and the guaranteed portion is eaten
into only after that std, the notes being silent on a withdrawal the account value
cannot fund. At t = 19 point 7 pays 765.92 of guaranteed portion and none of the 374.12
excess.
One consequence had to be decided rather than read off: when the contract terminates, the
survivors leave as a deemed full surrender (lapse_rate returns 1.0 that year, paying
max(surrender value, MGV) which is MGV once the account value is zero), following
American Equity’s “the contract as well as the rider will be considered Surrendered” S5.
Without it the block would sit in force forever holding a contract that pays nothing, and
the in-force roll-forward would not close.
An account value exhausted in ACCUM terminates rather than depletes std: the notes
define the depleted state only out of INCOME, and before exercise there is no LW to pay.
The horizon#
The notes state none, and the DEPLETED liability is a life annuity, so stopping early
would silently drop the tail the product exists for. The model runs through the contract
year entered at attained age maturity_age = 120, the terminal age of the shipped
mortality table, where the annual rate is 1.000000 — so the projection closes itself rather
than being truncated, and pols_maturity is numerically zero. It is kept anyway, because a
substituted table with no terminal age would make it bite and because without it the last
year would appear to lose lives with no cause. The name follows BasicTerm_S.pols_maturity.
Standardizations used#
Everything in this list is std: the annual grid and the anniversary-only event date;
the annual step-up (no retrieved document describes an automatic ratchet during deferral —
step_up_mode = "at_exercise" reduces the model to the documented designs); the 1.00% flat
nonforfeiture rate inside the 0.15%–3% corridor; the insurer-favourable reading under which
the guaranteed withdrawal consumes the free withdrawal amount; the pro-rata allocation of a
withdrawal across the fixed and indexed accounts, the notes giving a rule only for the
charge; the base surrender vector 2/3/4/5/6% and the three-way shock lapse 33%/10%/5%; the
rider moneyness multiplier; the locking of the payout percentage at first exercise; the
ACCUM-exhaustion-terminates rule; the deemed full surrender on termination; the excess
going unpaid before the guaranteed portion when the account value cannot fund the whole
withdrawal; the 6.0%-of-premium acquisition expense with no separate commission line; the
$80 per contract per year maintenance expense inflating at 2.5%; the 0% premium tax; the
illustrative mortality table with an A/E factor of 100%; and the attained age 120 horizon.
Two crediting parameters belong here rather than on the sourced list. The notes print the
index-margin and performance-trigger forms — max(f, p × R − s) S8 R1 and
d × 1{R ≥ 0} R1 — but declare no level for either, in neither technical-notes.md nor
product-spec.md. So spread_rate = 2.00% std and trigger_rate = 4.50% std
are illustrative levels that exist only to exercise those branches, and must not be read as
parameters of the composite. The two crediting parameters that are sourced are the cap,
5.25% S2, and the participation rate, 80% — the latter from R1’s worked
min(80% × 10%, 6%) = 6%, which a test asserts.
Switched off by default so the base run reproduces the worked example:
index_cost_rate = 0 (the volatility-controlled-index haircut), mgsv_annual_charge = 0
(Model #805 permits $50), rider_charge_from_mgsv = False (Athene and Allianz deduct from
the floor too), use_guaranteed_scale = False (the guaranteed minimum cap and fixed rate),
rb_wd_convention = "pro_rata" (Athene’s dollar subtraction is the alternative), and
comm_rate_acq = 0.
Not implemented#
Named so the gaps cannot be mistaken for oversights: monthly-sum crediting (needs the
monthly grid the notes exclude, and its floor convention is itself flagged ambiguous);
interim values, Nassau’s or Nationwide’s, both daily marks of the embedded option;
cap re-declaration against the option budget (the notes give the target but no
option-pricing function); stochastic GLWB activation on the h(a) table, which cannot be
applied to a single deterministic cell — activation_rate() reports it and the base run
activates at income_start_age; generational mortality with Scale G2; joint-life
survivorship (the notes give the joint payout percentage but no second-life mortality);
the income doubler, confinement and terminal illness waivers, and annuitization, all put out
of scope by the notes themselves; additional premium; and check_margin(), because the
notes define no margin decomposition and the model projects no asset side.
Tests#
tests/test_fixed_indexed_annuity_us.py asserts all sixteen rows of the notes’ worked
example and every line of its surrender trace, the “Where the step-up binds” variant block,
the year-1 step-up on a new issue, the depletion arithmetic and the survival of the income
stream, the payment cap on the terminating exhaustion branch, the verbatim S9
excess-withdrawal reduction and the verbatim S10 clawback, one test per pitfall the notes
state as a model mechanic, the crediting engine against R1’s worked case, the three
shock-lapse rates, the Model #805 §4B/§4C rate including the 15 bp floor, the in-force and
account-value roll-forwards — through the no-argument check_pols_roll_fwd() and
check_av_roll_fwd() and through the check_*_resid(t) residuals behind them — that the
pols_if column of result_cf() is the weight carried by the cash flows on its own row,
and that every model point projects.
Three of the notes’ thirteen “Known modeling pitfalls” entries carry no test, and cannot:
that the behavioural assumptions must not be reused in a CARVM valuation, that “efficient
policyholder selection” is not AG 33’s language, and that the declared parameters are stale
and state-varying are statements about how the projection may be used rather than about
what it computes. A fourth, “Interim values and index costs”, is half covered — the
interim-value structures it names are not implemented at all, while the index-cost haircut
it also names is, and
test_pitfall_index_costs_are_deducted_before_the_cap_and_participation_rate asserts that
index_cost_rate comes off R(t) ahead of both the cap and the participation rate. The
remaining nine each have their own test.
python -m pytest tests/test_fixed_indexed_annuity_us.py -q
Verifying this copy#
tests/test_fixed_indexed_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_indexed_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.