Implementation Notes#
Status: Draft, 2026-08-14. Built from
products/whole_life/technical-notes.md;
the product it implements is specified in
product-spec.md.
This is a mechanics demonstration, not a pricing or reserving result. Very little of this product is public. The contractual skeleton is sourced — the 4.00% guarantee interest rate, endowment of the guaranteed cash value at face at age 100, the fixed 6.00% loan rate with direct recognition, paid-up additions as the default dividend option, and, for the final-expense variant, the per-$1,000 premium rates, the $36 policy fee and the 110%-of-premiums graded death benefit. Everything else is a std standardization, including all four guarantee-basis tables: the shipped mortality, net single premiums, nonforfeiture net level premiums and cash value schedules are illustrative curves calibrated to the worked example’s anchors, not the 2017 CSO / 4% tables the notes name — those are licensed and cannot be shipped here. They are not even one basis between them, because the worked example’s own anchors rule that out; the arithmetic is below. Replace them with company data before drawing any conclusion from the numbers.
Run it#
python products/whole_life/run.py
python products/whole_life/run.py 12 # the final-expense graded plan
Three lines to the same thing:
import modelx as mx
model = mx.read_model("products/whole_life/WholeLife_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 the 0-based month index t with
one column per cash flow line, the start-of-month pols_if each row is weighted by, and
the net flow under both signs — net_cf (income positive, the library convention) and
liability_cf (outgo positive, the notes’). result_cf_annual() sums the same frame into
policy years; result_pols() and result_cv() give the decrement and value rolls.
The model and both its Spaces 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, 0-based — lifelib’s own clock
(basiclife/BasicTerm_S, savings/CashValue_SE) and the one every other model in this
library runs on: t = 0 is the issue month, period t runs from time t to time
t + 1, and for t in range(proj_len()) is the frame.
Everything contractual about this product is nevertheless annual — the guaranteed cash
value schedule, the dividend declaration, the anniversary capitalization of loan interest,
the premium-paying period — so the policy year is derived and used as a lookup key:
duration(t) = t // 12 is the completed policy years, the contractual policy year is
the 1-based label duration(t) + 1 (never indexed by), age(t) = age_at_entry() + duration(t) is the attained age entering that policy year, and age_anniv(t) the age at
the anniversary ending it. is_anniv(t) — true in the last month of a policy year — is
where every annual event lands.
The frame is t = proj_start() … proj_len() − 1. proj_len() = 12 × (100 − age_at_entry()) is the number of policy months projected from issue — the exclusive
end of the frame, so proj_len() − 1 is the final month, the one ending at attained age
100 — and proj_start() = 12 × duration_inforce() is 0 for new business and the elapsed
policy years in months for an in-force point, so the frame always opens on an anniversary.
len(result_cf()) == proj_len() − proj_start(): 660 rows for the new-business male 45,
552 for the anchor at duration 9.
Two speeds, and what actually changed#
mort_rate(t) and lapse_rate(t) are the annual rates of the policy year containing
month t — the vectors the notes tabulate — and mort_rate_mth(t) and lapse_rate_mth(t)
are 1 − (1 − q)^(1/12), the rates the month applies. That naming is the library’s
convention and tests/test_model_conventions.py asserts it. Because twelve monthly rates
compound back to the annual one and every annual event stays on the anniversary,
pols_if(12k) is exactly what the annual-step model carried at t = k, to floating
point, and so is every anniversary-dated value: the guaranteed cash value, the dividend
and all three of its margins, the paid-up additions purchased and in force, the anniversary
surrender and death benefit amounts. All fifteen steps of the notes’ worked example are
anniversary quantities, which is why the walk-through survived the change of grid
unaltered — only the indices it is read at moved, CV_8 → CV_107 and CV_9 → CV_119.
What the finer grid buys is everything that is not contractually annual:
Annual grid |
Monthly grid |
|
|---|---|---|
Death claims |
at the anniversary, on the anniversary in force |
at the end of the month of death |
Surrenders |
at the anniversary, valued at the anniversary CV |
at the end of the month, valued at an interpolated CV |
Maintenance expense |
once a year, |
a twelfth a month, |
Premium |
the annual premium, once a year |
the modal instalment, when it is contractually due |
Loan advances |
one net advance a year |
spread as the cash value moves; interest still capitalizes once, at the anniversary |
The last of those is the one the notes themselves asked for: the annual-mode
standardization of product-spec Table 2 note (f) existed only because the grid could not
express the sourced modal factors, and it is now retired. premium_pp_ann(t) is the notes’
G, the annual premium; premium_pp(t) is the gross instalment billed and
premium_net_pp(t) the one actually collected once any dividend offset is applied, and
modal_factor_par_* / modal_factor_fe_* carry the two sourced scales ([S1] 0.515 /
0.26265 / 0.085833 and [S7] 0.52 / 0.275 / 0.089), one Reference each. The dividend offset under REDUCE_PREM
is applied to the annual premium and the instalments split what is left std, so
changing the mode moves the timing of the collection and not the amount the offset
absorbs.
The two interpolations#
cv_pp(t) and nsp_mth(t) are straight-lined between anniversary values, (k + 1)/12 of
the way with k = duration_mth(t) % 12, both std and both exact at the anniversary.
cv_pp_anniv(y) is the schedule as printed, and is what the tests pin. Nothing else is
interpolated: pua_face is a step function that moves only when a purchase is made, and
the dividend is annual by contract.
Closing balances and opening state#
The value state variables are closing balances. cv_pp(t), pua_face(t),
pua_cv(t), div_accum(t) and loan_bal(t) are all as at the end of month t. The
value entering month t is the closing value of month t − 1, or, at the first
projected month, the model point’s opening state — the notes’ initializations
PUAF = puaf_inforce, DA = 0, L = loan_inforce. That is written inline wherever an
opening balance is read,
puaf = pua_face(t - 1) if t > proj_start() else puaf_inforce()
so that no cells is ever asked for a month below the first projected one — there is no
t = −1 anywhere in the model, and for an in-force point the opening balance is the
model point’s input rather than a projected value. An annual quantity reads the balance
entering its policy year instead, twelve months back: div_int(t) takes cv_pp(t − 12)
and loan_bal(t − 12), which is the annual grid’s t − 1 under the month index.
pols_if(t) is on the same clock: the number in force at the start of month t —
the notes’ l_t, l_0 = 1 at issue. See
below.
Within the month the order is the notes’: premium, rider premium, premium tax and expenses at the beginning; then deaths, and — where the month is an anniversary — loan-interest capitalization and the dividend credit, then surrenders and, in the final month only, maturity at the end. So deaths carry the paid-up additions entering the month while surrenders carry the closing ones, including any dividend just credited.
Inputs are external files#
The six input CSVs live in this directory, beside run.py — not inside the model
folder. WholeLife_US_S/ holds nothing but formulas:
products/whole_life/
model_point_table.csv <- inputs live here
cv_table.csv
nsp_table.csv
np_guar_table.csv
mort_table.csv
premium_rates.csv
run.py
README.md
WholeLife_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, and Projection[1].data is Projection[2].data. A test
counts the reads.
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 WholeLife_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 |
|---|---|---|
|
Fifteen points. Point 1 is the worked-example anchor cell (WL_PAR / M45 / STD_NT / $100k / $1,800 / PUA / annual mode), carried as an in-force point at duration 9 with $4,100 of paid-up additions; points 2–10 are the same policy as new business under each dividend option, each in-scope rider, the limited-pay variant and the female cell; points 11–13 are the final-expense variant; point 14 is the term blend with no rider premium funding it; point 15 is point 2 on monthly premium mode, added with the monthly grid |
anchor from the worked example std; FE points from the sourced rate table [S7]; modal factors [S1] [S7] |
|
Guaranteed cash value per $1,000 of face by premium period, sex, issue age and policy year, with a |
policy years 9 and 10 of the M45 pay-to-100 cell — the model’s |
|
Endowment-at-100 net single premium per 1 of paid-up face by sex and age |
age 55 male is the worked example’s 0.42 std; the curve is std generated and equals exactly 1.000000 at age 100. It is not the endowment NSP implied by |
|
Nonforfeiture net level premium per $1,000 by sex and issue age, keyed by premium period |
the M45 pay-to-100 cell is the worked example’s 13.00 std; the rest is |
|
Guaranteed mortality |
male age 54 is the worked example’s 0.00320 std; the rest is a std illustrative Makeham curve with a 3-year female setback — not a published table, and not the 2017 CSO |
|
Final-expense annual premium per $1,000 by plan, sex, class and issue age |
sourced [S7] (California edition) |
What the input files key on, and why none of them moved with the index#
The 0-based time index is the model’s; the shipped CSVs were not re-keyed, because none
of them is keyed by the model’s t:
File |
Time-like column |
Decision |
|---|---|---|
|
|
Left alone. It is the contractual, 1-based policy-year label of a cash value schedule, not the frame’s index — and it stayed annual through the move to a monthly frame, because it is the contract that is annual. |
|
|
Left alone. An elapsed count of completed policy years, which is 0-based by nature: 9 means nine years have run. |
|
|
Added with the monthly grid. The contractual billing mode, read by |
|
|
Attained age, not time; unchanged. |
|
|
Issue age, not time; unchanged |
No other column in any of the six files is on the frame’s time axis.
Naming#
Cells follow lifelib’s basiclife/BasicTerm_S and savings/CashValue_SE wherever those
models have an analogue: pols_* for policy counts, plural nouns for cash flows,
*_rate for rates, *_pp for per-policy amounts, plus model_point, age_at_entry,
sum_assured, policy_term, proj_len, age, net_amt_at_risk, net_cf and
result_cf, and the argument-keyed families claim_pp(t, kind), claims(t, kind) and
pols_if_at(t, timing).
The technical notes use compact actuarial symbols instead; the full mapping lives in the
Projection Space docstring. Seven cases needed care:
Notes |
Cells |
Why |
|---|---|---|
|
|
Three mortality bases off one table. |
|
|
It is the premium-paying period, not the coverage period — those differ on |
|
|
The notes say surrenders; lifelib says lapse, and the |
|
|
The cash surrender value, not a file; reached through the shared |
|
|
Mortality is indexed at the age entering the policy year, paid-up additions bought at its anniversary at the age one higher. Swapping them shifts every dividend purchase by a year |
|
|
The library’s two-speed convention: the unsuffixed cells is the annual rate the notes tabulate, the |
|
|
The annual premium the notes call |
|
|
|
|
|
The notes are outgo-positive and the library is income-positive, so the stream is published under both names — below |
risk_class follows this product’s notes; Term_US_S calls the same concept
rate_class.
The worked example sets the PUA-block dividend aside#
The notes’ worked-example table computes its last five steps from the base-block
dividend alone, and says so: “For clarity the PUA-block dividend D^PUA is omitted
from this table; in the model it adds (0.02 · PUACV_107) + (0.00096 · (PUAF_107 − PUACV_107)) to the amount in step 9.”
Rather than reproduce four of the fifteen steps and quietly miss the rest, the
Reference pua_div_on ships False, so the base deterministic run reproduces the
worked example exactly — the same device Term_US_S uses when it ships
conv_rate_base = 0 because its worked example sets conversion aside.
It is a reproduction switch, not a claim about the product. Paid-up additions are
dividend-eligible, that compounding is the notes’ first-ranked sensitivity, and
pua_div_on = True is the product-faithful setting: on the anchor cell it raises the
policy-year-10 dividend — div_credited(119), at the anniversary — from 326.25 to 361.58 and, on the new-business
point, paid-up-additions face at maturity from 83,675 to 134,042. div_pua(t) implements the notes’ formula
either way, and a test asserts its value against the notes’ own parenthetical —
35.331623 on the anchor cell — so neither reading can be lost.
The dividend is rounded to the cent, and that is load-bearing#
div_round_digits = 2 rounds the base dividend before it buys paid-up additions. This
looks cosmetic and is not.
The notes’ worked example adds its displayed margins — 216.00 + 85.25 + 25.00 = 326.25 — and then divides 326.25 by the net single premium to get 776.79 of paid-up
additions face. The exact mortality margin is 85.248, so the unrounded dividend is
326.248, and 326.248 / 0.42 = 776.7810, which displays as 776.78. One displayed
cent, because the exact value sits just below the rounding boundary — and it propagates
into PUAF_119 — the model’s pua_face(119), 4,876.79 — and into the death benefit.
Declared dividends are credited in whole cents, so the model rounds; that reading makes
every one of the fifteen steps reproduce. Setting div_round_digits = None turns it off,
and a test pins both values so the gap cannot be closed silently in either direction.
Neither is “correct”: the notes’ own arithmetic is what is ambiguous.
The anchor model point is in force, not new business#
The worked example walks through policy year 10 — the months t = 108 … 119 — of a
policy that already holds 9,500 of guaranteed cash value and 4,100 of paid-up additions at
the anniversary it opens on. Paid-up-additions face is a projected state variable, so the
only faithful way to hold it at 4,100 is to make the anchor an in-force point:
duration_inforce = 9, puaf_inforce = 4100. Both columns are in the notes’ own
model-point attribute table, and this is what they are for. duration_inforce is an
elapsed count of policy years and so already 0-based: proj_start() is therefore
12 × 9 = 108 for point 1 and result_cf() begins at t = 108 and ends at t = 659;
point 2 is the same policy issued as new business and runs the full 660 months,
t = 0 … 659.
The alternative — tuning the shipped tables until a new-business projection happened to produce 4,100 at duration 9 — would have been fitting the model to the answer.
The net flow is published under both signs: liability_cf and net_cf#
The whole-life notes print
NetCF_t = −P·l − A·l + E·l + q^e_m·l·DB + w_m·l(1−q^e_m)·CSV + D^cash·l(1−q^e_m)
+ MAT·l(1−q^e_m)·1{t=T−1}
with the sign convention stated inline: outgo positive. The other eleven reference
models in products/ all define net_cf the other way round, income less outgo — the
sign Term_US_S sets. One name cannot carry both without result_cf()["net_cf"]
becoming uncomparable and unsummable across the library.
So the model publishes the stream twice, under two names, and both are result_cf()
columns:
Cells |
Sign |
What a positive value means |
Use it for |
|---|---|---|---|
|
outgo positive |
money leaving the insurer |
reconciling against the technical notes, which print exactly this |
|
income positive |
money arriving at the insurer |
anything that crosses models — summing, comparing, aggregating |
net_cf(t) = −liability_cf(t) exactly, and a test asserts it year by year on two model
points and on the result_cf() frame. This is the pattern SPIA_US_S and
DIA_US_S already use for the same clash. Nothing about the whole-life
notes’ own convention is denied — it is kept under a name that does not collide with the
library-wide one, because a sign error in a 55-year liability projection is invisible in
any summary statistic.
The four guarantee-basis tables are not one construction, and cannot be#
The notes’ first “known modeling pitfall” is a mismatch between the cash value table and
the NSP/annuity functions: if they come from different bases, PUACV ≠ PUAF at age
100 and the dividend recursion leaks. The instruction is to “regenerate all
guarantee-basis quantities from one 2017 CSO / 4% source.”
The shipped tables do not do that. They are pinned to their worked-example anchors
one at a time: mort_table.csv to q^g_54 = 0.00320, nsp_table.csv to
NSP_55 = 0.42 and NSP_100 = 1, np_guar_table.csv to NP_g = 13.00, cv_table.csv
to 95.00 and 112.00 per $1,000 on its policy-year 9 and 10 rows (cv_pp_anniv(9), cv_pp_anniv(10)). This is a disclosed divergence, not an oversight,
because the worked example’s own anchors are unreachable on any single basis:
On one mortality table at interest i, endowment insurance and the annuity-due satisfy
A_{x:n|} = 1 − d · ä_{x:n|} with d = i / (1 + i). The notes’ definition
NNLP = F · NSP_x / ä_{x:(100−x)|} therefore collapses to
NNLP / F = d · NSP_45 / (1 − NSP_45)
and the worked example’s NNLP = 13.00 per $1,000 at i = 4% forces
NSP_45 = 13 / (1000 d + 13) = 0.252616. But the endowment recursion
NSP_y = v · (NSP_{y+1} + q_y (1 − NSP_{y+1})) gives NSP_y ≥ v · NSP_{y+1} for every
q_y ≥ 0, so
NSP_55 ≤ NSP_45 · 1.04^10 = 0.252616 × 1.480244 = 0.373933 < 0.42
whatever mortality is assumed. Read the other way, NSP_55 = 0.42 forces
NSP_45 ≥ 0.42 / 1.04^10 = 0.283737 and hence NNLP ≥ 15.236 per $1,000 — 17% above
the notes’ 13.00. Steps 3 and 10 of the worked-example table are mutually exclusive.
The size of the resulting gap is worth stating plainly. Recomputing the endowment NSP
from the shipped mortality at 4% gives 0.330820 at age 55 against the shipped 0.420000
(+27.0%) and 0.236184 at 45 against 0.258170 (+9.3%). Reconciling the two needs a
guarantee interest rate that falls from 5.99% at age 45 to 4.89% at 54, 2.08% at 80 and
0.02% at 99 — never the 4.00% int_rate_guar that div_int credits excess interest
against. Inverting the shipped curve for the implied q at 4% is worse still: the male
curve implies a negative mortality rate at every age from 18 to 57 and a rate above 1
from age 89 on (female: 18–62 and 90 on).
test_the_guarantee_basis_is_not_one_construction pins all of this, so the mismatch
cannot quietly change size or quietly close.
What the shipped tables do guarantee are the two endpoints whose failure the pitfall is actually about, and both are asserted:
nsp = 1.000000at attained age 100, sopua_cv(T − 1) == pua_face(T − 1)exactly, andcv_per_1000 = 1000.00on the last policy year of the schedule, socv_pp(T − 1) == sum_assured(), whereT = proj_len()andT − 1is the last projected period.
Neither block leaks at maturity. What is missing is the means — one basis — not the endpoints.
Swapping in the real 2017 CSO / 4% tables means replacing all four files together;
replacing mort_table.csv alone is precisely the pitfall the notes warn about, and the
Data docstring says so. It also means the worked example will stop reproducing, which
is the honest price of the notes’ own arithmetic rather than something to tune away.
The term-blend rider needed two decisions the notes do not make#
The notes originally gave the blend as: OYT face = max(TF − F − PUAF_t, 0), the dividend
first pays q^sc · OYT_t · v_g, remainder buys paid-up additions.
It is circular. PUAF_t is bought with the dividend that is left after the term
cost, which is computed from PUAF_t. The model sizes the layer on the block entering the
policy year instead — pua_face(anniv − 12), or puaf_inforce() where that falls
before the frame — and says so in the oyt_face docstring. The notes were rewritten to
match, and now read max(TF − F − PUAF_{prev anniv}, 0) with the cost at
q^{sc}_{x+dur+1} · OYT · v_g.
It has no shortfall rule. Nothing in the notes says what happens when the dividend
cannot pay for the whole gap. Left uncapped, the model would report a term face it never
charges for and inflate the death benefit by the difference. So the layer is capped at
D_t (1 + i_g) / q^sc_{x+t+1} — as much term as the dividend actually buys — and
claim_pp(t, "DEATH") is written as F + PUAF_{t−1} + OYT_t, which equals the notes’
“target face plus excess paid-up additions” whenever the gap is funded and stays
correct when it is not.
Whether the cap binds is a property of the funding, not of the design, so the model carries both cases:
Model point 8 — 2× target, $5,000 rider premium |
Model point 14 — 2× target, no rider premium |
|
|---|---|---|
cap binds in |
policy year 1 only, where |
policy years 1–3, while the dividend is still small, and every year from the thirtieth on as |
crossover |
policy year 8: the rider money closes the gap and the blend becomes pure paid-up additions |
never; |
Point 8 is how blends are actually funded and exercises the uncapped branch; point 14 exists so the shortfall branch is exercised too, and so the claim that the block never crosses over without rider money is a test rather than an assertion.
pols_if is the start-of-month count#
Every other state variable in these notes is an end-of-period value, but the in-force
probability is read at the start of the period, because that is what the period’s cash
flows are earned by: the notes write every term of NetCF_t over the same count that pays
the premium. Reporting the end-of-period count in a pols_if column would put a policy
count on a row whose cash flows belong to a different count, and the printed table would
stop reconciling: 1,800 of premium beside 0.98 policies.
pols_if(t) is therefore the number in force at the start of period t — the notes’
l_t, l_0 = 1 at issue — which is both the weight on that same result_cf() row and
what pols_if means in every other model in this library but SPIA_US_S
(Term_US_S.pols_if(0) is pols_if_init(); lifelib’s CashValue_SE.pols_if(t) is
pols_if_at(t, "BEF_MAT")).
premiums(t) / premium_net_pp(t) == pols_if(t) is an identity wherever an instalment
falls, and a test asserts it;
pols_if(proj_start()) == pols_if_init() on every model point, in force or new business.
SPIA_US_S is the library’s one documented exception: its pols_if(t) is the notes’
end-of-month obligation indicator max(C(t), l_alive(t+1)), a closing measure, so
pols_if(0) != pols_if_init() there — see that product’s own model.md.
The end-of-period count is not lost. It is pols_if_at(t, "AFT_DECR"), the notes’
l_{t+1} and the fourth timing string: after deaths, surrenders and — in the final
period — maturities, which is where the notes’ processing order ends. CashValue_SE has
no name for that point, hence a new string rather than a reused one; it is documented in
the pols_if_at docstring and pols_if_at(t, "AFT_DECR") == pols_if(t + 1) by
construction.
pols_maturity and the terminal year#
pols_if(t) is zero from t = proj_len() onward, because everything still in force in
the final projected period T − 1 matures at attained age 100 and the contract
terminates. That is not a decrement — the modelled contract simply ends — but the
roll-forward does not close without naming it, so pols_maturity(t) carries it, zero in
every period but the last:
pols_if(t) − pols_if(t+1) = pols_death(t) + pols_lapse(t) + pols_maturity(t)
The notes’ lapse schedule cooperates: it is “0 through the final policy year”, so the survivors of the final month mature rather than surrender.
check_pols_roll_fwd() takes no argument and returns a bool over every projected
period — the shape every check_* in this library has, so one test can call the same
check across all twelve models — and check_pols_roll_fwd_resid(t) returns the signed residual
of a single month for when it fails. check_pua_roll_fwd() / check_pua_roll_fwd_resid(t)
are the same pair for the paid-up-additions block, which is this product’s analogue of
CashValue_SE.check_av_roll_fwd().
Standardizations used#
Everything in this list is std: the 6.00% dividend interest rate snapshot and the
whole three-factor dividend parametrization; the 0.70 experience factor, which produces
both the scale mortality and the best-estimate mortality (the notes’ “consistency
trap”); the $25 expense margin; the dividend floor at zero and the no-dividend-in-year-1
convention; rounding the dividend to the cent; the par lapse schedule (5% grading to 2%
by year 10, level after, zero in the maturity year) and the final-expense schedule (12%,
10%, grading to 6% by year 5); acquisition expense 90% of first-year premium plus $250;
maintenance $60 a year accruing at a twelfth a month and inflating at 1.02^(t/12);
premium tax 2%, which the notes’ processing order
collects but their one-line NetCF formula omits — the model follows the processing
order; the 10% load on paid-up-additions rider payments; the 2× term-blend target and
both blend decisions above; routing the final period’s REDUCE_PREM dividend to paid-up
additions, since there is no premium after it to offset; the 3% accidental share of
final-expense deaths; truncation of the projection at attained age 100; holding the loan
at loan_utilization × CV_t; the funding-progress construction that makes the pay-to-100
cash value schedule sex-distinct; the constant-force conversion of every annual rate to its
monthly counterpart; the straight-line interpolation of the cash value and the net single
premium between anniversaries; applying the REDUCE_PREM offset to the annual premium and
letting the instalments split what is left; and every value in cv_table.csv, nsp_table.csv,
np_guar_table.csv and mort_table.csv — which, as set out above, are four separate
constructions rather than one.
Switched off by default, all implemented and all one Reference away: pua_div_on
(see above), dyn_lapse_on with competitor_rate (the interest-sensitive lapse
multiplier), and prem_offset_on with prem_offset_share (the premium-offset
behavioural overlay, applied proportionally rather than by splitting the cohort — a
std simplification of the notes’ “a fraction 0.50 of policyholders switch”).
Not implemented, and named as such in the model docstring: reduced paid-up and
extended term nonforfeiture, the automatic premium loan, partial surrender of paid-up
additions, terminal dividends and dividends credited at death, variable and adjustable
loan rates, the age 100–121 tail, state variations,
and any §7702 / §7702A policing — mec_flag() flags a model point that would need the
test rather than performing it, exactly as the notes prescribe.
Tests#
tests/test_whole_life_us.py asserts all fifteen steps of the worked example to the
cent, the PUA-block dividend against the notes’ parenthetical, the in-force and
paid-up-additions roll-forwards, the four cash-value/NSP endpoint invariants, one test
per “known modeling pitfall” the notes list, the dividend-rounding divergence in both
directions, both signs of the net flow and that they are exact negatives, the
start-of-month meaning of pols_if and its reconciliation with its own row, each
dividend option, each rider, the final-expense graded benefit and its premium formula
against the sourced rates, the annual-equivalence invariant (pols_if(12k) against the
annual recursion at every anniversary of the frame), that the two interpolations are exact
at the anniversary and that no annual event is credited off one, the modal-premium
machinery against model point 15, result_cf() shape, and that all fifteen model points
project. Four of them pin the divergences and the decisions written up above so they
cannot quietly change:
Test |
Pins |
|---|---|
|
the impossibility arithmetic, and the implied guarantee interest rate by age — the size of the mismatch between |
|
|
|
the female schedule below the male at every duration, equal at maturity, on every premium period |
|
total dividend credited = total delivered, under each of the four dividend options, including the final year |
python -m pytest tests/test_whole_life_us.py -q
Verifying this copy#
tests/test_whole_life_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_whole_life_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.