Implementation Notes#
Status: Draft, 2026-08-15. Built from
products/unit_linked_bond/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 100.1% death uplift, the surrender value as the bid value of units with no penalty, the 7.5% rolling withdrawal cap, charges accruing daily through the unit price, segmentation into 100 identical policies. Every rate is a std standardization: per-fund charge rate cards are not published (research gap), the CMI’s assured-lives tables are restricted to Authorised Users R8 REG-R30, and no public UK bond persistency study was retrieved — so the charge levels, the mortality basis and the whole surrender table are placeholders.
Run it#
python products/unit_linked_bond/run.py # the anchor cell
python products/unit_linked_bond/run.py 2 # the accumulation cell
Three lines to the same thing:
import modelx as mx
model = mx.read_model("products/unit_linked_bond/ULB_UK_S")
model.Projection[1].result_cf()
result_uf() gives the unit fund recursion column for column as the notes’ worked
example prints it; result_cf() gives the cash flows.
The unit / non-unit decomposition is the model#
UK practice splits this product into two streams, and so does this implementation:
The unit fund — the bid value of units, matched by the linked assets. It grows with the fund, bears the tax provision and the fund-based charges, and is drawn down by withdrawals and adviser charges.
The non-unit cash flow — what actually accrues to the insurer: the annual management charge and any rider charge, less expenses and the death strain.
Getting the split right is most of the work, and the notes list three separate ways to get it wrong.
1. Every benefit is funded by cancelling units#
Death, surrender and withdrawal all come out of the policyholder’s own fund, so a naive
gross presentation counts the same money twice. net_cf here is therefore the
non-unit stream:
NUCF(t) = AMC + rider charge − expenses − death strain
The gross flows are still published — claims_death, claims_surrender, withdrawals
and unit_releases are result_cf() columns — and check_unit_funding() asserts they
net exactly:
claims(t) − unit_releases(t) − death_strain(t) = 0
2. The death strain is the uplift, not the death benefit#
The sum assured is u × UF, of which UF is funded by cancelling units. The insurer’s
cost per death is
DS(t) = (u − 1)·UF(t) + max(0, G(t) − u·UF(t))·1{gmdb}
— a tenth of a percent of the fund on the composite, plus any in-the-money guarantee. At month 12 of the anchor cell the death benefit per death is £97,876.92 and the strain is £97.78.
The uplift is a parameter and never a literal. 100.1% against 101% is a tenfold difference in death strain, which the notes list as a pitfall; model point 5 is the anchor cell at 101% and its strain is exactly ten times point 1’s.
3. Two of the four amounts leaving the fund are not income#
Amount |
Insurer income? |
Why |
|---|---|---|
AMC |
yes |
the charge for managing the contract |
GMDB rider charge |
yes |
the price of the guarantee |
Further costs |
no |
fund-borne expenses, paid on to the fund |
Tax provision |
no |
collected in-price, paid on as corporation tax |
Adviser charges |
no |
post-RDR pass-throughs facilitated by cancelling units |
Booking the pass-throughs as margin would overstate the year-one non-unit result by
more than the AMC itself: on the anchor cell the tax provision is £967.89 against an
AMC of £993.10 (about 97%) and the further costs another £99.31 (about 10%).
further_costs and tax_provisions are published as their own result_cf() columns
precisely so their exclusion from net_cf is visible rather than merely asserted.
The charge ordering, and why all three points are exposed#
Per policy, within month t:
UF_g(t) = UF(t−1) × (1 + g_m(1 − t_pf)) growth, net of the tax provision
UF'(t) = UF_g(t) × (1 − c_m − f_m) AMC and further costs
UF(t) = UF'(t) − W(t) − AC(t) − GC(t) unit cancellations at end of month
The order matters and is a listed pitfall. The AMC accrues daily through the unit
price, so it is levied on the post-growth, pre-cancellation fund. Charging it on
UF(t−1) instead, or after the withdrawal, moves the margin by about half a month’s
growth or withdrawal — small in one month and systematic over decades.
av_pp_at(t, timing) exposes all three points — "BEF_GROWTH", "AFT_GROWTH",
"AFT_CHARGE", "AFT_WD" — so the ordering is inspectable rather than buried in one
expression, and check_av_roll_fwd() asserts the identity every month.
The fund runs out, and the projection ends there#
A 5% withdrawal against a 5% gross return is not sustainable once the 20% tax
provision and the 1.1% of charges are taken. The anchor cell’s fund drifts down from
£100,000 and is exhausted at month 354 — policy year 30, when the policyholder is 94.
wd_pp() caps the withdrawal at what the fund can pay, so the fund is drawn to nothing
rather than through it, and proj_len() ends the projection there: a bond with no units
has no liability, no margin and nothing left to project.
That is a product fact worth seeing rather than an artefact to hide. Every margin the insurer was counting on stops at month 354, and the accumulation cell (model point 2, no withdrawals) runs the full 660 months to the limiting age instead.
The 5% allowance is policyholder tax machinery, not a product feature#
allowance_cum_pp() and excess_gain_pp() track the cumulative 5% tax-deferred
allowance and the excess-event gain it produces when exceeded R1 R2. Neither
generates an insurer cash flow. Two things follow, and both are listed pitfalls:
the allowance never caps what can be withdrawn — the product cap is the rolling 7.5% of
wd_cap_pp()[S2 §7.1], which is a different and larger number; andadviser charges consume the same allowance [S2 §12.1.1], which is why
wd_cum_pp()adds them in.
It is carried because it drives behaviour: allow_factor() steps surrender up by half
from policy year 21, once twenty years of allowance have been drawn and further
withdrawals generate immediate excess gains.
Behaviour is the whole valuation#
Every margin line is proportional to the unit fund and to persistency, and a surrender costs nothing at the point of exit — the surrender value is the bid value of units, cancelled, with no penalty — while truncating the entire future AMC stream. Two dynamic overlays sit on the base table, both std and both inert in the base run:
Overlay |
Formula |
Off value |
|---|---|---|
Performance |
|
|
Allowance exhaustion |
|
not a switch — it fires on schedule |
return_shock is kept separate from fund_return deliberately: it moves the
behavioural driver without disturbing the fund path, which is what makes the
multiplier testable in isolation. There is no interest-sensitive dynamic lapse and no
paid-up state — the product is single premium, so there is no premium obligation to
stop.
Inputs are external files#
The three input CSVs live in this directory, beside run.py — not inside the model
folder. ULB_UK_S/ holds nothing but formulas:
products/unit_linked_bond/
model_point_table.csv <- inputs live here
mort_table.csv
surr_table.csv
run.py
model.md
product-spec.md <- the documents this model implements
technical-notes.md
sources.md
ULB_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 |
|---|---|---|
|
|
|
|
|
|
|
|
|
Note what is not an input file. The charge rates — AMC, further costs, tax provision, death uplift — are model point columns rather than a rate table, because they are per-policy contractual and discretionary parameters rather than experience assumptions, and because per-fund rate cards are not published anyway. The fund return is a single Reference, because the base run is deterministic.
File |
Contents |
Provenance |
|---|---|---|
|
Six model points. Point 1 is the worked-example anchor cell (M65, £100,000, 100 segments, AMC 1%, further costs 0.10%, tax 20%, uplift 1.001, 5% withdrawals); point 2 drops the withdrawals; point 3 adds a 0.5% ongoing adviser charge; point 4 elects the GMDB rider; point 5 is the anchor at a 101% uplift; point 6 is a younger female on a larger premium and a 3% custom withdrawal |
anchor cell std, technical notes’ worked example |
|
Base annual mortality by sex and age 18–120, capped at 1 |
std proxy shaped like the ONS national life tables, anchored so that the 80% best-estimate factor gives the notes’ |
|
Annual full-surrender rates by policy year, 2 / 3 / 5 / 8 / 10 % |
std; low early, rising as the advised holding period completes. No public UK bond persistency study was fetched |
The GMDB rider, and why it never bites in the base run#
The return-of-premium rider guarantees G(t) = premium − withdrawals − adviser charges,
so the guarantee erodes as the policyholder draws the fund down. On the anchor
withdrawal pattern it reaches zero at month 240, well before the fund does — so on the
base assumptions the guarantee never bites and model point 4’s cash flows differ from
point 1’s only by the (zero) rider charge.
That is the honest outcome of the base assumptions rather than a defect, and the tests
demonstrate the machinery by driving fund_return negative, where the guarantee moves
firmly into the money and the death strain becomes market-contingent. The rider’s real
charge scale is unpublished [S2 §5.2], so the cost-of-insurance form used here has the
right shape and no authority — enable it only with its own sensitivity set.
One implementation note. gmdb_guarantee_pp(t) is measured before the current
month’s cancellations, at wd_cum_pp(t − 1). That is the only reading that resolves: the
rider charge is itself a cancellation alongside the withdrawal, so a guarantee net of the
same month’s withdrawal would make the charge depend on a withdrawal that depends on the
charge.
Naming#
Cells follow lifelib’s savings/CashValue_SE and the account-value vocabulary the U.S.
models settled on. The full symbol mapping lives in the Projection Space docstring.
Four cases needed care:
Notes |
Cells |
Why |
|---|---|---|
|
|
The library calls a policyholder-owned fund |
|
|
The notes use |
|
|
Not a gross liability total — see the decomposition above. Still income-positive, as everywhere in the library |
(no analogue) |
|
Borrowed from the term models; a bond has no maturity, so this is the population left when the projection ends, and it pays nothing |
Standardizations used#
Everything in this list is std: the AMC of 1.00%, further costs of 0.10% and the 20% tax provision; the 1/12 monthly accrual convention for both charge rates; the whole mortality basis and the 80% best-estimate factor; the surrender table; the 5% withdrawal pattern; the performance multiplier and its cap, and the allowance-exhaustion step; the GMDB charge form; acquisition expense £300 and maintenance £60 a year inflating at 2.5%; the deterministic 5% gross fund return; death-before-surrender as the processing order; and the treatment of the tax provision as exactly offsetting tax payable, which ignores the I-E timing and base differences that create real insurer-side tax strain or float.
Deliberately excluded, per the notes: top-ups (a top-up is a new model point with its own
premium and allowance clock), joint last-death bonds, segment-level granularity (bond
level is exact only while all 100 segments stay identical), settlement frictions, and
smoothed and with-profits funds — PruFund’s EGR and smoothing limits and MVR-bearing
funds change the unit-price dynamics and add guarantee costs, and belong to
products/with_profits, not to this recursion.
Tests#
tests/test_unit_linked_bond_uk.py asserts the notes’ worked example to the penny — the
month-by-month unit fund recursion, the year-one totals and the reconciliation that
closes them — plus the per-policy insurer-side extraction beside it, the charge ordering,
that the pass-throughs stay out of net_cf, the death-strain arithmetic and the tenfold
uplift sensitivity, the withdrawal caps, the fund exhausting the projection, the
allowance tracker generating no cash flow, both behavioural overlays, and the GMDB rider
under a falling fund.
python -m pytest tests -q