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()

Time index and the frame#

t is the policy month, 0-based, lifelib’s own convention: t = 0 is the issue month, month t runs from time t to time t + 1, pols_if(0) == pols_if_init(), and every model point is new business so the frame opens at t = 0. proj_len() is the number of projected months, 12 × (omega_age entry_age), and the exclusive end of the frame: result_cf() and result_pols() are indexed t = 0 proj_len() 1 and have proj_len() rows — 600 on the anchor cell, t = 0 599, the last of them the last month of age 119.

The policy year is a derived, 1-based contractual label, policy_year(t) = t // 12 + 1 (duration(t) = t // 12 completed years, age(t) = age_at_entry() + t // 12 on ALB); anniversaries fall at t = 12, 24, . The month-count parameters are elapsed counts and compare against t at the boundary: the moratorium is t < moratorium_mths(), premiums are due while t < cessation_mths() (so t = 240 is the first premium-free month on the anchor), the suicide window is t < suicide_mths, and payments_made(t) = min(t + 1, T_cess). crossover_mth() returns the 0-based month and -1 where there is none, since 0 is now a real month.

product-spec.md states the same mechanics in contractual month numbering, where the issue month is month 1: its t is this t + 1, so its t <= 12 is this t < 12, its CumPrem(t) = P x min(t, T_cess) is this P x min(t + 1, T_cess), and its crossover at month 167 is this t* = 166.

The notes’ l(t) is the in-force probability at the start of month t with l(0) = 1, so pols_if(t) is exactly l(t) and the worked example’s rows are the same numbers at the same t.

CSV decisions. No input column is the frame’s t, so no file changed:

File

Column

Decision

lapse_table.csv

policy_year (1–6)

contractual 1-based label; left as is, read through policy_year(t) = t // 12 + 1, capped at the table’s last year

model_point_table.csv

cessation_months, moratorium_months

elapsed counts (240 / 300 and 12 months), not points on the time axis; unchanged, compared against t with < / >=

mort_table.csv

age

attained age last birthday, not a time key; unchanged, read at age(t)

Two cells, one engine#

RefWOL-UW (cell = "UW")

RefWOL-O50 (cell = "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

t = 166, the 167th premium — 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_S, 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, t = 0 11:

  • 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.

From t = 12 (the thirteenth policy month) the full cash sum becomes payable for any death, and expected death outgo jumps about elevenfold on the anchor cell: £0.91 at t = 11 against £10.00 at t = 12. 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 t = 0 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 t = 240, the first premium-free month, 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 t = 240 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

pols_if(t)

policies still on full cover at the start of month t — the notes’ l(t), and the column the worked example prints

pols_pu(t)

paid-up policies

pu_benefit(t)

the aggregate paid-up cover in force

pols_all(t)

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 t = floor(5000/30) = 166, the month in which the 167th premium is paid — 13 years 11 months of premiums, which is the FCA’s stylised example exactly R2. It returns -1 where there is no crossover. 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

model_point_file

model_point_table()

model_point_table.csv

mort_table_file

mort_table()

mort_table.csv

lapse_table_file

lapse_table()

lapse_table.csv

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

model_point_table.csv

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

mort_table.csv

Annual mortality by basis × sex × smoker × age 18–120, capped at 1, with a provenance column

std throughout. The population rates are anchored so that q(F, NS, 70) × 1.20 = 0.024 with 10% p.a. age progression — the notes’ walk-through basis exactly — and the assured rates are anchored at q(M, NS, 40) = 0.00090 on the same progression. Sex and smoker cells are flat factors. Neither basis is a published table

lapse_table.csv

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

l(t)

pols_if / pols_pu / pols_all

The pro-rata paid-up variant splits the population into full-cover and paid-up strands; on every other model point the three coincide

SA

sum_assured / cover_pp

A constant in the notes and a function of t here, because the escalating variants move it

(no analogue)

pols_maturity

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 at the same t (0-based, keyed 0, 5, 11, 12, …, 240), the moratorium discontinuity between t = 11 and t = 12 and its size, the accidental split’s share of year-one outgo, the crossover at t = 166, that lapse pays nothing and stops at cessation (t = 240), both escalation variants, the pro-rata paid-up strand and its effect on the liability, the two mortality bases against each other, that the truncation residual is negligible, and that result_cf() is indexed 0 proj_len() 1.

python -m pytest tests -q