Source code for uklib.products.critical_illness.CI_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:`~.CI_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 worked example's anchor cell
    >>> Projection.point_id = 2            # or switch the default

``t`` counts **policy months**, 1-based: ``t = 1`` is the first projected month and
``t = proj_len() = 12 x term`` the last. The notes index the in-force probability
``l(t)`` at the **end** of month ``t`` with ``l(0) = 1``; the library indexes
:func:`pols_if` at the **start** of the month, so ``pols_if(t)`` is the notes' ``l(t-1)``
and the notes' ``l(t)`` is :func:`pols_if_at` ``(t, "AFT_DECR")``. That is deliberate:
``pols_if(t)`` is then the weight on the same ``result_cf()`` row's cash flows, which is
the convention every model in this library shares.

.. rubric:: Input data

Inputs are **external files**: plain CSVs living in the model folder's parent
directory, ``products/critical_illness/``, 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
``CI_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:`~.CI_UK_S.Data`,
reached here through the ``data`` Reference:

======================  ==============================  ==========================
Reference               Cells                           File
======================  ==============================  ==========================
model_point_file        data.model_point_table()        model_point_table.csv
ci_rate_file            data.ci_rate_table()            ci_rate_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 policy counts, plural nouns for
cash flows, ``*_rate`` for annual rates and ``*_rate_mth`` for monthly ones, ``*_pp``
for per-policy amounts, ``claims(t, kind)`` and ``benefit_pp(t, kind)`` with an
uppercase ``kind`` string, ``pols_if_at(t, timing)`` for the within-month in-force
reads. The technical notes use compact actuarial symbols instead. The mapping is:

===================  ===============================  ================================
Notes symbol         Cells                            Meaning
===================  ===============================  ================================
contract_type        contract_type()                  accelerated or standalone
issue_age            age_at_entry(life)               Issue age (ANB), life 1 or 2
a                    age(t, life)                     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
n                    policy_term()                    Term in years
12n                  proj_len()                       Last projected month
SA                   sum_assured()                    Sum assured at outset
SA(t)                benefit_pp(t, "MAIN")            Sum assured in month t, indexed
B_AP                 benefit_pp(t, "AP")              Additional-payment benefit
B_ch                 benefit_pp(t, "CHILD")           Children's-cover benefit
P                    premium_mth_pp()                 Monthly premium at outset
P(t)                 premium_pp(t)                    Monthly premium in month t
(pivot table)        pivot_interp(x, sex, smoker, c)  Log-linear pivot lookup
i_ci(a)              ci_rate_base(t, life)            Table CI diagnosis rate
(1 + tau)^(y-1)      ci_trend_factor(t)               CI trend factor
eta                  sel_lapse_ci_loading             Anti-selection loading on i_ci
(none)               ci_sel_factor(t)                 Anti-selection factor applied
i_ci x trend x sel   ci_rate(t, life)                 CI diagnosis rate applied
q_d(a)               mort_rate(t, life)               Best-estimate mortality rate
k                    overlap_k                        Death/CI overlap factor, 0.10
(per life)           claim_rate_life(t, life)         q_d(1-k) + i_ci, one life
q_claim(a)           claim_rate(t)                    Combined claim decrement
q_pay(a)             claim_rate_paid(t)               The part that pays a benefit
delta                survival_slip                    Standalone slippage, 0.03
q_m(t)               claim_rate_mth(t)                Monthly combined decrement
q_pay_m(t)           claim_rate_paid_mth(t)           Monthly paying decrement
q_exit_m(t)          claim_rate_exit_mth(t)           Monthly non-paying decrement
a(x)                 ap_rate(t)                       Additional-payment frequency
a_m(t)               ap_rate_mth(t)                   The same, monthly
lambda_ch            child_freq                       Children's claim frequency p.a.
lambda_m             child_rate_mth()                 The same, monthly
w(y)                 lapse_rate(t)                    Annual lapse rate in month t
(table)              lapse_rate_base(t)               Table lapse rate before shock
w_shock              lapse_rate(t) at a review        Post-review shock lapse
w_m(t)               lapse_rate_mth(t)                Monthly lapse rate
rho_review           review_prem_shock                Premium change at a review
(none)               reviews_passed(t)                Reviews already applied
(none)               review_shock_active(t)           In the post-review window
l(t-1)               pols_if(t)                       In force at the start of month t
l(t)                 pols_if_at(t, "AFT_DECR")        In force at the end of month t
(none)               pols_if_at(t, timing)            BEF_DECR / BEF_LAPSE / AFT_DECR
(none)               pols_claim(t)                    Main-benefit claims in month t
(none)               pols_lapse(t)                    Lapses at the end of month t
(none)               pols_maturity(t)                 Expiries at the end of the term
idx(t)               idx_factor(t)                    Cover indexation factor
idx_p(t)             idx_prem_factor(t)               Premium indexation factor
P x l                premiums(t)                      Premium income
SA x q_m x l         claims(t, "MAIN")                Main-benefit outgo
B_AP x a_m x l       claims(t, "AP")                  Additional-payment outgo
B_ch x lambda_m x l  claims(t, "CHILD")               Children's-cover outgo
0                    claims(t, "LAPSE")               Surrender outgo; always zero
E_cl x q_m x l       claim_expenses(t)                Claim handling expense
E0, E_m(y)           expenses(t)                      Initial + maintenance expense
(none)               inflation_factor(t)              Expense inflation factor
CF(t)                net_cf(t)                        Net cash flow, income positive
===================  ===============================  ================================

Three names needed care.

The notes write ``a(x)`` for the additional-payment frequency and ``a`` for the attained
age in the same table. :func:`ap_rate` and :func:`age` separate them; nothing in the
model is called ``a``.

``q_d`` is the best-estimate mortality rate and is spelled :func:`mort_rate`, the
library-wide name, even though on this product mortality never acts as a decrement on
its own: it enters only through :func:`claim_rate`, net of the overlap. Keeping the
name makes the input visible where a reader looks for it.

``SA`` is a constant in the notes and a function of ``t`` here, because the indexation
option moves it. ``benefit_pp(t, "MAIN")`` is that quantity; :func:`sum_assured` is the
outset value the model point carries. The additional-payment and children's benefits
are derived from the *indexed* sum assured **[std]** — the notes state their caps
against ``SA`` without saying which ``SA``, and applying the cap to a frozen outset
value would make the benefit shrink in real terms while the main benefit did not.

.. rubric:: The combined decrement, and why the rates cannot be added

The insured event is *death or first CI diagnosis, whichever comes first*. Adding
``q_d`` and ``i_ci`` double-counts lives that are both diagnosed and die in the same
period: once the CI claim has been paid the subsequent death of that life is not a
second claim, and a death inside the survival period converts the CI claim into a death
claim of the same amount rather than adding one. So

    q_claim(a) = i_ci(a)(1 + tau)^(y-1) + q_d(a)(1 - k)          **[std]**

where ``k`` is the proportion of deaths preceded by a claimable diagnosis. ``k = 0.10``
flat is a standardization: the cause-of-claim splits that would calibrate it live in
CMI working papers whose datasets are subscriber-restricted. ``k = 0`` maximally
double-counts and ``k = 0.25`` may understate, so the notes rate it the third-largest
lever on the liability and it is a Reference rather than a literal.

On the **accelerated** contract nothing further is needed: however the overlap
resolves, ``SA`` is paid once. The 14-day survival period is cash-flow-neutral there
and is not modelled, which is the notes' second pitfall — applying the survival-period
slippage ``delta`` to the accelerated main benefit is wrong, because a death inside the
period still pays ``SA`` as a death claim.

On the **standalone** contract death pays nothing, and the decrement splits::

    q_pay(a)  = i_ci(a)(1 + tau)^(y-1) (1 - delta)
    q_exit(a) = q_d(a)(1 - k) + i_ci(a)(1 + tau)^(y-1) delta

with the same total, so the in-force run-off is identical and only the *paid* part
generates outgo. Note where ``k`` goes: onto the non-paying death exit, never onto the
paid decrement. Applying it to ``q_pay`` instead is the mirror-image pitfall and
understates claims.

One divergence from the notes' literal text. The notes convert annual rates to monthly
with ``1 - (1 - q)^(1/12)`` and prescribe ``q_pay_m`` for the standalone main benefit,
but converting ``q_pay`` and ``q_exit`` independently leaves their sum slightly below
the ``q_m`` the same notes use for the in-force run-off — the two conventions are
inconsistent at second order. :func:`claim_rate_paid_mth` follows the notes literally
and :func:`claim_rate_exit_mth` is defined as the **residual** ``q_m - q_pay_m``, so the
split is exact by construction and the run-off is the notes'.
:func:`check_claim_split` bounds the artefact: it asserts the residual really is the
independently converted ``q_exit`` to within ``claim_split_tol``, which on the
shipped points it is by four decimal places or better.

.. rubric:: The non-terminating benefits

The additional-payment and children's-cover benefits are the notes' third and fourth
pitfalls, and they are one mistake in two directions. Both are **non-depleting** —
they do not reduce the sum assured — and **non-terminating** — they do not decrement
the in-force. Only the main benefit ends the policy. Modelling them as accelerations
of the sum assured is a different product; terminating the policy on one is the same
error with the opposite sign.

They are carried as frequency loadings on the in-force, ``a_m`` and ``lambda_m``, and
appear in the roll-forward nowhere at all: :func:`pols_if_at` never sees them. Note the
monthly conversion is ``rate / 12`` and not ``1 - (1 - rate)^(1/12)``, which is correct
and deliberate: these are claim *frequencies* — expected events per year, repeatable —
not probabilities of a terminating event, so there is no survival transform to apply.

The treatment deliberately ignores the contractual claim-count caps — one per
additional-payment condition, two children's claims, and the per-child cross-policy
limit — because at the **[std]** frequencies shipped here the probability of reaching a
cap is second order. Respecting them exactly would need claim-count state variables.

.. rubric:: The rate basis is interpolated, not tabulated

``ci_rate_table.csv`` holds six pivot ages and the notes' rule is to interpolate
log-linearly between them, so :func:`pivot_interp` does exactly that: geometric in the
rate, linear in age. Outside the pivot range it continues the nearest end segment's
gradient, which is the same expression with the exponent out of [0, 1] — an
extrapolation, and flagged as one, because a 25-year policy issued at 40 stays inside
the pivots but a longer or younger one does not.

Both rate sets are **[std]** placeholders shaped like the tables the notes name — AC04
and the "16" Series for diagnosis, a scaled population table for mortality — and must
not be presented as CMI or ONS values. The sex and smoker cells are the male
non-smoker pivots times flat factors, which is cruder still: real accelerated-CI
experience is not a flat female factor, since breast cancer makes female incidence
exceed male at the younger ages. The file's ``provenance`` column marks which cells came
from the notes and which from a factor.

.. rubric:: The reviewable variant

``premium_guarantee = reviewable`` turns on a 5-yearly review from the fifth
anniversary. Premiums are constant between reviews and multiplied by
``1 + rho_review`` at each one; the snapshot is ``rho_review = 0``, so the variant runs
identically to the guaranteed one until the Reference is moved. Two behavioural
responses hang off the same switch, both **[std]**:

- a **review shock lapse** in the twelve months after a review that raises premiums by
  more than 5%, ``min(0.30, w(y) + 2.0 max(0, rho - 0.05))``; and
- **selective lapsation** from the first such shock onward, loading the diagnosis rate
  by ``1 + eta`` on the lives that remain, because the healthier ones leave first.

The economics are the point of the variant rather than the cash flows: guaranteed
premiums mean morbidity deterioration and definition drift fall entirely on the
insurer, and the reviewable design transfers that to policyholders at the cost of these
two responses.

.. rubric:: What this product does not have

No account value, no asset share, no surrender or paid-up value, no bonus, no market
value reduction, and no commission line — the notes fold acquisition cost into the £200
initial expense rather than carrying commission separately as the term assurance
chassis does. There is likewise no interest-sensitive dynamic lapse: with no cash value
and no credited rate there is nothing to arbitrage, so the machinery the accumulation
products in this library carry is deliberately absent. ``claims(t, "LAPSE")`` exists and
returns zero so that the absence of a surrender value is stated rather than inferred.

.. rubric:: Sign convention

The notes' ``Net CF`` is income positive, which is the library-wide sign of
:func:`net_cf`, so there is no outgo-positive ``liability_cf`` companion here. One
caveat for a reader checking the worked example by eye: the notes' **Net CF column
excludes the initial expense** — at month 1 it shows 31.88, with the £200 initial
expense noted separately as taking the month to -168.12. :func:`net_cf` is the total,
so ``net_cf(1)`` is -168.12 and the notes' column is ``net_cf(1) + 200``.
"""

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 contract_type(): """``accelerated`` (death or CI, whichever first) or ``standalone`` (CI only). On the standalone variant death pays nothing and simply terminates the policy [S4][S11]; a premium-refund-on-death feature exists in some designs and is excluded **[std]**. """ v = model_point()["contract_type"] if v not in ("accelerated", "standalone"): raise ValueError("invalid contract_type") return v
[docs] def life_basis(): """``single`` or ``joint_first_event``. The joint variant is a two-life survivorship product the notes place outside the base model **[std scope]**. It is not combined with the standalone contract: the notes write the standalone decrement split for one life only, and there is no published basis for splitting a joint first-event decrement into paying and non-paying parts, so the combination raises rather than inventing one. """ v = model_point()["life_basis"] if v not in ("single", "joint_first_event"): raise ValueError("invalid life_basis") if v == "joint_first_event" and contract_type() == "standalone": raise ValueError("standalone joint first event is out of scope") return v
[docs] def is_joint(): """True when the policy covers two lives on a first-event basis.""" return life_basis() == "joint_first_event"
[docs] def age_at_entry(life=1): """The issue age (ANB) of the first (``life = 1``) or second life. Age nearest birthday, attained age advancing on policy anniversaries **[std]**. Any consistent basis works, but it must be the same one for the diagnosis rates, the mortality rates and the attained-age indexing - a mismatch there is one of the notes' listed pitfalls. """ if life == 1: return int(model_point()["age_at_entry"]) if life == 2 and is_joint(): return int(model_point()["joint_age"]) raise ValueError("invalid life")
[docs] def sex(life=1): """The sex (M / F) of the first or second life.""" if life == 1: return model_point()["sex"] if life == 2 and is_joint(): return model_point()["joint_sex"] raise ValueError("invalid life")
[docs] def smoker(life=1): """The smoker status (NS / S) of the first or second life.""" if life == 1: return model_point()["smoker"] if life == 2 and is_joint(): return model_point()["joint_smoker"] raise ValueError("invalid life")
[docs] def sum_assured(): """SA: the sum assured at outset **[std]**, £100,000 on the anchor cell. :func:`benefit_pp` ``(t, "MAIN")`` is the amount actually at risk in month t, which differs from this once the indexation option is elected. """ return float(model_point()["sum_assured"])
[docs] def policy_term(): """n: the term in years, 5-50 [S2][S5]; the policy also ends by the 75th birthday.""" return int(model_point()["policy_term"])
[docs] def cover_basis(): """The cover shape. Level only: decreasing and FIB shapes are out of scope here. Both exist on the term assurance chassis this product sits on and are implemented in ``Term_UK_A``; these notes scope them out **[std scope]**. """ v = model_point()["cover_basis"] if v != "level": raise ValueError("invalid cover_basis") return v
[docs] def premium_guarantee(): """``guaranteed`` [S1] or ``reviewable``, which turns on the 5-yearly review cycle.""" v = model_point()["premium_guarantee"] if v not in ("guaranteed", "reviewable"): raise ValueError("invalid premium_guarantee") return v
[docs] def premium_mth_pp(): """P: the monthly premium per policy at outset **[std]**, £55.00 on the anchor cell. A placeholder, not a market rate: no UK insurer publishes CI rate cards, so any reference premium is constructed. Profitability conclusions drawn from the worked example are meaningless. """ return float(model_point()["premium_mth"])
[docs] def premium_mode(): """Monthly or annual premium payment. Inert: the grid is monthly and the notes carry the premium as a monthly amount either way. Carried because the model point attribute table names it. """ return model_point()["premium_mode"]
[docs] def children_cover(): """Whether children's cover is active; automatic on the composite product [S1].""" return bool(model_point()["children_cover"])
[docs] def indexation(): """Whether the increasing-cover option is elected; false in the base run **[std]**. Declining an increase three years running removes the option [S1][S4]; the base model assumes full take-up while the option is active, so that rule is never reached and is not implemented. """ return bool(model_point()["indexation"])
[docs] def pols_if_init(): """Initial number of policies in force; 1.0 on a single-policy model point.""" return float(model_point()["pols_if_init"])
[docs] def proj_len(): """Projection length in months: ``12 x term``. The policy expires at the end of the term with no maturity and no surrender value [S1][S4][S5], so there is nothing after it. """ return 12 * policy_term()
[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 issue 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, life=1): """a: the attained age (ANB) of ``life`` in the policy year containing month t.""" return age_at_entry(life) + duration(t)
[docs] def pivot_interp(x, sex_, smoker_, column): """Log-linear interpolation of ``column`` at age x from the pivot table. Geometric in the rate and linear in age:: r(x) = r0 (r1/r0)^((x - x0)/(x1 - x0)) which is the notes' "interpolate log-linearly between pivot ages" **[std]** written without a logarithm. Outside the pivot range the nearest end segment's gradient is continued - the same expression with the exponent outside [0, 1] - which is an **extrapolation** and should be read as one: the shipped pivots run 40 to 65, so a policy issued young or running long leaves the tabulated region. """ sub = data.ci_rate_table().loc[(sex_, smoker_)] # noqa: F821 ages = sorted(int(a) for a in sub.index) 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, column]) r_hi = float(sub.loc[hi, column]) return r_lo * (r_hi / r_lo) ** ((x - lo) / (hi - lo))
[docs] def ci_rate_base(t, life=1): """i_ci(a): the table CI diagnosis rate for ``life`` in the year containing month t. First diagnosis of a listed condition, total and permanent disability included. A **[std]** proxy shaped like an insured-lives accelerated-CI diagnosis-rate table - the AC04 and "16" Series values are subscriber-restricted - and not to be presented as a CMI rate. """ return pivot_interp(age(t, life), sex(life), smoker(life), "i_ci")
[docs] def mort_rate(t, life=1): """q_d(a): the best-estimate annual mortality rate for ``life`` **[std]**. Never a decrement on its own: on this product mortality enters only through :func:`claim_rate`, net of the overlap factor. A **[std]** proxy shaped like a scaled population table, not an ONS value. """ return pivot_interp(age(t, life), sex(life), smoker(life), "q_d")
[docs] def ci_trend_factor(t): """(1 + tau)^(y-1): the CI trend factor in the year containing month t **[std]**. Zero trend in the base run. It is the notes' *first*-listed sensitivity anyway: diagnosis rates move with medical practice, and the covered event itself moves when the ABI revises its model definitions - the 2021/22 review broadened Alzheimer's to all dementia, tightened cancer staging and excluded myocardial injury from heart attack. Those are **step** changes that no trend parameter anticipates, so the basis must be re-mapped at each definition generation rather than trended through one. """ return (1.0 + ci_trend) ** (policy_year(t) - 1) # noqa: F821
[docs] def ci_sel_factor(t): """1 + eta once a review shock has occurred; 1 otherwise **[std]**. Healthier lives lapse first when premiums rise, so the lives remaining after a review shock carry loaded diagnosis rates. Reachable only on the reviewable variant, and only once a review has actually raised premiums past the shock threshold; ``sel_lapse_ci_loading`` is 0.10 in the notes and the magnitude is a placeholder. """ if premium_guarantee() != "reviewable": return 1.0 if reviews_passed(t) < 1: return 1.0 if review_prem_shock <= review_lapse_threshold: # noqa: F821 return 1.0 return 1.0 + sel_lapse_ci_loading # noqa: F821
[docs] def ci_rate(t, life=1): """The CI diagnosis rate applied to ``life`` in month t: table x trend x selection.""" return ci_rate_base(t, life) * ci_trend_factor(t) * ci_sel_factor(t)
[docs] def claim_rate_life(t, life=1): """The combined annual claim decrement for one life: ``i_ci + q_d (1 - k)``. The overlap term is what stops the two rates being added. See the Space docstring for why, and for the bounds on ``k``. """ return ci_rate(t, life) + mort_rate(t, life) * (1.0 - overlap_k) # noqa: F821
[docs] def claim_rate(t): """q_claim(a): the combined annual claim decrement applied to the policy. One life's rate on a single-life policy; on a joint first-event policy the joint decrement ``1 - (1 - q_1)(1 - q_2)`` **[std]** on one policy, which pays once and ends. Capped at 1. """ q1 = claim_rate_life(t, 1) if not is_joint(): return min(1.0, q1) q2 = claim_rate_life(t, 2) return min(1.0, 1.0 - (1.0 - q1) * (1.0 - q2))
[docs] def claim_rate_paid(t): """q_pay(a): the part of the claim decrement that pays the main benefit. The whole of it on the **accelerated** contract, where death and CI both pay ``SA`` and the 14-day survival period is cash-flow-neutral. On the **standalone** contract only diagnoses that survive the period pay, ``i_ci(1 - delta)``; deaths pay nothing and neither do diagnoses followed by death inside the period. The overlap factor ``k`` belongs to the non-paying exit and never appears here - applying it to the paid decrement understates claims, which is the mirror image of the double-counting pitfall. """ if contract_type() == "accelerated": return claim_rate(t) return ci_rate(t, 1) * (1.0 - survival_slip) # noqa: F821
[docs] def claim_rate_mth(t): """q_m(t) = 1 - (1 - q_claim)^(1/12): the monthly combined decrement **[std]**. This is what runs the in-force off, on both contract types: the standalone variant terminates on death as well, it simply pays nothing for it. """ return 1.0 - (1.0 - claim_rate(t)) ** (1.0 / 12.0)
[docs] def claim_rate_paid_mth(t): """q_pay_m(t) = 1 - (1 - q_pay)^(1/12): the monthly paying decrement. Equal to :func:`claim_rate_mth` on the accelerated contract, where every claim pays. """ return 1.0 - (1.0 - claim_rate_paid(t)) ** (1.0 / 12.0)
[docs] def claim_rate_exit_mth(t): """q_exit_m(t): the monthly decrement that terminates the policy without paying. Defined as the **residual** ``q_m - q_pay_m`` rather than by converting ``q_exit`` independently, so that the paying and non-paying parts sum exactly to the decrement that runs the in-force off. Converting both annual parts independently would leave their sum slightly below ``q_m``: the notes' additive annual split and their ``1 - (1 - q)^(1/12)`` conversion are inconsistent at second order. :func:`check_claim_split` bounds the difference rather than hiding it. Zero on the accelerated contract. """ return claim_rate_mth(t) - claim_rate_paid_mth(t)
[docs] def ap_rate(t): """a(x) = 0.15 i_ci(a): the annual additional-payment claim frequency **[std]**. A **frequency**, not a probability: additional-payment conditions are payable more than once across the schedule, subject to one claim per condition, and they never terminate the policy. """ return ap_freq_factor * ci_rate(t, 1) # noqa: F821
[docs] def ap_rate_mth(t): """a_m(t) = a(x)/12: the monthly additional-payment frequency **[std]**. Divided by twelve rather than transformed by ``1 - (1 - a)^(1/12)``, because a frequency of repeatable events has no survival transform to apply. The notes use the same approximation and say so. """ return ap_rate(t) / 12.0
[docs] def child_rate_mth(): """lambda_m = lambda_ch/12: the monthly children's-cover claim frequency **[std]**. Flat in age and duration, and zero if children's cover is not active. Like the additional-payment frequency it is per-year events divided by twelve, not a probability conversion. """ if not children_cover(): return 0.0 return child_freq / 12.0 # noqa: F821
[docs] def reviews_passed(t): """The number of premium reviews already applied at month t. Reviews fall every ``review_period_mths`` from the fifth anniversary [S3][S4], so the first bites in month 61. Always zero on guaranteed-premium business, where the premium cannot be changed at all. """ if premium_guarantee() != "reviewable": return 0 return (t - 1) // review_period_mths # noqa: F821
[docs] def review_shock_active(t): """Whether month t falls in the twelve months after a premium-raising review. The window a review shock lapse applies in. A review that raises premiums by less than the threshold produces no shock at all. """ if reviews_passed(t) < 1: return False if review_prem_shock <= review_lapse_threshold: # noqa: F821 return False return (t - 1) % review_period_mths < 12 # noqa: F821
[docs] def idx_increase(): """The cover increase offered at each anniversary under the indexation option. ``min(max(RPI, 0), 10%)`` [S1][S4] on a flat 3% RPI scenario **[std]**. """ return min(max(rpi_rate, 0.0), idx_cover_cap) # noqa: F821
[docs] def idx_factor(t): """idx(t): the cumulative **cover** indexation factor in month t. Steps on policy anniversaries, so it is ``(1 + increase)^(y - 1)``; 1 throughout if the option is not elected. """ if not indexation(): return 1.0 return (1.0 + idx_increase()) ** (policy_year(t) - 1)
[docs] def idx_prem_factor(t): """idx_p(t): the cumulative **premium** indexation factor in month t. The premium rises by ``min(1.5 x increase, 15%)`` for a cover increase of ``increase`` [S1][S4] - the same x1.5 factor the term assurance chassis carries. """ if not indexation(): return 1.0 step = min(idx_prem_mult * idx_increase(), idx_prem_cap) # noqa: F821 return (1.0 + step) ** (policy_year(t) - 1)
[docs] def premium_pp(t): """P(t): the monthly premium per policy in month t. The outset premium, indexed if the option is elected, and multiplied by ``1 + rho_review`` once per review already passed. On guaranteed-premium business - the base run - it is level for the whole term, which is what puts every month of premium inside the Solvency UK contract boundary. """ p = premium_mth_pp() * idx_prem_factor(t) return p * (1.0 + review_prem_shock) ** reviews_passed(t) # noqa: F821
[docs] def premiums(t): """Premium income at the beginning of month t, an inflow. Carried on the survivors at the start of the month, :func:`pols_if`. """ return premium_pp(t) * pols_if(t)
[docs] def lapse_rate_base(t): """The table annual lapse rate in month t **[std]**, before any review shock. 10 / 8 / 6 / 6 / 6 / 4 percent by policy year. Protection lapse is duration-skewed - buyer's remorse, remortgaging, distribution churn - and levels off later; UK CI lapse studies are proprietary, so the levels are calibrations to be replaced with the user's own experience. 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(y): the **annual** lapse rate applying in month t, review shock included. ``min(0.30, w(y) + 2.0 max(0, rho_review - 0.05))`` in the twelve months after a review that raises premiums by more than 5% **[std]**, and the table rate otherwise. Review-driven shocks are the dominant behavioural risk on reviewable business, where one insurer's review changes are subject to no stated limit at all; the slope and the cap are placeholders. There is no interest-sensitive dynamic lapse anywhere in this model. With no cash value and no credited rate there is nothing to arbitrage, so the machinery the accumulation products in this library carry is deliberately absent. """ w = lapse_rate_base(t) if not review_shock_active(t): return w shock = w + review_lapse_slope * max( # noqa: F821 0.0, review_prem_shock - review_lapse_threshold) # noqa: F821 return min(review_lapse_cap, shock) # noqa: F821
[docs] def lapse_rate_mth(t): """w_m(t) = 1 - (1 - w(y))^(1/12): the monthly lapse rate **[std]**.""" return 1.0 - (1.0 - lapse_rate(t)) ** (1.0 / 12.0)
[docs] def pols_if(t): """The number of policies in force at the **start** of policy month t. The notes' ``l(t-1)``, and the weight on every cash flow of the same ``result_cf()`` row. ``pols_if_init()`` in month 1, then the notes' recursion ``l(t) = l(t-1)(1 - q_m)(1 - w_m)``. Zero once the term has run out. """ if t < 1 or t > proj_len(): return 0.0 if t == 1: return pols_if_init() return pols_if_at(t - 1, "AFT_DECR")
[docs] def pols_if_at(t, timing): """The number of policies in force at a point inside policy month t. ``"BEF_DECR"`` the start of the month, before any decrement; the same number as :func:`pols_if` and the weight on that month's cash flows. ``"BEF_LAPSE"`` after main-benefit claims, before lapses - the notes' processing order is **claim before lapse** **[std order]**, so this is the population lapses are taken from. ``"AFT_DECR"`` the notes' ``l(t)``, the end-of-month state, and zero from the last month on because the cover expires there. The additional-payment and children's-cover claims appear at none of these points. They are non-terminating: only the main benefit ends the policy. """ if timing == "BEF_DECR": return pols_if(t) if timing == "BEF_LAPSE": return pols_if(t) * (1.0 - claim_rate_mth(t)) if timing == "AFT_DECR": if t < 1 or t >= proj_len(): return 0.0 return pols_if_at(t, "BEF_LAPSE") * (1.0 - lapse_rate_mth(t)) raise ValueError("invalid timing")
[docs] def pols_claim(t): """Main-benefit claims in month t: death, terminal illness or CI diagnosis. The whole combined decrement, including the standalone contract's non-paying deaths - they terminate the policy just the same. What is *paid* on them is :func:`claims` ``(t, "MAIN")``. """ return pols_if(t) * claim_rate_mth(t)
[docs] def pols_lapse(t): """Lapses at the end of month t, taken from the non-claiming survivors. Pays nothing: there is no surrender or paid-up value [S1][S4][S5]. The 60-day grace period is not modelled separately - the lapse rates are assumed already to reflect grace-period cures **[std]**. """ return pols_if_at(t, "BEF_LAPSE") * lapse_rate_mth(t)
[docs] def pols_maturity(t): """Policies whose cover expires at the end of the term; zero in every other month. Not a decrement and not a benefit - the contract runs out, with no maturity value - but needed for the in-force roll-forward to close; see :func:`check_pols_roll_fwd`. """ if t != proj_len(): return 0.0 return pols_if_at(t, "BEF_LAPSE") * (1.0 - lapse_rate_mth(t))
[docs] def benefit_pp(t, kind): """The benefit amount per policy in month t, by kind. ``"MAIN"`` SA(t), the sum assured, indexed if the option is elected. ``"AP"`` the additional-payment benefit ``min(25% of SA(t), £25,000)`` [S1][S4][S11] - £25,000 at the anchor cell, and **non-depleting**: it does not reduce the main benefit. ``"CHILD"`` children's cover ``min(50% of SA(t), £25,000)`` [S1], also non-depleting, and zero if the cover is not active. The £4,000 child funeral benefit is excluded as de minimis **[std]**. Both caps are struck against the **indexed** sum assured **[std]**: the notes state them against ``SA`` without saying which, and holding them to a frozen outset value would let the ancillary benefits shrink in real terms while the main one did not. In practice the cash cap binds at the anchor cell either way. """ if kind == "MAIN": return sum_assured() * idx_factor(t) if kind == "AP": return min(ap_share * benefit_pp(t, "MAIN"), ap_cap) # noqa: F821 if kind == "CHILD": if not children_cover(): return 0.0 return min(child_share * benefit_pp(t, "MAIN"), child_cap) # noqa: F821 raise ValueError("invalid kind")
[docs] def claims(t, kind=None): """Benefit outgo in month t, by kind; the total when kind is omitted. ``"MAIN"`` ``SA(t) x q_pay_m(t) x l``, the terminating benefit. On the accelerated contract every claim pays; on the standalone one only diagnoses surviving the 14-day period do. ``"AP"`` ``B_AP x a_m(t) x l``, non-terminating and non-depleting. ``"CHILD"`` ``B_ch x lambda_m x l``, likewise. ``"LAPSE"`` zero, always. There is no surrender or paid-up value [S1][S4][S5]; the kind exists so that the zero is stated rather than inferred. """ if kind is None: return sum(claims(t, k) for k in ("MAIN", "AP", "CHILD", "LAPSE")) if kind == "MAIN": return benefit_pp(t, "MAIN") * claim_rate_paid_mth(t) * pols_if(t) if kind == "AP": return benefit_pp(t, "AP") * ap_rate_mth(t) * pols_if(t) if kind == "CHILD": return benefit_pp(t, "CHILD") * child_rate_mth() * pols_if(t) if kind == "LAPSE": return 0.0 raise ValueError("invalid kind")
[docs] def claim_expenses(t): """E_cl x q_m(t) x l: the claim handling expense in month t **[std]**. £250 per main claim, uninflated, carried on the **whole** combined decrement rather than on the paying part: a notified death on a standalone contract costs the insurer to assess even though it pays nothing. That is the notes' own formula. Kept out of :func:`expenses` because the notes' worked example prints the two as separate columns. """ return expense_claim * claim_rate_mth(t) * pols_if(t) # noqa: F821
[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 expenses(t): """E0 and E_m(y): the initial and maintenance expense in month t **[std]**. £200 per policy at issue, then £30 per policy per year - a twelfth of it each month - inflating at 3%, both at the beginning of the month. There is **no commission line** on this product: the notes fold acquisition cost into the initial expense rather than carrying commission separately as the term assurance chassis does. """ acq = expense_acq * pols_if(t) if t == 1 else 0.0 # noqa: F821 return acq + expense_maint / 12.0 * inflation_factor(t) * pols_if(t) # noqa: F821
[docs] def net_cf(t): """CF(t): the net cash flow of month t, **income positive**. Premiums less main, additional-payment and children's claims, claim expense and expenses. The notes' own sign, and the library-wide one, so there is no outgo-positive ``liability_cf`` companion. One caveat for a reader checking the worked example by eye: the notes' *Net CF* column **excludes the initial expense**, showing 31.88 at month 1 and noting the £200 separately as taking the month to -168.12. This cells is the total, so ``net_cf(1)`` is -168.12 and the notes' column is ``net_cf(1) + 200``. """ return premiums(t) - claims(t) - claim_expenses(t) - expenses(t)
[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) - main claims - lapses - expiries``. The additional-payment and children's-cover claims are absent by design: they are non-terminating, and their appearance here would be the notes' fourth pitfall. Expiries are non-zero only in the last month, where the survivors neither claim nor lapse - their cover runs out. """ return (pols_if(t) - pols_if(t + 1) - pols_claim(t) - pols_lapse(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_claim_split_resid(t): """How far the residual exit rate is from an independently converted ``q_exit``. :func:`claim_rate_exit_mth` is defined as ``q_m - q_pay_m`` so that the split is exact; the notes' own construction would instead convert the annual ``q_exit`` directly. The two differ because the notes add the annual parts but convert them geometrically, which cannot both hold. This residual is that inconsistency, made visible: it is zero on the accelerated contract and of order 1e-5 on the standalone one at the shipped rates, growing with the level of the rates. """ if contract_type() == "accelerated": return 0.0 q_exit = (mort_rate(t, 1) * (1.0 - overlap_k) # noqa: F821 + ci_rate(t, 1) * survival_slip) # noqa: F821 return claim_rate_exit_mth(t) - (1.0 - (1.0 - q_exit) ** (1.0 / 12.0))
[docs] def check_claim_split(): """True when the paying/non-paying split stays inside ``claim_split_tol``. No argument, one bool over all t, the library-wide shape of a ``check_*`` cells. Unlike a roll-forward check this one is not an identity that must hold exactly - it bounds a known second-order artefact of the notes' two conventions - so its tolerance is a Reference rather than a machine epsilon, and moving that Reference is how a user states how much of the artefact they will accept. """ return all(abs(check_claim_split_resid(t)) <= claim_split_tol # noqa: F821 for t in range(1, proj_len() + 1))
[docs] def result_cf(): """Result table of cashflows, indexed by policy month t. ``pols_if`` is the start-of-month count, which is the weight applied to every cash flow on the same row. ``net_cf`` carries the notes' own income-positive sign and **includes** the initial expense, unlike the notes' own Net CF column. ``claims_lapse`` is a column of zeros by product design - there is no surrender value - and is published rather than dropped. """ ts = list(range(1, proj_len() + 1)) return pd.DataFrame( # noqa: F821 { "pols_if": [pols_if(t) for t in ts], "premiums": [premiums(t) for t in ts], "claims_main": [claims(t, "MAIN") for t in ts], "claims_ap": [claims(t, "AP") for t in ts], "claims_child": [claims(t, "CHILD") for t in ts], "claims_lapse": [claims(t, "LAPSE") for t in ts], "claim_expenses": [claim_expenses(t) 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_pols(): """Result table of policy counts and decrement rates, indexed by policy month t.""" ts = list(range(1, proj_len() + 1)) return pd.DataFrame( # noqa: F821 { "pols_if": [pols_if(t) for t in ts], "pols_claim": [pols_claim(t) for t in ts], "pols_lapse": [pols_lapse(t) for t in ts], "pols_maturity": [pols_maturity(t) for t in ts], "pols_if_aft_decr": [pols_if_at(t, "AFT_DECR") for t in ts], "ci_rate": [ci_rate(t) for t in ts], "mort_rate": [mort_rate(t) for t in ts], "claim_rate": [claim_rate(t) for t in ts], "claim_rate_mth": [claim_rate_mth(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 overlap_k = 0.1 survival_slip = 0.03 ci_trend = 0.0 ap_freq_factor = 0.15 ap_share = 0.25 ap_cap = 25000.0 child_freq = 0.0004 child_share = 0.5 child_cap = 25000.0 expense_acq = 200.0 expense_maint = 30.0 expense_claim = 250.0 inflation_rate = 0.03 rpi_rate = 0.03 idx_cover_cap = 0.1 idx_prem_mult = 1.5 idx_prem_cap = 0.15 review_period_mths = 60 review_prem_shock = 0.0 review_lapse_slope = 2.0 review_lapse_threshold = 0.05 review_lapse_cap = 0.3 sel_lapse_ci_loading = 0.1 claim_split_tol = 0.0001 pd = ("Module", "pandas")