Implementation Notes#
Status: Draft, 2026-08-15. Built from
products/whole_of_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. The contractual mechanics are sourced — the moratorium and its return-of-premiums benefit, accidental death paying the full cash sum from day one, premium cessation at 90 with cover continuing, the absence of any surrender value, the suicide clause, the escalation ratios, the pro-rata paid-up formula. Every rate is a std standardization: the CMI’s current tables are restricted to Authorised Users REG-R22 R7, so both mortality bases are proxies shaped like the tables the notes name, and no insurer publishes whole of life premium rate tables, so the premium is a model point input.
Run it#
python products/whole_of_life/run.py # the O50 anchor cell
python products/whole_of_life/run.py 5 # the underwritten cell
Three lines to the same thing:
import modelx as mx
model = mx.read_model("products/whole_of_life/WOL_UK_S")
model.Projection[1].result_cf()
Two cells, one engine#
RefWOL-UW ( |
RefWOL-O50 ( |
|
|---|---|---|
Underwriting |
full |
none — guaranteed acceptance |
Premiums |
level, for life |
level, ceasing at the anniversary on or after the 90th birthday; cover continues |
Year-1 death |
sum assured, less a suicide refund carve-out |
twelve-month moratorium: non-accidental death returns premiums paid, accidental death pays the full cash sum |
Mortality basis |
assured-lives shape, 100% |
population shape, 120% anti-selection loading |
Anchor |
M40 NS, £150,000, £101.25/month |
F70 NS, £5,000, £30.00/month |
Crossover |
none at the anchor |
month 167 — 13 years 11 months |
Neither cell has an account value, a unit fund or a surrender value. Both are pure
decrement protection models: premiums in, death benefits and expenses out, weighted by
survivorship. That is the deliberate contrast with
WholeLife_US_A, the U.S. whole life model in
the same library, which is built around a guaranteed cash value schedule, three-factor
dividends, paid-up additions, a dividend accumulation balance and policy loans. None of
that machinery exists here — no cv_pp, no div_*, no pua_*, no loan_bal — and a
lapse on either UK cell pays exactly nothing.
The bases must not be swapped#
Full underwriting restores select experience, so the UW cell takes an assured-lives shape. Guaranteed acceptance removes underwriting, so the O50 pool cannot be better than the population and self-selects worse; it takes a population shape with a 120% anti-selection loading std. The CMI analyses non-underwritten whole of life separately from underwritten business for exactly this reason R7.
mort_basis() and mort_loading() are therefore derived from cell() rather than
left as free parameters. Feeding either cell the other’s basis produces
plausible-looking but wrong margins, and the FCA’s price differential between the two
designs — £71.73 against £8.10 per £1,000 of cover R2 — is the scale of that error.
The moratorium is a discontinuity, not a curve#
During the O50 cell’s first twelve months:
a non-accidental death returns
CumPrem(t)— the premiums paid, not the cash sum and not an annualized premium;an accidental death pays the full cash sum from day one.
At month 13 the full cash sum becomes payable for any death, and expected death outgo jumps about elevenfold on the anchor cell: £0.91 at t = 12 against £10.00 at t = 13. That step is the signature of the product and must not be smoothed — an annual-grid implementation has to split policy year 1 explicitly.
Note where the year-one outgo actually comes from. At month 1 the blended benefit is
0.97 × £30 + 0.03 × £5,000 = £179.10: five sixths of it is the small accidental tail
paying the full cash sum, not the premium refund. An implementation that dropped the
accidental split would understate year-one claims by about that much.
The accidental-multiplier variant doubles the accidental benefit, but only on and after
the first anniversary [S7]. Inside the moratorium the accidental benefit is already the
full cash sum, so doubling it there — or applying the multiplier to all deaths — overstates
outgo. adb_multiplier() is applied in exactly one place, benefit_pp(t, "ACC"), past the
moratorium only.
Lapse pays nothing, which is the whole economics#
There is no surrender value at any duration on either cell [S1] [S4] [S5] [S7] [S9] [S10], so
a lapse produces no cash flow at all: its entire effect is through pols_if. Every lapse
therefore extinguishes a liability for nothing, the best estimate falls monotonically
as assumed lapses rise, and the FCA records that without the continuing-payer
cross-subsidy insurers would need to rely on lapses to remain profitable R2.
Two consequences are wired into the model rather than left as prose:
No lapse after premiums cease. There is nothing left to stop paying once the O50 cell
reaches cessation, so lapse_rate(t) is zero from month 241 on the anchor. Applying a
lapse decrement past cessation silently destroys liability, and the notes list it as a
pitfall. The post-cessation period is pure outgo — the worked example’s month 241 row
shows premium income at zero while death outgo rises.
The pro-rata paid-up variant is a different product. Once half the expected payments
have been made, a would-be lapse converts to a paid-up policy at
SA × N_paid / N_expected [S9] instead of forfeiting everything. That converts lapse
profit into a retained pro-rata liability and collapses most of the lapse sensitivity —
on the shipped points it takes the O50 anchor’s total net cash flow from about −£41 to
about −£418. It is a variant to model separately, never a small adjustment.
It is carried as a second population strand:
Cells |
What it holds |
|---|---|
|
policies still on full cover — the notes’ |
|
paid-up policies |
|
the aggregate paid-up cover in force |
|
the sum, which is what the maintenance expense is carried on |
Carrying the aggregate benefit alongside the count is what removes the need for a
per-conversion cohort dimension: the paid-up payout depends on when the policy
converted, but every paid-up policy thereafter rolls forward on the same survival factor,
so the sum of their payouts satisfies the same recursion as the count. Death outgo on the
strand is then simply pu_benefit(t) × q_m(t).
The crossover#
On the O50 cell cumulative premiums eventually exceed the cash sum. crossover_mth()
finds the month: on the anchor cell floor(5000/30) + 1 = 167 months — 13 years 11
months, which is the FCA’s stylised example exactly R2. Total premiums are capped at
P × T_cess (£7,200 against a £5,000 cash sum), so a crossover exists only where the cash
sum is below that cap; the underwritten anchor has none.
It is searched rather than closed-form so that an escalating variant still resolves,
and it is reported rather than acted on. The notes’ crossover-aware lapse module,
which raises lapse past the tipping point, is a pure stress dial: lapse_crossover_beta
is 0 in the base run, and the FCA has seen no evidence that a significant proportion of
customers reach the premium caps R2.
pols_maturity means something different here#
The name is borrowed from the term models, but whole of life has no maturity. The cells is the population still alive when the projection is truncated at the limiting age, so it is a truncation artefact rather than a benefit, and it pays nothing. It exists so the roll-forward closes in the last month.
check_truncation() asserts it is negligible, and that is the substantive statement: the
shipped mortality tables reach 1 well before omega_age = 120, so the population is
exhausted inside the projection rather than cut off by it. A limiting age set too low
would drop liability off the end instead of merely rounding it.
Age last birthday#
This is the one model in the library on ALB rather than age nearest birthday: the underwritten cell’s specimen defines entry age x as “before the (x+1)th birthday” [S10], which is ALB, and the over-50s documents price on “age at outset” without stating a basis std. All age lookups here are on that one basis.
Inputs are external files#
The three input CSVs live in this directory, beside run.py — not inside the model
folder. WOL_UK_S/ holds nothing but formulas:
products/whole_of_life/
model_point_table.csv <- inputs live here
mort_table.csv
lapse_table.csv
run.py
model.md
product-spec.md <- the documents this model implements
technical-notes.md
sources.md
WOL_UK_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. Projection is parameterized by
point_id, so the CSV readers live in an unparameterized Data Space and each file
is read once per model rather than once per model point; a test counts the reads.
Reference |
Cells |
File |
|---|---|---|
|
|
|
|
|
|
|
|
|
Both the mortality and the lapse table are keyed by the cell as well as by the usual rating factors, for the reason above.
File |
Contents |
Provenance |
|---|---|---|
|
Seven model points. Point 1 is the worked-example anchor cell (O50 / F70 / non-smoker / £5,000 / £30.00 a month / cessation at 240 months); points 2–4 are the accidental-double, pro-rata paid-up value and RPI-increasing O50 variants; points 5–6 are the underwritten cell level and on the 5% increasing-cover variant; point 7 is an O50 male smoker on a longer cessation |
anchor cell std, technical notes’ worked example |
|
Annual mortality by basis × sex × smoker × age 18–120, capped at 1, with a |
std throughout. The |
|
Annual lapse by cell and policy year: O50 8/6/4/4/4/4 %, UW 6/5/3/3/3/2 % |
std; no public UK whole of life lapse study was retrieved, and on a product with no surrender value this is the single largest lever on the liability |
The mortality table is a std construction with no separate “worked example” basis
switch, because the notes’ walk-through basis and the shipped population basis are the
same object: the notes describe their q(y) = 0.024 × 1.10^(y−1) as “a 0.020
population-style rate at 70 × the 120% anti-selection loading, with 10% p.a. age
progression”, so the shipped table is that, extended over the full age range and capped
at 1. The worked example reproduces without any special-casing.
Sign convention, and reading the worked example#
net_cf is income positive, the notes’ own sign and the library-wide one, so there
is no outgo-positive liability_cf companion.
One caveat for a reader checking the worked example by eye: the notes’ table omits
expenses entirely, “for clarity”, and prints premium income and death outgo as separate
positive columns. So net_cf will not equal any column of that table; the tests assert
premiums(t) and claims(t, "DEATH") against it directly.
Naming#
Cells follow lifelib’s basiclife/BasicTerm_S and savings/CashValue_SE: pols_* for
policy counts, plural nouns for cash flows, *_rate for annual rates and *_rate_mth
for monthly ones, *_pp for per-policy amounts, claims(t, kind) and
benefit_pp(t, kind) with uppercase kind strings. The full symbol mapping lives in the
Projection Space docstring. Three cases needed care:
Notes |
Cells |
Why |
|---|---|---|
|
|
The pro-rata paid-up variant splits the population into full-cover and paid-up strands; on every other model point the three coincide |
|
|
A constant in the notes and a function of |
(no analogue) |
|
Borrowed from the term models but a truncation artefact, not a benefit — see above |
Standardizations used#
Everything in this list is std: both mortality bases and their sex/smoker factors; the 120% anti-selection loading and the 100% assured factor; the mortality improvement dial (zero in base, a flat annual rate as the proxy for a subscriber-restricted CMI projections model); both lapse tables; the accidental share of deaths (3%) and the suicide share of year-one deaths (1%); premium cessation at the anniversary on or after 90; the 5% increasing-cover pick; the flat 3% RPI snapshot; full escalation take-up (the three-declines rule cannot be represented in a deterministic run and is not implemented); the crossover lapse stress dial; the assumption that all would-be lapses convert once the pro-rata paid-up halfway point is passed; acquisition £150 / £300 and maintenance £30 / £50 a year inflating at 3%; initial commission at 25% of first-year premiums; and death-before-lapse as the processing order.
Deliberately excluded, per the notes: terminal illness acceleration (it pays the same amount earlier and is not an additional decrement — modelling it as one would double-count), claims interest at BoE − 0.5%, premium reduction options, payment holidays, and the anti-selective milestone-benefit increases on the underwritten cell.
Tests#
tests/test_whole_of_life_uk.py asserts all eleven rows of the notes’ worked example to
the penny and the in-force column to five decimals, the month-12/13 moratorium
discontinuity and its size, the accidental split’s share of year-one outgo, the
month-167 crossover, that lapse pays nothing and stops at cessation, both escalation
variants, the pro-rata paid-up strand and its effect on the liability, the two mortality
bases against each other, and that the truncation residual is negligible.
python -m pytest tests -q