Implementation Notes#
Status: Draft, 2026-08-14. Built from
products/indexed_ul/technical-notes.md;
the product it implements is specified in
product-spec.md. The shared universal
life mechanics those notes defer to are in
products/universal_life/technical-notes.md,
and their executable form is UL_US_S.
This is a mechanics demonstration, not a pricing or reserving result. The contractual elements — the 10.00% cap and 2.00% guaranteed cap, 100% participation and 0% floor of the AG 49-A Benchmark Index Account, the 4.50%/1.00% fixed account, the monthly 12-month segment ladder, the $10 policy fee, the $25 withdrawal fee, the 61-day grace and cumulative-MNLP no-lapse test, the 10-year surrender charge period, the IRC 7702(d) corridor factors — are sourced. Every behavioural and expense assumption, the whole COI scale, the surrender charge dollars, the per-unit charge and the 6.40% level index return are std standardizations introduced for the reference implementation. Replace them with company data before drawing any conclusion from the numbers.
Run it#
python products/indexed_ul/run.py
Three lines to the same thing:
import modelx as mx
model = mx.read_model("products/indexed_ul/IUL_US_S")
model.Projection[1].result_cf()
Projection takes a point_id; Projection[1] is the baseline cell.
result_cf() returns a tidy DataFrame indexed by policy month t with one column per
cash flow line; result_av() is the account value roll-forward, result_seg() the
indexed segment ladder, and result_pols() the decrements.
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#
Policy month t runs 1 … proj_len() = 12 × (121 − age_at_entry()), so a policy
issued at 45 projects 912 months, ending as the insured attains 121. The notes fix the
grid: everything happens on the monthiversary, because that is where segments are
created and mature. t = 1 is the issue month of a new-business point; the notes’ own
month index (which starts at 0) is duration_mth(t), and every mod 12 test — the
anniversary premium, the segment maturity — is written against it.
Age 121 is an unverified inference (spec F5): no retrieved document states maturity
mechanics, and the spec reads charges ceasing at 120 with coverage continuing. The
horizon therefore truncates the run rather than terminating the contract, and
pols_maturity(t) is identically zero.
Inputs are external files#
The seven input CSVs live in this directory, beside run.py — not inside the model
folder. IUL_US_S/ holds nothing but formulas:
products/indexed_ul/
model_point_table.csv <- inputs live here
coi_rates.csv
corridor_factors.csv
mort_table.csv
class_factor_table.csv
lapse_table.csv
surr_charge_table.csv
run.py
README.md
IUL_US_S/ <- formulas only
__init__.py (model docstring)
_system.json
Data/__init__.py (reads the CSVs, once per model)
Projection/__init__.py (the by-policy 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 policy. 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 policies 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 IUL_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.
Two things the universal life chassis reads from a file are not files here, because these notes give them in closed form instead:
Premium persistency. The chassis reads a 16-row schedule; these notes give
expected premium_y = planned × 0.98^(y−1), which isprem_persistency(t)with the rate in a Reference.The index path.
index_level(t)generates it from a level annual return. It is the single point of substitution: override that one cells with a historical or simulated path and the whole ladder follows.
File |
Contents |
Provenance |
|---|---|---|
|
Five model points, all on the notes’ anchor configuration M45 / NT / $250,000 / Option A / GPT / $10,000 planned annual premium |
anchor cell from the notes’ model point attribute table; the std example premium |
|
Guaranteed maximum monthly COI per $1,000 of net amount at risk, policy years 1–76 for M / NT / 45 |
illustrative std — the notes set the guaranteed basis at 2017 CSO ANB ultimate REG-R17, which is licensed and is not reproduced here |
|
IRC 7702(d)(2) applicable percentages, attained ages 0–121 |
sourced R4 |
|
Best-estimate annual mortality by age |
illustrative std, not the 2015 VBT the notes recommend REG-R18; the same table |
|
Rate-class factors for the spec’s seven classes |
|
|
Base annual lapse 6% years 1–10, 4% after |
|
|
$25.00 per $1,000 of initial face, 10-year linear run-off |
The model points and what each is for:
Point |
Configuration |
What it exercises |
|---|---|---|
1 |
baseline, Option A, 100% indexed |
the notes’ anchor; one segment a year |
2 |
Option B |
corridor and a net amount at risk that does not run off |
3 |
indexed allocation 0% |
the control run: no segment ever created, everything at the fixed rate |
4 |
monthly premium mode |
the full twelve-concurrent-segment ladder |
5 |
$6,000/yr, $200/mo withdrawal from year 2, $6,000/yr loan from year 21 |
fixed-account-first-then-pro-rata sourcing, the loan collateral account, the no-lapse test, and the overloan exposure. The $200 is deliberately below the sourced $500 withdrawal minimum, and the account value is not meaningful after month 605 — both explained below |
A model point on any other issue age, sex or class needs coi_rates.csv extended first;
a test asserts every model point in the table actually projects.
Naming#
Every concept shared with the universal life chassis carries the chassis name, 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-policy amounts, timing and kind string arguments. A test
asserts that shared set is present, so the two models cannot drift apart silently.
The names that are new are the ones the indexed crediting engine needs — the fixed
(holding) account (fa_pp, fa_pp_at), the segment ladder (seg_new_pp, seg_bal_pp,
seg_bal_tot_pp, seg_count, seg_return, sweep_pp, seg_roll_pp), the crediting
formula (index_level, index_change, index_credit_rate, seg_credit_base,
index_credit, seg_matured_value), the loan collateral account (lca_pp) and the
no-lapse guarantee (mnlp_rate, cum_mnlp_pp, nlg_test_ok, nlg_in_effect).
The full notes-symbol → cells-name mapping lives in the Projection Space docstring.
Seven cases needed care:
Notes |
Cells |
Why |
|---|---|---|
|
|
the model’s |
|
|
the notes’ cash surrender value already nets the loan; the chassis splits |
|
|
the fee is inside |
|
|
a withdrawal is a payment on the owner’s election, not a claim — see below |
|
|
these notes net the end-of-month loan, where the chassis nets the opening one |
|
|
one nets loans as well as withdrawals, the other does not |
|
|
|
result_cf() returns, in order: pols_if, premiums, claims_death, claims_lapse,
withdrawals, expenses, premium_taxes, net_loan_cf, net_cf. pols_if is the
count in force at the start of month t and is the weight on that same row’s cash
flows; the cash flow columns are income-positive and sum to net_cf. The surrender
column is claims_lapse, not claims_surr, so the column name matches the "LAPSE"
kind that produces it.
The worked example is the crediting engine, not a projection#
The notes’ worked example prices one segment year with stipulated inputs: a segment created with $12,000.00 from the sweep, “its pro-rata share of monthly deductions is $15.00 in each of the 12 segment months std example values”, index 4,500.00 → 5,040.00 (scenario A) or → 3,825.00 (scenario B). It is not output of the full monthly projection, and the notes label the numbers as example values.
So it is asserted against the engine cells with exactly those inputs, and every row and
column reproduces to the cent — including the two variant credits the notes price in the
same paragraph: Transamerica’s half-weighted adjusted beginning value
(10.00% × (12,000.00 − 90.00) = 1,191.00 S3) via seg_credit_base(..., "ADJ_BEGIN"),
and the guaranteed-cap-only credit (2.00% × 11,820.00 = 236.40 S2) via the cap
argument of index_credit.
Twelve deductions in the example, eleven in the ladder#
The one place the notes do not close on themselves. Their processing order puts segment
maturity at step 2 of month m + 12, before that month’s step 5 deduction; and a
segment is created at step 7 of month m, after that month’s deduction. A segment in
the running projection therefore bears deductions in months m+1 … m+11 — eleven of
them — while the worked example stipulates twelve.
Both readings are defensible and neither is retunable into the other without breaking something the notes state explicitly: charging twelve would require maturity after the deduction, contradicting the step order; charging eleven contradicts the example’s prose. Rather than silently pick one and let the discrepancy vanish:
the step order governs the projection, because it is the notes’ normative specification and the whole account value roll-forward depends on it;
the example governs the engine test, because its balance at maturity (11,820.00) is a stipulated input, not something the ladder computes;
test_first_segment_credit_reproduces_from_its_own_ladderpins the ladder’s own count at eleven, andcheck_seg_ladder()asserts every segment accounts for its creation balance exactly, so the gap cannot be closed silently in either direction.
The money is not lost either way: at month m + 12 the matured value rolls into the fixed
account and immediately pays that month’s deduction from it. Across twelve months the same
twelve deductions are taken — only eleven of them are inside the credit base.
The withdrawal fee is inside W, not on top of it#
The universal life chassis debits the account value W + 25 and pays the policyholder W.
These notes read the other way: W_t is “gross of $25 fee” and the “withdrawal outflow =
W_t − $25 fee”. So av_pp_at(t, "BEF_FEE") here subtracts wd_pp(t) alone, and
claim_pp(t, "WITHDRAWAL") is wd_pp(t) − wd_fee_pp(t). This is a deliberate divergence
from the chassis, made because these notes restate the rule with their own convention; the
Projection docstring says so at the point of divergence.
The same rule applies to the death claim: the chassis nets L(t−1), these notes write
DB_t − L_t, and claim_pp follows these notes.
…and a withdrawal is not a claim#
The notes write the withdrawal outflow as its own term of CF_t, alongside the death and
surrender legs, and the library follows that. The payment is the standalone cells
withdrawals(t) — claim_pp(t, "WITHDRAWAL") × pols_if(t), weighted by the in-force at
BOM because a withdrawal is taken on the owner’s election, not by a decrement — and it
gets a withdrawals column of its own in result_cf().
claims(t, kind) therefore takes "DEATH" and "LAPSE" only; "WITHDRAWAL" raises.
The per-policy rule stays in claim_pp, which is where the fee-inside-W_t convention
above is stated, and withdrawals is its only caller. Dropping the kind from claims
is what keeps the kind is None total from counting the withdrawal twice — once in
claims(t) and once in the standalone cells — now that net_cf names both.
What is not here is either of the two $500 limits from the same source. The product
spec’s Table 3 reads “$25 per withdrawal; minimum withdrawal $500; CSV may not fall below
$500” S3, and neither the minimum nor the floor is enforced. wd_pp(t) is whatever the
model point’s column says, and model point 5 deliberately asks for $200 a month — below the
sourced minimum — because a compliant $500 a month against a $6,000 annual premium empties
the policy long before the year-21 loan module that point exists to exercise. ncsv_pp(t)
floors at zero, not at $500. Both are limits on what a policyholder may request, and the
notes give no behaviour for a refused request, so the mechanics are implemented and the
limits are left to the data. They are named in Not implemented below and pinned by
test_the_two_five_hundred_dollar_withdrawal_limits_are_not_enforced.
One pool, two claims on it, in that order#
Withdrawals, new loan collateral and the monthly deduction are all sourced fixed account
first, then pro rata across live segments S3. The withdrawal and the loan collateral go
at step 3 of the monthiversary; the deduction at step 6. Both draw on the same pool — the
segments born in the previous eleven months, valued at the end of month t − 1,
seg_bal_active_pp(t) — and the obvious implementation caps each of them at that pool.
That is wrong, and it was wrong here until this pass. Capping both against the pre-draw
pool lets the two together take more than the segments hold. On model point 5 at month 384
the pool was $265.29, the step-3 draw took $200.00, and the step-6 deduction was then
allowed $177.38 — $377.38 out of $265.29. The segment born at month 373 went to −$112.09,
and twelve months later it paid an index credit of 0.0640 × (−112.0873) = −$7.17: a
negative index credit, on an account whose floor is contractually 0%
(cr_k = max(f, min(c, p × r)), floor 0% S2 R1).
Nothing caught it. The accounting identity in check_seg_ladder() still closed, because a
negative balance accounts for its creation amount as faithfully as a positive one, and
seg_count(t) reported zero, because it counts only segments with a positive balance.
So mth_deduction_from_seg_pp(t) is capped at seg_bal_active_pp(t) − draw_from_seg_pp(t)
instead: the step-3 draw has the first claim on the pool and the step-6 deduction gets what
is left. Their sum can no longer exceed the pool, no segment balance can go negative, and
no segment can pay a negative credit. check_seg_ladder() now asserts the last two
directly — seg_bal_pp(t, m) ≥ 0 and index_credit_pp(t) ≥ 0 for every month of every
model point — so the floor is a tested bound rather than a hoped-for property of the
arithmetic.
Whatever the segments cannot cover stays with the fixed account, which goes negative rather
than the deduction being silently truncated. That is deliberate: the notes charge MD_t in
full and hand an uncovered deduction to the grace cascade, which is not implemented — see
the next section for where that ends up.
check_margin() is allowed to open up once a loan overruns the cash value#
Four self-checks run on every model point: check_av_components() (the account value still
equals fixed account + live segments + loan collateral), check_av_roll_fwd() (the notes’
processing order), check_seg_ladder() (every segment accounts for its creation balance,
and neither a balance nor a credit is ever negative), and check_margin() (net_cf
reconciles to the expense and mortality margins). The first three hold for all five model
points.
check_margin() holds for points 1–4 and fails for point 5 from policy month 384
(policy year 32), which is where its $6,000-a-year loan overtakes the cash value:
ncsv_pp floors at zero, the identity opens up by the unrecoverable debt, and
is_shortfall(t) starts firing. That is the exposure the notes name as key sensitivity 6 —
“heavy late-life loans plus a 0%-credit sequence can force lapse absent overloan protection
S3” — and the Overloan Protection Rider that would prevent it is described in the product
spec and deliberately not modeled. The test suite asserts the identity for points 1–4 and
asserts the overloan for point 5, so the gap is pinned open rather than papered over.
And then the account value itself runs away — point 5’s result_av() is meaningless after month 605#
The margin identity is not the only thing that gives way, and the earlier draft of this README stopped one step too soon. No policy is terminated for insufficiency in this model — the grace and lapse-for-insufficiency cascade is in the Not implemented list below, because the notes leave the in-grace account value treatment and the cure-payment cash flow undetermined. So a policy that has run out of money keeps being charged: the monthly deduction is taken in full, whatever the segments cannot cover is carried by the fixed account, and the account value goes negative. From there it compounds, because
NAAR_t = max(0, DB_t × v_g − AV'_t)
rises one-for-one as AV'_t falls, and COI_t = coi_t × NAAR_t / 1000 is charged against
AV'_t again next month. On model point 5:
Policy month |
Policy year |
What breaks |
|---|---|---|
384 |
32 |
loan overtakes cash value; |
605 |
51 |
|
912 |
76 (horizon) |
|
So result_av() for model point 5 — av_pp, net_amt_at_risk, coi_pp,
mth_deduction_pp — is not a meaningful number from month 605 onward. Points 1–4 never
get near it: point 1’s account value never falls below $8,102.
result_cf() is a different matter and stays finite and bounded throughout. The death
benefit is the Option A face amount — $250,000 at issue, reduced dollar-for-dollar by the
withdrawals to $70,000 by the horizon, and never lifted by the corridor, because the
corridor multiplies a negative account value. The death claim max(0, DB − L) floors at
zero once the loan passes the face, and the surrender payment floors at zero as well. The
largest single month’s net_cf over the whole 912 months is $5,692 and the total is
−$18,374: the runaway lives entirely inside the account value, not in the cash flows.
Two fixes were available and neither was taken silently. Flooring the account value at zero
would stop the compounding, but it is a rule the notes do not give, and it would break
check_av_components() and check_av_roll_fwd() — the two identities that make the
segment bookkeeping checkable. Implementing the cascade properly needs the in-grace
treatment the notes withhold. So the behaviour is disclosed instead, here and in the model
docstring, and test_point_5_account_value_runs_away_after_month_605 pins the boundary
month, the sign either side of it, and the finiteness of result_cf(), so the number
cannot drift without a test failing.
The guaranteed basis is a Reference away#
The notes distinguish class (a) contractual guarantees from class (b) current
non-guaranteed scales and say guaranteed-basis projections use class (a) only. Setting
Projection.basis = "GUARANTEED" switches the cap to 2.00%, the fixed account to 1.00%,
the premium load to 8%, the policy fee to $15, the per-unit charge to $0.40 in all years
and the COI scale to its guaranteed maximum. Key sensitivity 1 in the notes is how far
apart the two runs are; a test asserts the divergence and its direction.
Not implemented#
Named here and in the model docstring so their absence is not mistaken for an oversight:
stochastic index scenarios (the notes give lognormal parameters but simulation is a driver
around the model — index_level is the substitution point); cap re-declaration from an
option budget (no option pricing model, no NIER path, no target spread in the notes); the
grace and lapse-for-insufficiency cascade (the trigger and no-lapse test are implemented,
and so is the notes’ lapse suppression while the guarantee is in effect, but the in-grace
account value treatment and the cure-payment cash flow are undetermined — with the
consequence for model point 5’s account value set out above); the two $500 withdrawal
limits of spec Table 3 (minimum withdrawal $500, and a cash surrender value that may not
fall below $500 S3), which constrain what a policyholder may request and are left to the
model point data; the funding-stop
state of the premium persistency section (a second account value path the notes do not say
how to blend); the guaranteed floor accumulation test (explicitly a variation — the 0%
annual floor needs no shadow account); charge-funded high-cap accounts, multi-index and
multi-year segments, participating loans, the Overloan Protection Rider, face increases
and decreases, option changes, reinstatement and riders (all excluded from the baseline by
the product spec); and MEC status, which is a flag because it changes policyholder
taxation, not insurer liability cash flows.
Standardizations used#
Everything in this list is std: the 6.40% level index return of the base deterministic run and the level index path it generates; the 5% current / 8% guaranteed premium load; the $0.30 per $1,000 per-unit charge and its ten-year window; the $15 guaranteed policy fee; the whole COI scale and the 65% current-to-guaranteed factor; the $25 per $1,000 surrender charge scale; the best-estimate mortality table; the rate-class factors; the base lapse vector and its year-11 surrender-charge-expiry spike; the dynamic lapse formula and the “underfunded = non-positive cash surrender value” reading behind the 25% no-lapse-expiry shock; the 98% premium persistency; the 3.00% charged and 2.00%/3.00% credited loan rates; the guideline single, guideline level and 7-pay placeholders; the $75-a-year maintenance and $150 acquisition expenses (with no inflation, because these notes give none, unlike the chassis); the 2.0% premium tax; the monthiversary sweep, the 100% roll of matured value, the pro-rata deduction sourcing and the remaining-balance credit base; routing the matured value through the fixed account; the dollar-for-dollar Option A face reduction; treating an in-force model point’s opening balance as all fixed account, because the notes give no opening segment ladder; and the projection horizon at attained age 121, which is itself unverified.
Tests#
tests/test_indexed_ul_us.py asserts every row and column of the worked example in both
index scenarios plus both variant credit bases; the anchor account value roll-forward to
the cent and the month-1 trace at full precision; one test per entry in the notes’ “Known
modeling pitfalls” list; the four roll-forward self-checks across all five model points;
the in-force roll-forward; the no-lapse suppression, the surrender-charge-expiry spike and
the neutral dynamic multiplier; the loan collateral mechanics and the overloan exposure;
the guaranteed-basis divergence; that the model carries no present values or discount
curve; that the chassis name set is present; and a read → write → re-read round trip
carrying the inputs along.
Three of them exist because of the sections above, and each fails against the behaviour it replaced or the claim it corrects:
Test |
What it pins |
|---|---|
|
the step-3 draw plus the step-6 deduction never exceed |
|
the disclosed boundary: |
|
model point 5’s $200 withdrawal is below the sourced $500 minimum, and |
python -m pytest tests/test_indexed_ul_us.py -q
Verifying this copy#
tests/test_indexed_ul_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_indexed_ul_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.