Source code for uklib.products.income_protection.IP_UK_S.Projection

# modelx: pseudo-python
# This file is part of a modelx model.
# It can be imported as a Python module, but functions defined herein
# are model formulas and may not be executable as standard Python.

"""The by-policy projection of the :mod:`~.IP_UK_S` model.

The Space is parameterized by ``point_id``, so ``Projection[1]`` is an ItemSpace
projecting model point 1::

    >>> Projection[1].result_cf()          # the active-lives anchor cell
    >>> Projection.point_id = 2            # the claims-in-payment worked example

``t`` counts **policy months**, 1-based: ``t = 1`` is the first projected month and
``t = proj_len() = 12 (expiry_age - entry_age)`` the last. The notes index the state
probabilities ``l_H(t)`` and ``l_S(t, z)`` at the **end** of month ``t`` with
``l_H(0) = 1``; the library indexes at the **start** of the month, so
``pols_active(t)`` is the notes' ``l_H(t-1)`` and ``pols_sick_dur(t, z)`` its
``l_S(t-1, z)``. That is deliberate: every cash flow on a ``result_cf()`` row is then
weighted by a state count on the same row.

.. rubric:: Input data

Inputs are **external files**: plain CSVs living in the model folder's parent
directory, ``products/income_protection/``, read at run time rather than stored inside
the model. The model folder therefore holds nothing but formulas — no ``_data/``, no
IOSpec, no embedded values — so a diff of the model shows logic changes only, and an
input can be edited or swapped without rewriting the model. This follows
``annuallife.TradLife_A``; contrast ``basiclife.BasicTerm_S``, which keeps its inputs
*inside* the model through modelx's IOSpec machinery.

The consequence worth knowing: **the model is not portable on its own.** Copying the
``IP_UK_S`` folder without its parent's CSVs produces a model that reads and then fails
on first evaluation.

Each table has a filename Reference and a reader Cells, both on :mod:`~.IP_UK_S.Data`,
reached here through the ``data`` Reference:

======================  ==============================  ==========================
Reference               Cells                           File
======================  ==============================  ==========================
model_point_file        data.model_point_table()        model_point_table.csv
inception_file          data.inception_table()          inception_table.csv
termination_file        data.termination_table()        termination_table.csv
mort_table_file         data.mort_table()               mort_table.csv
lapse_table_file        data.lapse_table()              lapse_table.csv
======================  ==============================  ==========================

.. rubric:: Naming

Cells names follow lifelib's ``basiclife.BasicTerm_S`` and ``savings.CashValue_SE``
wherever those models have an analogue — ``pols_*`` for population counts, plural nouns
for cash flows, ``*_rate`` for annual rates and ``*_rate_mth`` for monthly ones,
``*_pp`` for per-policy amounts, ``claims(t, kind)`` with an uppercase ``kind`` string.
The technical notes use compact actuarial symbols instead. The mapping is:

=========================  ==============================  ==========================
Notes symbol               Cells                           Meaning
=========================  ==============================  ==========================
entry_age                  age_at_entry()                  Entry age (ANB)
a                          age(t)                          Attained age (ANB) in month t
y = ceil(t/12)             policy_year(t)                  Policy year containing month t
(none)                     duration(t)                     Completed policy years, y - 1
(none)                     duration_mth(t)                 Months elapsed at end of month t
expiry_age                 expiry_age()                    Age all cover ceases
T = 12(expiry - entry)     proj_len()                      Last projected month
d                          deferred_weeks()                Deferred period in weeks
occ_class                  occ_class()                     CMI occupation class OC1-OC4
status                     status()                        active or in_claim cell
z                          (the duration argument)         Claim duration in months
(none)                     claim_dur_year(z)               Duration year containing z
(none)                     max_dur()                       Longest claim duration tracked
B(y)                       benefit_pp(t)                   Escalated monthly benefit
P(y)                       premium_pp(t)                   Escalated monthly premium
AP(y)                      amount_payable_pp(t)            Amount payable per month
(max benefit formula)      benefit_max_pp()                Contractual maximum benefit
k                          claim_severity                  Severity factor, 1.0
AP/B                       ap_ratio                        Offset/guarantee effect, 1.0
j                          esc_rate()                      Escalation rate
M_esc(y)                   esc_lapse_factor(t)             Premium-shock lapse multiplier
iota_a(a)                  inception_rate(t)               Annual claim inception rate
iota_m(a)                  inception_rate_mth(t)           The same, monthly
M_cycle                    cycle_factor                    Economic-cycle overlay, 1.0
q_H_a(a)                   mort_rate(t)                    Active-life annual mortality
q_H_m(a)                   mort_rate_mth(t)                The same, monthly
w_a(y)                     lapse_rate(t)                   Annual lapse rate in month t
(table)                    lapse_rate_base(t)              Table lapse rate before shock
w_m(y)                     lapse_rate_mth(t)               Monthly lapse rate
rho_a(z)                   rec_rate(z)                     Annual recovery rate
rho_m(z)                   rec_rate_mth(z)                 Monthly recovery rate
q_S_a(z)                   mort_rate_sick(z)               Annual in-claim mortality
q_S_m(z)                   mort_rate_sick_mth(z)           Monthly in-claim mortality
s_S(z)                     claim_surv_step(z)              Monthly in-claim survival
(the three vectors)        claim_rate_vectors()            rho_m, q_S_m and s_S as lists
l_H(t-1)                   pols_active(t)                  In state H at start of month t
l_S(t-1, z)                pols_sick_dur(t, z)             In claim at duration z
l_S(t-1)                   pols_sick(t)                    Total in claim payment
(the whole vector)         sick_cohorts(t)                 l_S(t-1, .) as a list
(none)                     pols_if(t)                      H + S: policies in force
(none)                     pols_if_at(t, timing)           BEF_DECR / AFT_DECR
n(t)                       pols_inception(t)               New claim inceptions
rec(t)                     pols_recovery(t)                Recoveries out of S
dth_S(t)                   pols_death_sick(t)              Deaths in claim
dth_H(t)                   pols_death_active(t)            Deaths in state H
lps(t)                     pols_lapse(t)                   Lapses out of H
(none)                     pols_exit(t)                    Recoveries leaving the model
(none)                     pols_maturity(t)                In force at the policy end date
(none)                     pols_sick_surv(t)               In claim at the end of month t
PREM(t)                    premiums(t)                     Premium income
BEN(t)                     claims(t, "BENEFIT")            Income benefit outgo
0                          claims(t, "DEATH")              Death benefit; always zero
0                          claims(t, "LAPSE")              Surrender outgo; always zero
EXP(t)                     expenses(t)                     Maintenance + claim management
e_m(y), ec_m(y)            expense_maint, expense_claim    Expense levels p.a.
(none)                     inflation_factor(t)             Expense inflation factor
CF(t)                      net_cf(t)                       Net cash flow, income positive
v(t)                       disc_factor(t)                  Worked-example discount factor
(none)                     pv_benefits()                   PV of benefit outgo
a_dis(a0, z0)              annuity_dis()                   Disabled-life annuity factor
=========================  ==============================  ==========================

Three names needed care.

The notes use ``q_H`` and ``q_S`` for the two mortality rates. :func:`mort_rate` is the
**active-life** one, because that is what ``mort_rate`` means in every other model in
this library — the rate applying to the population the projection starts with — and the
in-claim rate is :func:`mort_rate_sick`, keyed by claim duration rather than by month.
Reading a claimant mortality rate out of ``mort_rate`` is the kind of mistake the naming
is there to prevent.

``BEN(t)`` is an income stream, not a lump sum, but it is reached as
``claims(t, "BENEFIT")`` so that the library's one benefit-outgo cells covers it. The
other two kinds are zero and say so: this composite carries **no death benefit** — the
£5,000-£10,000 death benefits two sampled insurers offer are out of scope — and **no
surrender value** at any time.

``z`` is the claim duration in months and ``t`` the policy month. They are different
clocks and the model never mixes them: rates out of state S take ``z``, rates out of
state H take ``t``.

.. rubric:: Three states, and why the model needs all of them

Healthy and active (H), sick and in claim payment (S), dead (D), with lapse as a further
exit from H and **recovery flowing back from S to H**. That is the structure of the
CMI's own graduations, and it is why income protection has two experience bases rather
than one: claim *inception* rates out of H, and claim *termination* rates out of S, the
latter split into recovery and death.

The deferred period is embedded **in the inception basis**, not modelled as a fourth
state. ``iota`` is a claim *payment* inception rate specific to the policy's deferred
period — exactly the quantity the CMI publishes per deferred period — so a sickness
spell that recovers inside the deferred period never leaves H, and a life sick but not
yet in payment stays in H and keeps paying premiums, which is what the contract's
waiver-from-payment-start convention says. No separate "sick, not yet in payment" state
is needed and the lag between onset and payment is absorbed into the calibration of
``iota``. Dual deferred periods and sick-pay-linked deferreds would need spell-level
modelling and are out of scope.

.. rubric:: The in-claim population is two-dimensional

Termination rates depend on how long the claim has already run: 40% a year at duration
one falling to 5% from duration five in the shipped **[std]** basis. So the model tracks
``l_S(t, z)`` cohort by cohort. Collapsing that to a single bucket with a
duration-independent termination rate materially misstates claim run-off, and is the
notes' first-listed pitfall — the duration gradient *is* the defining feature of income
protection terminations.

:func:`sick_cohorts` holds the whole vector for one month and is the model's only
list-valued cells. The alternative — a two-argument ``pols_sick_dur(t, z)`` recursion —
would be ``proj_len() x max_dur()`` separate cells, 130,000 of them on the anchor cell,
each with its own cache entry. Keeping the vector in one cells per month makes it
``proj_len()`` cells with a loop inside, and :func:`pols_sick_dur` then reads an element
out of it so that the notes' two-dimensional object is still addressable by name. The
list is rebuilt rather than mutated on each step, so a caller cannot corrupt the cache
by holding one.

The shipped termination basis suppresses the **age** dimension **[std]**. IP11 is
two-dimensional in age and duration, with claimant mortality duration-dependent to five
years and age-only beyond, and it also has a "run-in" of *increasing* recovery rates
over the first weeks of claim for the shorter deferred periods, which annual duration-year
granularity smooths away. A licensee restoring both dimensions changes
``termination_table.csv`` and the two lookups, and nothing else.

.. rubric:: Where recoveries go, and the two kinds of model point

The notes give two calculations, and the model point's ``recovery_basis`` column says
which one a cell runs:

``recovery_basis = "return_to_h"``
    The notes' processing order step 5: recovered lives re-enter H, resume paying
    premiums and are again exposed to inception. This is the **active-lives** basis and
    every ``active`` model point uses it.

``recovery_basis = "exit"``
    Recovered lives leave the model. This is the **disabled-life annuity** the notes'
    claims-in-payment section values — the expected present value of the benefit until
    recovery, death or expiry — and it is the basis the worked example is computed on.
    Both ``in_claim`` model points use it.

Keeping it a column rather than deriving it from ``status`` matters, because the choice
is a valuation question and not a property of the cell: a claims-in-payment reserve is
the disabled-life annuity, but a full contract-boundary best estimate for the same
policy would carry the post-recovery active phase as well. Running an ``in_claim`` cell
on ``return_to_h`` gives the second reading, and gives a materially different answer,
because recovered lives can and do claim again.

One limitation the notes name and this model inherits: contractually a same-cause
recurrence within 52 weeks restarts payment with **no new deferred period**, and
returning recovered lives to the standard inception basis ignores that. It understates
re-inception at short horizons. The refinement is a post-recovery flag carrying a loaded
inception rate for twelve months, and it is not implemented.

.. rubric:: Premiums come from H alone

Premiums are waived from the start of benefit payment, so :func:`premiums` is carried on
:func:`pols_active` and never on :func:`pols_if`. Projecting premium income from lives in
claim is the notes' second-listed pitfall and overstates income by the whole in-claim
population. Note the asymmetry it creates with escalation: the benefit escalates in
claim and the premium that would have paid for it does not, which is why the escalation
option is inflation-sensitive on exactly the claims that are longest.

.. rubric:: Benefit in arrears, and the month a claim starts

A claim incepting at the end of month ``t`` seeds cohort ``z = 1`` and receives its first
payment at the end of month ``t + 1``. So the benefit is paid on
:func:`pols_sick_surv` — the cohorts that were already in payment at the start of the
month and survived it — and not on :func:`pols_sick` plus new inceptions. Paying the new
inceptions would hand over a full month's benefit at the instant payment starts and
break the equivalence with the inception-annuity decomposition of the same projection.

The contractual daily pro-rating of partial claim months is replaced by whole-month
payment **[std]**: a life recovering mid-month receives nothing for that month here and
a pro-rated amount in reality.

.. rubric:: Expiry truncates everything

All cover and any claim in payment terminate at the policy end date with no value.
:func:`pols_maturity` is that termination, non-zero only in the last month, and it is
what makes the in-force roll-forward close. An untruncated disabled-life annuity
materially overstates the liability for claims incepting near expiry — model point 5 is
a claim at duration 30 months on a policy with 15 years to run, and its benefit stream
stops dead at ``proj_len()``.

.. rubric:: Amount payable is not the same thing as the chosen benefit

Offsets against other income, the minimum benefit guarantee and proportionate benefits
on a partial return to work all move the amount actually paid away from the benefit the
policyholder chose. The base run sets ``AP = B`` through ``ap_ratio`` and the
severity factor ``claim_severity`` to 1 **[std]**, which **overstates** outgo
wherever the maximum-benefit formula bites and understates nothing, since ``AP <= B``
always. A portfolio calibration sets one or both below 1 from claims experience.
:func:`benefit_max_pp` implements the contractual two-band maximum so that
:func:`check_benefit_max` can assert every shipped model point is inside it.

.. rubric:: Discounting, which the rest of the library does not do

Every other model in this library projects **undiscounted** gross liability cash flows
and leaves discounting to the layer that consumes them. This one carries
:func:`disc_factor`, :func:`pv_benefits` and :func:`annuity_dis` as well, because the
notes' worked example is a present value and because the disabled-life annuity is the
object a claims-in-payment reserve is quoted as. They are a **companion**, not part of
the cash flow projection: no line of :func:`result_cf` is discounted, and
``disc_rate`` is the worked example's flat 3% **[std]**, not a valuation basis. A
Solvency UK best estimate discounts these same cash flows on the PRA risk-free term
structure, and the claims-in-payment element is matching-adjustment eligible where it is
organised and managed separately.
"""

