Implementation Notes#
Status: Draft, 2026-08-15. Built from
products/pension_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 mechanics are sourced — the instalment formula, the four escalation bases and the RPI catch-up ratchet, the dependant’s percentage and overlap rule, the guarantee period as an annuity-certain floor, value protection and its exclusivity with the guarantee, the
v + δ ≤ 1bound, the absence of any surrender value. Every rate is a std standardization: the SAPS S3/S4 and PMA16/PFA16 annuitant tables are restricted to CMI Authorised Users R10 R11 REG-R22 REG-R27 and the CMI projections model software with them, so the mortality basis here is an ONS-shaped population proxy with a flat adjustment and a deterministic improvement scale. No insurer publishes an annuity rate card, so the starting income is a model point input.
Run it#
python products/pension_annuity/run.py # the worked-example scenario
python products/pension_annuity/run.py 2 # the same contract, expected basis
Three lines to the same thing:
import modelx as mx
model = mx.read_model("products/pension_annuity/PA_UK_S")
model.Projection[1].result_cf()
Mortality is the model#
After outset the contract has no premiums, no surrender value, no account value and no policyholder options at all [S1 p4] [S2 §1.1, §12] [S5 cl.14.7]. The only decrements are deaths; the only stochastic drivers are longevity and, on the indexed options, inflation.
That is not an omission — it is the design property that makes the liability eligible for
the Solvency UK matching adjustment, whose conditions effectively require this shape
R1. The model therefore has no lapse_rate of any kind, and no dynamic behaviour
formulas. Behaviour enters only at outset, outside the projection, as basis-selection
effects: voluntary annuitants self-select for longevity since the 2015 pension freedoms
R6, which is the direction of the annuitant_adj factor being below 1, and
whole-market enhanced quoting R5 leaves standard-terms lives healthier on average,
which is carried through rating_factor() rather than through any dynamic.
Two mortality bases: table and scenario#
The notes’ worked example is a scenario — “the annuitant dies in month 17; the
dependant survives throughout” — while the rest of the notes projects on an expected
basis. Both readings ship, as a model point column, which is the same device SPIA_US_S
uses for the same reason:
|
|
Model points |
|---|---|---|
|
the generational recursion off the shipped table and improvement scale |
2, 5, 6, 7, 8 |
|
the step function |
1, 3, 4, 9, 10 |
Point 2 is the worked configuration on the table basis and is the run to read for a
realistic cash flow shape; point 1 is the same contract as a scenario and reproduces the
notes row by row. The switch is a std modelling device, not a product feature: it
exists because the verification anchor is a scenario, and retuning assumptions to force a
probability-weighted run onto it would be dishonest.
The guarantee is a floor, not a second stream#
payment_factor(t) = max(certain_floor(t), payment_factor_life(t))
is the notes’ first-listed pitfall written as one line. During the guarantee period the
full instalment is payable regardless of survival, escalating as if the annuitant were
alive [S2 §§6.5–6.6] [S7 §4.2]; an additive construction would pay 1 + l_a and silently
double the guarantee. check_payment_factor() asserts it every month.
The guarantee and value protection never coexist in the representative design
[S2 §§6.7, 7.6] — check_guarantee_xor() asserts no model point carries both. An engine
supporting the combinable variant would have to net guarantee payments off the
value-protection balance [S7 §4.3], or the death benefit is paid twice.
The overlap gate#
overlap = False is the representative default and means the dependant’s stream starts
only at the end of the guarantee period, not at the annuitant’s death
[S2 §§5.9–5.11]. Applying δ from the death date silently converts every without-overlap
policy into the more expensive with-overlap form.
Model points 3 and 4 are the same 10-year-guarantee contract on either side of that
switch, and the difference is exactly the dependant’s stream from month 18 to month 120 —
about £27,600 of total outgo on the shipped scenario. overlap_gate(t) is spelled that
way, rather than as a rate, so that nothing in the model reads as a decrement that is not
one.
Escalation, and the one path-dependent option#
Basis |
Rule |
|---|---|
|
|
|
|
|
|
|
income indexed to the running peak of the RPI reference index [S2 defs] |
The catch-up is a ratchet: a fall in the index freezes income rather than reducing
it, and later rises bite only once the index passes its previous peak. rpi_peak(k)
carries that state across anniversaries. Resetting it each year turns the catch-up into a
plain zero floor and overstates indexed income after a deflation-recovery path.
Under the deterministic 3% RPI assumption std the index is monotone, so the ratchet
never binds and rpi_catchup degenerates to fixed 3% — model points 6 and 2 agree by
construction, and lpi5 at 3% agrees with them too. That is not an accident to be
tidied away. The zero floor, the ratchet and the LPI cap are all inflation options,
and a deterministic path values them at intrinsic only: the floor and ratchet never bind
and the cap never pays off. A market-consistent value needs stochastic inflation, and the
tests assert the degeneracy so that the limitation is visible rather than implied.
Escalation applies on the anniversary, not on payment dates: the year-2 rate does not
reach the t = 12 arrears instalment, which accrued in year 1 [S2 §3.3].
Value protection, and where the balance is measured#
VP(t) = d(t) × max(0, v·P − G(t−1)) — the death benefit measured against instalments
already paid [S1 p11] [S2 §7]. Two timing rules matter and both are the notes’
pitfalls:
on arrears timing the balance is
G(t−1), because the instalment due at the end of the death month is never paid;on advance timing an instalment paid at the start of the death month has been paid, so in an advance payment month the balance is
G(t)— netting it, or the lump sum is overstated by one instalment.
G(t) means two things in the notes, so it takes a kind#
The notes’ state variable table defines G(t) as cumulative gross instalments
scheduled, which is what the worked example’s G column prints. The value-protection
section says it accumulates instalments “while the annuitant is alive” on the first-death
basis and “the dependant’s instalments too” on the last-survivor one. Those are different
objects whenever the dependant’s stream is running, so:
|
What it accumulates |
Used by |
|---|---|---|
|
the deterministic as-if-alive annuitant schedule — which needs no path simulation precisely because it ignores survival |
the |
|
the same plus the expected dependant instalments |
the notes’ printed G column, and the |
On a probability-weighted run the "ALL" figure is an expected cumulative payment
rather than a path-specific one, so the last-survivor balance is an approximation. That is
stated rather than hidden: it is exact in a scenario run, which is the basis the shipped
last_survivor model point (10) uses.
The contractual bound v + δ ≤ 1 on the first-death basis [S2 §7.3] is asserted by
check_vp_bound(); the worked configuration sits exactly on it, at 50% + 50%.
The proportionate final payment#
Arrears contracts may elect a proportionate final payment for the accrued part-period [S2 §4]; the representative default does not, and nothing is paid for the final partial period. With it,
PROP(t) = d_a(t) × (h(t) + 0.5)/(12/m) × inst(next(t)) [std half-month accrual]
On the worked configuration a death in month 17 with quarterly arrears payments at months
3, 6, … gives h = 1 — one complete month since the month-15 instalment — and a stub of
(1 + 0.5)/3 × 1,390.50 = 695.25, which is the notes’ own figure. Model point 9 is the
worked configuration with the option elected, and reproduces it.
Inputs are external files#
Only two CSVs, because this product has almost nothing to parameterize: no lapse
table, no charge scale, no bonus rates, no surrender-value schedule. They live in this
directory, beside run.py — not inside the model folder:
products/pension_annuity/
model_point_table.csv <- inputs live here
mort_table.csv
run.py
model.md
product-spec.md <- the documents this model implements
technical-notes.md
sources.md
PA_UK_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. 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 |
|---|---|---|
|
|
|
|
|
|
File |
Contents |
Provenance |
|---|---|---|
|
Ten model points. Point 1 is the worked configuration as a scenario (£100,000, M65 with F62 dependant at 50%, quarterly arrears, fixed 3%, VP 50% first-death, no guarantee, |
anchor std, technical notes’ worked example |
|
Base annual mortality by sex and age 50–115, capped at 1, with a |
std proxy shaped like the ONS UK national life tables — population mortality, not an annuitant table. Anchored at |
Substituting a licensed basis means replacing mort_table.csv with a same-schema
file — SAPS S3/S4 or the PMA16/PFA16 family — and setting Projection.annuitant_adj to 1
so the population-proxy adjustment stops being applied on top of an annuitant table. No
formula changes.
The mortality construction, and its three standardizations#
q_base = ONS-shaped table rate × α α = 0.80 [std]
q_imp = q_base × (1 − f(x))^(c − c₀) f = 1.25% to age 90, tapering to 0 at 110 [std]
q_rated = min(1, θ × q_imp) θ = 1.0 standard, 1.35 on the enhanced point [std]
q_m = 1 − (1 − q_rated)^(1/12)
Every one of the three factors is a standardization, and the notes are explicit about how weak each is:
α = 0.80 is a shape-level placeholder, not calibrated against any published annuitant-versus-population comparison. It is the weakest link in the reference basis.
The improvement scale stands in for the CMI Mortality Projections Model — CMI_2024 R12, now CMI_2025 REG-R30 — whose software is restricted. It materially understates the age–period–cohort structure of the real model and exists only so the reference implementation is runnable without CMI access. The choice of long-term improvement rate is the single most sensitive judgment in UK annuity valuation, and the CMI model carries no default recommendation for it.
θ is the simplest overlay that reprices longevity without touching contract mechanics; insurers’ real rating structures (postcode, condition-specific factors) are not public.
The liability is a life-contingent stream with no offsetting decrements, so the level of this rate is the single largest lever on it. Treat all reference-basis results as mechanics demonstrations.
Sign convention#
The notes define CF(t) as total gross liability outgo, which is liability_cf;
net_cf is its negative, the library-wide income-positive convention. Both are published
as result_cf() columns rather than one being made to stand for the other — the same
arrangement SPIA_US_S, DIA_US_S and WholeLife_US_A use. There is no premium income
in the projection at all: the purchase price is a pricing input at t = 0.
Naming#
Cells follow lifelib and SPIA_US_S. The full symbol mapping lives in the Projection
Space docstring. Four cases needed care:
Notes |
Cells |
Why |
|---|---|---|
|
|
The notes use one symbol for two accumulations — see above |
|
|
They differ only by case in the notes, as in |
|
|
Not a lapse rate; there is no lapse on this product at all |
|
|
Not a policy count but the probability any payment obligation remains — kept because it is what the rest of the library calls the expense weight |
Standardizations used#
Everything in this list is std: the whole mortality basis — table, α, improvement
scale and taper, θ; the limiting age of 115; the flat 3% RPI and expense inflation; the
starting income; maintenance expense of £30 a year; the half-month accrual in the
proportionate final payment; the scenario mortality switch; joint-life independence
(which ignores broken-heart dependence and so modestly overstates the expected dependant
stream); measuring the value-protection balance at t − 1 on arrears; the age-based
stopping rule in place of the notes’ IF(t) < 1e-6 alternative; and reading the
dependant’s contractual “percentage of the higher of income at death and at guarantee
end” as δ × A(y(t)), which is exact under a non-decreasing escalation path and would
need explicit treatment under a decreasing one.
Deliberately excluded, per the notes: exact-day payment mechanics and stub proportioning, the 30-day cancellation window, GMP-bearing policies and their different escalation dates, and RPI reform risk.
Tests#
tests/test_pension_annuity_uk.py asserts every row of the notes’ worked example to the
penny — the instalment schedule, the anniversary step to £1,390.50, the £43,209.50
value-protection lump sum on the month-17 death, the G column including the dependant’s
instalment from month 18, and the £695.25 proportionate stub — plus the guarantee floor,
the overlap gate on both settings, the four escalation bases and their degeneracy under a
deterministic RPI path, the advance-timing VP netting rule, the v + δ ≤ 1 bound, the
guarantee/VP exclusivity, and that no lapse machinery exists anywhere in the model.
python -m pytest tests -q