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()
The time index, and the two kinds of index in this model#
t is the month from the annuity start date, 0-based: t = 0 is the first
projected month, month t runs from time t to time t + 1, and proj_len() is the
number of months projected — the exclusive end of the frame, so result_cf() and
result_pols() are indexed by range(proj_len()) and the last row is proj_len() - 1. On
the worked model point that is 636 rows, t = 0 … 635. The contractual policy year is
the 1-based label policy_year(t) = duration(t) + 1 with duration(t) = t // 12, derived
and never indexed by; age(t, life) is age_at_entry(life) + duration(t). This is the
library-wide convention (basiclife/BasicTerm_S, savings/CashValue_SE:
for t in range(proj_len())), asserted for every model point in
tests/test_model_conventions_uk.py.
The state cells are indexed differently, and deliberately: lives_if(k, life),
lives_if_last(k), cum_annuity_pp(k, kind) and vp_balance(k) take a time point
k, with k = 0 at the start date. l(0) = 1 and G(0) = 0 are the seeds, month t opens
with the state at k = t and closes with the state at k = t + 1, and that index does
not move with the frame: the annuitant who dies in month 16 is alive at time 16 and
dead at time 17, and the value-protection balance that nets “instalments already paid”
is G(16) on either labelling. Every flow, rate and factor — annuity_pp,
payment_factor, certain_floor, lives_death, claims, pols_if, expenses — is
indexed by the month it belongs to. payment_surv_mth(t) maps between the two: it
returns the time point at which survival is measured for month t’s instalment,
t + 1 on arrears and t on advance.
rpi_index(a) and rpi_peak(a) sit on a third scale, an anniversary count with
a = 0 at outset and one step per policy year — rpi_index(1) is the reference level
one year in, not one month — and it is likewise unmoved by the frame. The letter is
a, not k, precisely so that the two cannot be read as the same index.
The result_pols() state columns (lives_if_1, lives_if_2, cum_annuity_all,
vp_balance) are therefore read at t + 1: they are the closing values of the month
whose flows the same row of result_cf() carries.
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 16; 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 17 to month 119 —
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(a)
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 = 11 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)) — the death benefit measured against instalments
already paid [S1 p11] [S2 §7], G being the time-point schedule: G(k) sums the
instalments of months 0 … k − 1, so month t’s instalment enters at G(t+1) whether it
falls at the month’s start or at its end. Two timing rules matter and both are the notes’
pitfalls:
on arrears timing the balance is
G(t), the opening one, 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+1)— 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 16 with quarterly arrears payments at months
2, 5, … gives h = 1 — one complete month since the month-14 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 |
Which input columns carry a time index#
Neither file is keyed by t: model_point_table.csv is keyed by point_id and
mort_table.csv by (sex, age), so no key column moved with the frame. Of the
time-valued columns:
File, column |
Decision |
Why |
|---|---|---|
|
shifted −1 (17 → 16 on points 1, 3, 4, 9, 10); |
a point on the frame’s time axis: the month in which the scenario life dies. The blank sentinel moved from |
|
unchanged (120) |
an elapsed count of months, not a point on the axis; the comparison moved instead, |
|
unchanged (2026) |
a calendar year, the improvement-scale anchor |
|
unchanged |
entry ages |
|
unchanged |
an attained age, not a duration |
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_S use. There is no premium income
in the projection at all: the purchase price is a pricing input paid at outset, before
the first projected month.
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 the start of the death month 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-16 death, the G column including the dependant’s
instalment from month 17, 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. It
also pins the frame: result_cf() is indexed by range(636) on the worked point.
tests/test_model_conventions_uk.py asserts the 0-based frame itself — contiguous,
opening at a t_first >= 0, which is 0 for every point of this model, and ending at
proj_len() - 1 — for every model point.
python -m pytest tests -q