from modelx.serialize.jsonvalues import *

_formula = lambda point_id: None

_bases = []

_allow_none = None

_spaces = []

# ---------------------------------------------------------------------------
# Cells

[docs] def model_point(): """The selected model point as a Series.""" return data.model_point_table().loc[point_id] # noqa: F821
[docs] def age_at_entry(): """The entry age (ANB) of the selected model point. On an ``in_claim`` cell this is the **attained age at the valuation date**, and the policy-year clock restarts there **[std]**: the notes give no anniversary offset for an in-force claim, so escalation steps at ``t = 13, 25, ...`` from the valuation date rather than from the contractual anniversary. The worked example is level cover, where the distinction does not arise. """ return int(model_point()["entry_age"])
[docs] def sex(): """The sex (M / F) of the selected model point. Not a smoker split: the IP11 rate structure is sex, deferred period and occupation class, and any smoker differentiation sits in insurer pricing, which is not public. """ return model_point()["sex"]
[docs] def occ_class(): """The CMI occupation class, OC1 to OC4 [R1]; 1 is the lightest.""" v = int(model_point()["occ_class"]) if v not in (1, 2, 3, 4): raise ValueError("invalid occ_class") return v
[docs] def deferred_weeks(): """d: the deferred period in weeks, from the 4/8/13/26/52 menu [S6]. It is not a state in this model: the inception basis is deferred-period-specific, so the deferred period enters through :func:`inception_rate` and nowhere else. See the Space docstring. """ v = int(model_point()["deferred_weeks"]) if v not in (4, 8, 13, 26, 52): raise ValueError("invalid deferred_weeks") return v
[docs] def benefit_mth(): """B(1): the chosen monthly benefit at issue **[std]**, £2,000 on the base cell.""" return float(model_point()["benefit_mth"])
[docs] def earnings_annual(): """The annual earnings on the underwriting record, which cap the benefit.""" return float(model_point()["earnings_annual"])
[docs] def expiry_age(): """The age at which all cover and any claim in payment cease, 50-70 [S1][S3][S5].""" return int(model_point()["expiry_age"])
[docs] def escalation(): """``RPI`` or ``none``: whether the escalation option is elected [S1][S2].""" v = model_point()["escalation"] if v not in ("RPI", "none"): raise ValueError("invalid escalation") return v
[docs] def premium_mth(): """P(1): the monthly premium at issue **[std]**, a placeholder. UK income protection premium rates are not public, so any reference premium is constructed. It is guaranteed level apart from the escalation uplift [S1][S3][S5][S7]. """ return float(model_point()["premium_mth"])
[docs] def premium_basis(): """``guaranteed``. Reviewable and age-costed premiums are out of scope. A reviewable variation exists - fixed for five years, then reviewed with no contractual cap - but the review formulas are insurer-discretionary and undisclosed, so any reviewable model would need a **[std]** review rule that no source supports. """ v = model_point()["premium_basis"] if v != "guaranteed": raise ValueError("invalid premium_basis") return v
[docs] def status(): """``active`` (the population starts in H) or ``in_claim`` (it starts in S). An in-force portfolio needs both kinds of cell: active lives, and claims already in payment carrying their claim duration as an attribute. """ v = model_point()["status"] if v not in ("active", "in_claim"): raise ValueError("invalid status") return v
[docs] def recovery_basis(): """Where recovered lives go: back to H, or out of the model. ``return_to_h`` the notes' processing order step 5 - recovered lives re-enter H, resume paying premiums and are again exposed to inception. The **active-lives** basis. ``exit`` recovered lives leave the model, which is the **disabled-life annuity** the claims-in-payment valuation uses, and the basis the notes' worked example is computed on. A column rather than something derived from :func:`status`, because it is a valuation question: a claims-in-payment reserve is the disabled-life annuity, but a full contract-boundary best estimate for the same policy carries the post-recovery active phase too. See the Space docstring. """ v = model_point()["recovery_basis"] if v not in ("return_to_h", "exit"): raise ValueError("invalid recovery_basis") return v
[docs] def claim_duration_months(): """z0: the claim duration already elapsed on an ``in_claim`` cell; 0 at inception. The seeded population enters cohort ``z0 + 1``, since cohort 1 is a claim that has just started paying. """ return int(model_point()["claim_duration_months"])
[docs] def pols_if_init(): """Initial number of policies; 1.0 on a single-policy model point.""" return float(model_point()["pols_if_init"])
[docs] def proj_len(): """Projection length in months: ``12 (expiry_age - entry_age)``. All cover and any claim in payment terminate at the policy end date with no value [S1][S3][S5][S7][S10], so there is nothing after it. This is what truncates the disabled-life annuity, and an untruncated one materially overstates the liability for claims incepting near expiry. """ return 12 * (expiry_age() - age_at_entry())
[docs] def duration(t): """Completed policy years at the start of month t: ``(t - 1) // 12``.""" return (t - 1) // 12
[docs] def duration_mth(t): """Months elapsed from the start of the projection at the end of month t; equal to t. ``t`` is 1-based, so the identity is trivial - the cells exists so the monthly models in this library share one vocabulary. """ return t
[docs] def policy_year(t): """y = ceil(t/12): the policy year containing month t; 1 for t = 1..12.""" return duration(t) + 1
[docs] def age(t): """a: the attained age (ANB) in the policy year containing month t.""" return age_at_entry() + duration(t)
[docs] def max_dur(): """The longest claim duration the cohort vector has to carry. ``proj_len() + z0 + 1``: a claim seeded at duration ``z0 + 1`` reaches ``z0 + proj_len()`` by the last month, and a claim incepting in month 1 reaches ``proj_len()``. Cohorts beyond this are structurally zero. """ return proj_len() + claim_duration_months() + 1
[docs] def claim_dur_year(z): """The claim duration year containing claim month z: ``(z - 1) // 12 + 1``. Duration years beyond the termination table take its last row, which is the shipped table's "5+" row. """ return (z - 1) // 12 + 1
[docs] def inception_rate(t): """iota_a(a): the annual claim payment inception rate out of state H. Read from ``inception_table.csv`` at the policy's sex, occupation class and deferred period, **linearly** interpolated between pivot ages and linearly extrapolated beyond them, then multiplied by the economic-cycle overlay ``cycle_factor``. Floored at zero, since linear extrapolation below the first pivot can go negative. The rate is a *claim payment* inception rate specific to the deferred period, which is exactly what the CMI publishes per deferred period - so the deferred period needs no state of its own. The shipped values are **[std]** proxies shaped like IP11 and carry no CMI authority; IP11's own inception rates are additionally known to be understated through exposure errors, and licensees must apply the CMI's indicative adjustments. """ sub = data.inception_table().loc[ # noqa: F821 (sex(), occ_class(), deferred_weeks())] ages = sorted(int(a) for a in sub.index) x = age(t) if x <= ages[0]: lo, hi = ages[0], ages[1] elif x >= ages[-1]: lo, hi = ages[-2], ages[-1] else: lo = max(a for a in ages if a <= x) hi = min(a for a in ages if a > x) r_lo = float(sub.loc[lo, "inception_rate"]) r_hi = float(sub.loc[hi, "inception_rate"]) r = r_lo + (r_hi - r_lo) * (x - lo) / (hi - lo) return max(0.0, r) * cycle_factor # noqa: F821
[docs] def inception_rate_mth(t): """iota_m(a) = 1 - (1 - iota_a)^(1/12): the monthly inception rate **[std]**.""" return 1.0 - (1.0 - inception_rate(t)) ** (1.0 / 12.0)
[docs] def mort_rate(t): """q_H_a(a): the **active-life** annual mortality rate. A minor decrement in income protection, and a **[std]** proxy shaped like the ONS national life tables scaled by ``mort_active_factor``. Claimant mortality is a different rate on a different clock - see :func:`mort_rate_sick`. """ return float(data.mort_table().loc[ # noqa: F821 (sex(), age(t)), "mort_rate"]) * mort_active_factor # noqa: F821
[docs] def mort_rate_mth(t): """q_H_m(a) = 1 - (1 - q_H_a)^(1/12): the monthly active-life mortality **[std]**.""" return 1.0 - (1.0 - mort_rate(t)) ** (1.0 / 12.0)
[docs] def rec_rate(z): """rho_a(z): the annual recovery rate at claim duration z months. From the duration year containing z, divided by the economic-cycle overlay - a recession is believed to slow recoveries as well as raise inceptions, and the overlay is a scenario axis rather than a calibrated assumption, held at 1 in the base run. The declining gradient, 40% in year one to 5% from year five, is the defining feature of income protection terminations; the values are **[std]** proxies. """ tbl = data.termination_table() # noqa: F821 y = min(claim_dur_year(z), int(tbl.index.max())) return min(1.0, float(tbl.loc[y, "recovery_rate"]) / cycle_factor) # noqa: F821
[docs] def rec_rate_mth(z): """rho_m(z) = 1 - (1 - rho_a)^(1/12): the monthly recovery rate **[std]**.""" return 1.0 - (1.0 - rec_rate(z)) ** (1.0 / 12.0)
[docs] def mort_rate_sick(z): """q_S_a(z): the annual mortality of a life in claim at duration z months. Flat across durations in the shipped **[std]** proxy. IP11 makes it duration-dependent to five years and age-dependent beyond, so this is the crudest part of the shipped basis; it is also the smaller of the two termination causes. """ tbl = data.termination_table() # noqa: F821 y = min(claim_dur_year(z), int(tbl.index.max())) return float(tbl.loc[y, "mort_rate_sick"])
[docs] def mort_rate_sick_mth(z): """q_S_m(z) = 1 - (1 - q_S_a)^(1/12): the monthly in-claim mortality **[std]**.""" return 1.0 - (1.0 - mort_rate_sick(z)) ** (1.0 / 12.0)
[docs] def claim_surv_step(z): """s_S(z) = (1 - rho_m)(1 - q_S_m): monthly survival in claim at duration z. Recovery first, then death among the non-recovered, as independent decrements **[std]** - the notes' processing order for exits from state S. """ return (1.0 - rec_rate_mth(z)) * (1.0 - mort_rate_sick_mth(z))
[docs] def claim_rate_vectors(): """The three per-duration rate vectors, ``(rho_m, q_S_m, s_S)``, built once. Element ``z - 1`` of each list is the value at claim duration ``z``, for ``z = 1 ... max_dur()``. Purely a performance shape: :func:`pols_recovery`, :func:`pols_death_sick` and :func:`pols_sick_surv` each walk the whole cohort vector in every projected month, and reading the rates out of a list rather than calling :func:`rec_rate_mth` and friends per element turns ``proj_len() x max_dur()`` cells lookups into ``max_dur()`` of them - on the anchor cell, four hundred thousand down to four hundred. The scalar cells stay, because they are what a reader looks up and what a test asserts against; this is built *from* them, so there is still one definition of each rate. """ zs = range(1, max_dur() + 1) rec = [rec_rate_mth(z) for z in zs] dth = [mort_rate_sick_mth(z) for z in zs] return rec, dth, [(1.0 - r) * (1.0 - d) for r, d in zip(rec, dth)]
[docs] def esc_rate(): """j = min(max(RPI, 0), 10%): the escalation rate [S1][S2]. Zero when the option is not elected. A flat 3% RPI snapshot **[std]** - future RPI is an economic input, not insurer discretion - so the benefit grows 3% a year and the premium 4.5%, the x1.5 multiplier. The 10% cap is an embedded inflation option the insurer has written. """ if escalation() != "RPI": return 0.0 return min(max(rpi_rate, 0.0), esc_cap) # noqa: F821
[docs] def benefit_pp(t): """B(y): the escalated monthly benefit in the policy year containing month t. Escalates on policy anniversaries and **continues to escalate in claim** [S1][S2], which is what makes the disabled-life annuity inflation-sensitive exactly when it is longest. Some contracts instead escalate in-claim amounts on *claim* anniversaries with index-lag rules; align the convention with the contract being modelled. """ return benefit_mth() * (1.0 + esc_rate()) ** (policy_year(t) - 1)
[docs] def premium_pp(t): """P(y): the escalated monthly premium in the policy year containing month t. Guaranteed level apart from the escalation uplift of ``1 + 1.5 j`` a year [S1][S2][S3][S5][S7]. The premium side compensates for escalation only x1.5 on active lives and not at all on lives in claim, where premiums are waived. """ step = 1.0 + esc_prem_mult * esc_rate() # noqa: F821 return premium_mth() * step ** (policy_year(t) - 1)
[docs] def benefit_max_pp(): """The contractual maximum monthly benefit from the two-band earnings formula. 65% of earnings to the £60,000 breakpoint and 50% above it, capped at £20,000 a month and floored at the £1,500 guarantee [S1][S2][S5][S7]. Nothing in the projection consumes it - the base run pays :func:`amount_payable_pp` - but :func:`check_benefit_max` asserts every model point's chosen benefit is inside it, which is the check the underwriting record exists to support. """ e = earnings_annual() gross = (ben_band1_rate * min(e, ben_breakpoint) # noqa: F821 + ben_band2_rate * max(0.0, e - ben_breakpoint)) # noqa: F821 m = min(gross / 12.0, ben_cap_mth) # noqa: F821 return max(m, ben_guarantee_mth) # noqa: F821
[docs] def amount_payable_pp(t): """AP(y): the amount actually payable per month of full incapacity. ``ap_ratio x B(y)`` with ``ap_ratio`` at 1 **[std]**. Offsets against other income, the minimum benefit guarantee and proportionate benefits on a partial return to work all push the amount paid below the chosen benefit, so ``AP <= B`` always and the base run **overstates** outgo wherever the maximum-benefit formula bites. A portfolio calibration sets ``ap_ratio`` or ``claim_severity`` below 1 from claims experience. """ return ap_ratio * benefit_pp(t) # noqa: F821
[docs] def esc_lapse_factor(t): """M_esc(y): the premium-shock lapse multiplier **[std]**; 1 in the base run. ``1 + 2 max(0, 1.5 j - 0.05)`` once the escalation uplift has started, so a 3% RPI snapshot gives 4.5% premium growth and no shock, while the 10% cap would give 15% growth and a multiplier of 1.2. Sampled insurers let policyholders decline escalation increases, with the option lapsing after two or three consecutive refusals; declines are a portfolio-level phenomenon that a single-cell model cannot represent, so this multiplier is the aggregate proxy for them. """ if escalation() != "RPI" or policy_year(t) < 2: return 1.0 return 1.0 + esc_lapse_slope * max( # noqa: F821 0.0, esc_prem_mult * esc_rate() - esc_lapse_threshold) # noqa: F821
[docs] def lapse_rate_base(t): """The table annual lapse rate in month t **[std]**, before the escalation shock. 10 / 8 / 6 / 6 / 6 / 4 percent by policy year. No public UK income protection lapse study was retrieved, so the table has no anchor at all and is a pure placeholder. Policy years beyond the table take its last row. """ tbl = data.lapse_table() # noqa: F821 y = policy_year(t) return float(tbl.loc[min(y, int(tbl.index.max())), "lapse_rate"])
[docs] def lapse_rate(t): """w_a(y): the **annual** lapse rate out of state H in month t. Applied to lives in H only. Lives in claim never lapse **[std]**: their premiums are waived and the benefit in payment is the most valuable thing they own. """ return min(1.0, lapse_rate_base(t) * esc_lapse_factor(t))
[docs] def lapse_rate_mth(t): """w_m(y) = 1 - (1 - w_a)^(1/12): the monthly lapse rate **[std]**.""" return 1.0 - (1.0 - lapse_rate(t)) ** (1.0 / 12.0)
[docs] def sick_cohorts(t): """l_S(t-1, .): the in-claim population by claim duration, as a list. Element ``z - 1`` is the population in claim payment at the start of month t with claim duration ``z`` months, for ``z = 1 ... max_dur()``. The model's only list-valued cells, and the reason is cost: a two-argument recursion would be ``proj_len() x max_dur()`` separate cells - 130,000 on the anchor cell - where this is ``proj_len()`` cells with a loop inside. :func:`pols_sick_dur` reads an element out of it, so the notes' two-dimensional object is still addressable by name. At ``t = 1`` the vector is the seeded state: all zeros on an ``active`` cell, and ``pols_if_init()`` at cohort ``z0 + 1`` on an ``in_claim`` one. Thereafter cohort 1 is the previous month's inceptions and every other cohort is the previous cohort survived one month. A new list is built on each step rather than the previous one mutated, so holding a returned list cannot corrupt the cache. Past ``proj_len()`` the vector is all zeros: every claim in payment terminates at the policy end date without value, so there is no run-off tail for the roll-forward to reconcile against. """ n = max_dur() if t > proj_len(): return [0.0] * n if t <= 1: seed = pols_if_init() if status() == "in_claim" else 0.0 z0 = claim_duration_months() + 1 return [seed if z == z0 else 0.0 for z in range(1, n + 1)] prev = sick_cohorts(t - 1) surv = claim_rate_vectors()[2] out = [pols_inception(t - 1)] for z in range(1, n): out.append(prev[z - 1] * surv[z - 1]) return out
[docs] def pols_sick_dur(t, z): """l_S(t-1, z): the population in claim at the start of month t at duration z.""" v = sick_cohorts(t) return v[z - 1] if 1 <= z <= len(v) else 0.0
[docs] def pols_sick(t): """l_S(t-1): the total population in claim payment at the start of month t.""" return sum(sick_cohorts(t))
[docs] def pols_sick_surv(t): """The population still in claim at the **end** of month t, before new inceptions. ``sum over z of l_S(t-1, z) s_S(z)``. This is what the benefit is paid on: the benefit is monthly in arrears, so a claim incepting at the end of month t is not paid until the end of month ``t + 1``. """ v = sick_cohorts(t) surv = claim_rate_vectors()[2] return sum(a * b for a, b in zip(v, surv) if a != 0.0)
[docs] def pols_recovery(t): """rec(t): recoveries out of state S at the end of month t. ``sum over z of l_S(t-1, z) rho_m(z)``. Where they go depends on :func:`recovery_basis`. """ v = sick_cohorts(t) rec = claim_rate_vectors()[0] return sum(a * b for a, b in zip(v, rec) if a != 0.0)
[docs] def pols_death_sick(t): """dth_S(t): deaths in claim at the end of month t. ``sum over z of l_S(t-1, z)(1 - rho_m(z)) q_S_m(z)`` - recovery first, then death among the non-recovered. """ v = sick_cohorts(t) rec, dth, _ = claim_rate_vectors() return sum(a * (1.0 - r) * d for a, r, d in zip(v, rec, dth) if a != 0.0)
[docs] def pols_exit(t): """Recoveries that leave the model at the end of month t. Equal to :func:`pols_recovery` on the ``exit`` recovery basis and zero on ``return_to_h``, where the same lives reappear in :func:`pols_active`. It exists so that the population identity closes on both bases. """ return pols_recovery(t) if recovery_basis() == "exit" else 0.0
[docs] def pols_active(t): """l_H(t-1): the population in state H at the start of month t. ``pols_if_init()`` at ``t = 1`` on an ``active`` cell and zero on an ``in_claim`` one, then the notes' state update: survivors of mortality, lapse and inception, plus the month's recoveries where the recovery basis returns them. """ if t < 1 or t > proj_len(): return 0.0 if t == 1: return pols_if_init() if status() == "active" else 0.0 prev = pols_active(t - 1) stay = (prev * (1.0 - mort_rate_mth(t - 1)) * (1.0 - lapse_rate_mth(t - 1)) * (1.0 - inception_rate_mth(t - 1))) back = 0.0 if recovery_basis() == "exit" else pols_recovery(t - 1) return stay + back
[docs] def pols_death_active(t): """dth_H(t): deaths out of state H at the end of month t. The notes' processing order out of H is **death, then lapse, then inception among the survivors** **[std]**. """ return pols_active(t) * mort_rate_mth(t)
[docs] def pols_lapse(t): """lps(t): lapses out of state H at the end of month t. Taken from the survivors of mortality. Pays nothing: the contract has no cash-in value at any time [S4][S5][S7]. Lives in claim never lapse. """ return pols_active(t) * (1.0 - mort_rate_mth(t)) * lapse_rate_mth(t)
[docs] def pols_inception(t): """n(t): new claim inceptions at the end of month t, seeding cohort z = 1. Taken from the survivors of both mortality and lapse. Each inception starts a new duration cohort, and is not paid until the end of the following month. """ return (pols_active(t) * (1.0 - mort_rate_mth(t)) * (1.0 - lapse_rate_mth(t)) * inception_rate_mth(t))
[docs] def pols_if(t): """The number of policies in force at the start of month t: H plus S. The weight on the maintenance expense, and the count a reader of ``result_cf()`` reconciles the rest of the row against. Note that it is **not** the weight on premium income, which comes from :func:`pols_active` alone because premiums are waived in claim. """ return pols_active(t) + pols_sick(t)
[docs] def pols_if_at(t, timing): """The number of policies in force at a point inside month t. ``"BEF_DECR"`` the start of the month, before any transition; the same number as :func:`pols_if`. ``"AFT_DECR"`` the end of the month, once deaths, lapses, recoveries leaving the model and - in the last month - the expiry have been taken. Equal to ``pols_if(t + 1)`` everywhere but the last month, where it is zero. The intermediate points of the other models have no single-population meaning here, because two states are moving at once; :func:`pols_active` and :func:`pols_sick` expose them instead. """ if timing == "BEF_DECR": return pols_if(t) if timing == "AFT_DECR": if t < 1 or t >= proj_len(): return 0.0 return pols_if(t + 1) raise ValueError("invalid timing")
[docs] def pols_maturity(t): """The population still in force when cover ceases at the policy end date. Non-zero only in the last projected month, where all cover and any claim in payment terminate without value [S1][S3][S5][S7][S10]. Not a decrement and not a benefit - but without it the last month appears to lose lives with no cause, and :func:`check_pols_roll_fwd` would not close. """ if t != proj_len(): return 0.0 stay = (pols_active(t) * (1.0 - mort_rate_mth(t)) * (1.0 - lapse_rate_mth(t)) * (1.0 - inception_rate_mth(t))) back = 0.0 if recovery_basis() == "exit" else pols_recovery(t) return stay + back + pols_inception(t) + pols_sick_surv(t)
[docs] def pols_dead_cum(t): """Cumulative deaths, from both states, before the start of month t.""" if t <= 1: return 0.0 return pols_dead_cum(t - 1) + pols_death_active(t - 1) + pols_death_sick(t - 1)
[docs] def pols_lapse_cum(t): """Cumulative lapses out of state H before the start of month t.""" if t <= 1: return 0.0 return pols_lapse_cum(t - 1) + pols_lapse(t - 1)
[docs] def pols_exit_cum(t): """Cumulative recoveries that left the model before the start of month t. Zero throughout on the ``return_to_h`` basis, where recoveries never leave. """ if t <= 1: return 0.0 return pols_exit_cum(t - 1) + pols_exit(t - 1)
[docs] def inflation_factor(t): """The expense inflation factor in month t: ``(1 + pi)^(y - 1)`` **[std]**. Steps on policy anniversaries, not monthly, which is how the notes write it. """ return (1.0 + inflation_rate) ** (policy_year(t) - 1) # noqa: F821
[docs] def premiums(t): """PREM(t): premium income at the beginning of month t, an inflow. Carried on :func:`pols_active` and **never** on :func:`pols_if`. Premiums are waived from the start of benefit payment [S5][S7][S10][S11], so projecting income from lives in claim overstates it by the whole in-claim population - the notes' second-listed pitfall. Lives sick but still inside the deferred period do pay, and they are in H, so they are counted correctly without a state of their own. """ return premium_pp(t) * pols_active(t)
[docs] def claims(t, kind=None): """Benefit outgo in month t, by kind; the total when kind is omitted. ``"BENEFIT"`` ``k x AP(y) x`` :func:`pols_sick_surv`, the monthly income benefit, paid in arrears at the end of the month to lives in claim throughout it. New inceptions are excluded: a claim incepting at the end of month t is not paid until the end of month ``t + 1``. ``"DEATH"`` zero. This composite carries no death benefit; the £5,000-£10,000 death benefits two sampled insurers offer are out of scope, and would add a ``deaths x DB`` term here. ``"LAPSE"`` zero. There is no cash-in value at any time [S4][S5][S7]. The two zero kinds are published rather than omitted so that the product facts are stated instead of inferred from a missing column. """ if kind is None: return sum(claims(t, k) for k in ("BENEFIT", "DEATH", "LAPSE")) if kind == "BENEFIT": return claim_severity * amount_payable_pp(t) * pols_sick_surv(t) # noqa: F821 if kind in ("DEATH", "LAPSE"): return 0.0 raise ValueError("invalid kind")
[docs] def expenses(t): """EXP(t): maintenance and claim-management expense in month t **[std]**. £60 per policy a year on every policy in force, plus £300 a year on every claim in payment, both a twelfth at a time and both inflating at 3%. The claim-management load is what makes a long claim expensive to administer as well as to pay. """ maint = expense_maint / 12.0 * inflation_factor(t) * pols_if(t) # noqa: F821 claim_mgmt = (expense_claim / 12.0 * inflation_factor(t) # noqa: F821 * pols_sick(t)) return maint + claim_mgmt
[docs] def net_cf(t): """CF(t) = PREM(t) - BEN(t) - EXP(t): the net cash flow of month t, income positive. The notes' own sign, and the library-wide one, so there is no outgo-positive ``liability_cf`` companion. Death and lapse generate no payment at all on this product. """ return premiums(t) - claims(t) - expenses(t)
[docs] def disc_factor(t): """v(t) = (1 + i)^(-t/12): the worked example's flat discount factor **[std]**. A **companion to** the cash flow projection, not part of it: no line of :func:`result_cf` is discounted, and every other model in this library projects undiscounted gross cash flows and leaves discounting to the layer that consumes them. It exists because the notes' worked example is a present value and because the disabled-life annuity is the object a claims-in-payment reserve is quoted as. A Solvency UK best estimate discounts these same cash flows on the PRA risk-free term structure instead of a flat 3%. """ return (1.0 + disc_rate) ** (-t / 12.0) # noqa: F821
[docs] def pv_benefits(): """The present value of benefit outgo over the whole projection, at ``disc_rate``. On an ``in_claim`` cell run on the ``exit`` recovery basis this is the claims-in-payment liability the notes value. See :func:`disc_factor` for why discounting appears in this model and nowhere else in the library. """ return sum(claims(t, "BENEFIT") * disc_factor(t) for t in range(1, proj_len() + 1))
[docs] def annuity_dis(): """a_dis: the disabled-life annuity factor per £1 a month of amount payable. ``pv_benefits() / AP(1)``, which on an ``in_claim`` cell run on the ``exit`` basis is the notes' ``a_dis(a0, z0)`` exactly: the expected present value of the escalating benefit until recovery, death or expiry, truncated at the policy end date. On an ``active`` cell the same expression is the inception-annuity decomposition of the active-lives projection - the value of all *future* claims per unit of benefit - and is a different object with the same units. """ return pv_benefits() / amount_payable_pp(1)
[docs] def check_pols_roll_fwd_resid(t): """The in-force roll-forward residual in month t; zero everywhere. ``pols_if(t) - pols_if(t+1)`` less deaths from both states, lapses, recoveries leaving the model and the expiry. Inceptions and returning recoveries are absent because they move lives *between* states rather than out of the policy count - which is the point of running the check on ``H + S`` rather than on either state alone. """ return (pols_if(t) - pols_if(t + 1) - pols_death_active(t) - pols_death_sick(t) - pols_lapse(t) - pols_exit(t) - pols_maturity(t))
[docs] def check_pols_roll_fwd(): """True when the in-force roll-forward closes in every projected month. The library-wide form of a roll-forward check: no argument, one bool over all t, so one test can call it across every model. :func:`check_pols_roll_fwd_resid` gives the signed residual of the month that failed. The tolerance scales with ``pols_if_init()``, since the residual accumulates rounding on that many policies. """ return all(abs(check_pols_roll_fwd_resid(t)) <= 1e-10 * max(pols_if_init(), 1.0) for t in range(1, proj_len() + 1))
[docs] def check_states_resid(t): """The three-state population identity residual at the start of month t; zero. ``H + S + cumulative deaths + cumulative lapses + cumulative exits`` must equal the starting population in every month. This is the check that catches a leak in the cohort machinery: a mis-indexed duration shift drops population out of S with no corresponding exit, and nothing else in the model would notice. """ return (pols_active(t) + pols_sick(t) + pols_dead_cum(t) + pols_lapse_cum(t) + pols_exit_cum(t) - pols_if_init())
[docs] def check_states(): """True when the three-state population identity holds in every projected month. No argument, one bool over all t, the library-wide shape of a ``check_*`` cells; :func:`check_states_resid` gives the signed residual of the month that failed. """ return all(abs(check_states_resid(t)) <= 1e-10 * max(pols_if_init(), 1.0) for t in range(1, proj_len() + 1))
[docs] def check_benefit_max(): """True when the chosen benefit is inside the contractual maximum. ``B(1) <= benefit_max_pp() / tolerance``, the two-band earnings formula with the 90% tolerance that stops a small fall in earnings cutting an in-force benefit [S1][S2][S5][S7]. Unlike the other two checks this is a **validation of the model point** rather than an identity of the projection: a benefit above the maximum is a policy that could not have been written, and the underwriting record is on the model point precisely so that it can be checked. """ return benefit_mth() <= benefit_max_pp() / ben_tolerance + 1e-9 # noqa: F821
[docs] def result_cf(): """Result table of cashflows, indexed by policy month t. ``pols_if`` is H plus S at the start of the month. ``pols_active`` is published beside it because it, and not ``pols_if``, is the weight on premium income - the difference between the two columns is the population whose premiums are waived. Nothing here is discounted; see :func:`disc_factor`. """ ts = list(range(1, proj_len() + 1)) return pd.DataFrame( # noqa: F821 { "pols_if": [pols_if(t) for t in ts], "pols_active": [pols_active(t) for t in ts], "pols_sick": [pols_sick(t) for t in ts], "premiums": [premiums(t) for t in ts], "claims_benefit": [claims(t, "BENEFIT") for t in ts], "claims_death": [claims(t, "DEATH") for t in ts], "claims_lapse": [claims(t, "LAPSE") for t in ts], "expenses": [expenses(t) for t in ts], "net_cf": [net_cf(t) for t in ts], }, index=pd.Index(ts, name="t"), # noqa: F821 )
[docs] def result_states(): """Result table of state movements and rates, indexed by policy month t.""" ts = list(range(1, proj_len() + 1)) return pd.DataFrame( # noqa: F821 { "pols_active": [pols_active(t) for t in ts], "pols_sick": [pols_sick(t) for t in ts], "pols_inception": [pols_inception(t) for t in ts], "pols_recovery": [pols_recovery(t) for t in ts], "pols_death_active": [pols_death_active(t) for t in ts], "pols_death_sick": [pols_death_sick(t) for t in ts], "pols_lapse": [pols_lapse(t) for t in ts], "inception_rate": [inception_rate(t) for t in ts], "mort_rate": [mort_rate(t) for t in ts], "lapse_rate": [lapse_rate(t) for t in ts], }, index=pd.Index(ts, name="t"), # noqa: F821 )
# --------------------------------------------------------------------------- # References data = ("Interface", ("..", "Data"), "auto") point_id = 1 cycle_factor = 1.0 mort_active_factor = 1.0 ap_ratio = 1.0 claim_severity = 1.0 rpi_rate = 0.03 esc_cap = 0.1 esc_prem_mult = 1.5 esc_lapse_slope = 2.0 esc_lapse_threshold = 0.05 ben_band1_rate = 0.65 ben_band2_rate = 0.5 ben_breakpoint = 60000.0 ben_cap_mth = 20000.0 ben_guarantee_mth = 1500.0 ben_tolerance = 0.9 expense_maint = 60.0 expense_claim = 300.0 inflation_rate = 0.03 disc_rate = 0.03 pd = ("Module", "pandas")