Source code for frlib.products.dependance.Dep_FR_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:`~.Dep_FR_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 = 9            # or switch the default

``t`` counts **policy months**, 0-based, exactly as the technical notes index them:
``t = 0`` is the first policy month and ``t = proj_len() - 1`` the last, so
``result_cf()`` runs ``0 ... proj_len() - 1``. Cover is *viagère* with no age limit, so
what ends the projection is a **[std]** terminal age of 110 rather than the contract:
``proj_len() = 12 (terminal_age - entry_age)`` is the **number of projected months**,
480 on the base cell. The state ledgers are indexed at the **start** of the month, so
every cash flow on a ``result_cf()`` row is 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/dependance/``, 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
``Dep_FR_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:`~.Dep_FR_S.Data`, reached here through the ``data`` Reference:

========================  ==================================  ==========================
Reference                 Cells                               File
========================  ==================================  ==========================
model_point_file          data.model_point_table()            model_point_table.csv
mort_table_file           data.mort_table()                   mort_table.csv
prevalence_file           data.prevalence_table()             prevalence_table.csv
severity_share_file       data.severity_share_table()         severity_share_table.csv
lapse_table_file          data.lapse_table()                  lapse_table.csv
cause_mix_file            data.cause_mix_table()              cause_mix_table.csv
reduction_file            data.reduction_table()              reduction_table.csv
revision_file             data.revision_table()               revision_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
=========================  ================================  ==========================
(none)                     model_point()                     The selected model point
entry_age                  age_at_entry()                    Entry age, différence de
                                                             millésimes
x = age(t)                 age(t)                            Attained age in month t
y(t)                       policy_year(t)                    Policy year, t // 12 + 1
(none)                     duration(t)                       Completed policy years
(none)                     duration_mth(t)                   Months elapsed, equal to t
(none)                     terminal_age                      110, what ends the run
proj_len                   proj_len()                        The number of projected
                                                             months, exclusive end of t
z                          (the duration argument)           Months since first
                                                             recognition
fr                         franchise_months()                Franchise, 3 months
(none)                     max_dur()                         Longest duration tracked
G(y)                       rente_total_pp(t)                 Guaranteed rente totale
rho                        partial_ratio()                   Partial / total ratio, 0.50
rho G(y)                   rente_partial_pp(t)               Guaranteed rente partielle
G_pay(t, z)                rente_pay_pp(t, z)                Rente totale in payment for
                                                             the cohort at duration z
rho G_pay(t, z)            rente_pay_partial_pp(t, z)        The same, partial ledger
CAP(y)                     capital_pp(t)                     Capital d'équipement
P(y)                       premium_mth_pp(t)                 Monthly premium
12 P(y)                    premium_pp(t)                     The same, annualised
(none)                     premium_factor(y)                 Its compound index
g_G                        reval_guarantee                   Revalorisation des garanties
g_S                        reval_rente                       Revalorisation des rentes
                                                             en service
r(y)                       revision_rate(t)                  Scheduled tariff revision
M_rev(y)                   revision_lapse_factor(t)          Premium-shock lapse module
cum_prem(t)                cum_prem_pp(t)                    Premiums paid per policy
c(n)                       reduction_coeff(n)                Barème coefficient
S(t)                       carence_factor(t)                 Share of causes covered
(none)                     carence_months(cause)             The three carence lengths
mu_H(x)                    mort_force(t)                     Healthy force of mortality
mubar                      mort_force_avg(t)                 Population average force
(table)                    mort_rate(t)                      Healthy annual mortality
q_H(t)                     mort_rate_mth(t)                  The same, monthly
k_P, k_T                   mort_partial_mult,                State mortality multiples
                           mort_total_mult
(none)                     mort_rate_partial(t)              Partielle annual mortality
q_P(t)                     mort_rate_partial_mth(t)          The same, monthly
(none)                     mort_rate_total(t)                Totale annual mortality
q_T(t)                     mort_rate_total_mth(t)            The same, monthly
prev(x)                    prev_rate(t)                      APA prevalence
prev'(x)                   prev_slope(t)                     Its derivative in age
s_P, s_T                   severity_share(kind)              Public-to-insured shares
pi_P, pi_T                 prev_partial(t), prev_total(t)    Insured-state prevalence
i_P(x)                     inc_rate_partial(t)               Annual entry into partielle
i_Pm(t)                    inc_rate_partial_mth(t)           The same, monthly
i_T(x)                     inc_rate_total(t)                 Annual entry into totale
i_Tm(t)                    inc_rate_total_mth(t)             The same, monthly
i_A                        aggravation_rate                  Aggravation force
i_Am                       aggravation_rate_mth()            The same, monthly
(held at zero)             recovery_rate                     Return to autonomy
w(t)                       lapse_rate(t)                     Annual lapse rate
(table)                    lapse_rate_base(t)                Table rate before the shock
w(t) monthly               lapse_rate_mth(t)                 Monthly lapse rate
auto(t)                    pols_auto(t)                      Autonomous, premium-paying
red(t)                     pols_red(t)                       Paid-up on a reduced rente
(none)                     red_rente_pp(t)                   Its mean frozen rente
pols_part(t, z)            pols_part_dur(t, z)               In partielle at duration z
(sum over z)               pols_part(t)                      In partielle
pols_tot(t, z)             pols_tot_dur(t, z)                In totale at duration z
(sum over z)               pols_tot(t)                       In totale
pols_totr(t, z)            pols_totr_dur(t, z)               In totale on a reduced rente
(sum over z)               pols_totr(t)                      The same, total
(the four vectors)         dep_cohorts(t)                    The cohort ledgers as lists
pols_if(t)                 pols_if(t)                        Every ledger added
(none)                     pols_if_at(t, timing)             BEF_DECR / AFT_DECR
(none)                     pols_prem(t)                      Population paying premium
surv(t), base(t)           pols_surv(t), pols_base(t)        Autonomous survivors
n_P(t)                     pols_entry_partial(t)             Entrants into partielle
n_T(t)                     pols_entry_total(t)               Entrants into totale
n_Tr(t)                    pols_entry_total_red(t)           The same, from the reduced
n_A(t)                     pols_aggravation(t)               Aggravations partielle to
                                                             totale
(none)                     pols_aggravation_recog(t)         Those of them recognised
(none)                     pols_recognition(t)               First recognitions
(none)                     pols_death(t)                     Deaths, all five ledgers
lapse(t)                   pols_lapse(t)                     Lapses out of autonomy
(none)                     pols_lapse_exit(t)                Those that leave outright
(none)                     pols_reduction(t)                 Those that become paid-up
carence_exit(t)            pols_carence_exit(t)              Memberships terminated by
                                                             the carence
(none)                     pols_recovery(t)                  Returns to autonomy; zero
P x auto(t)                premiums(t)                       Premium income
claims_rente(t)            claims(t, "RENTE")                Rente outgo
claims_capital(t)          claims(t, "CAPITAL")              Capital d'équipement outgo
0                          claims(t, "LAPSE")                Surrender outgo; always zero
(none)                     instalments(t)                    Rente instalments paid
refunds_carence(t)         refunds_carence(t)                Premiums returned
e(y), a(y)                 expenses(t)                       Maintenance and assistance
ec_adj, ec_ren             claim_expenses(t)                 Adjudication and handling
(none)                     inflation_factor(t)               Expense inflation factor
net_cf(t)                  net_cf(t)                         Net cash flow, income
                                                             positive
(the calibration)          sojourn_total(x0)                 Expected sojourn in totale
(the calibration)          sojourn_partial(x0)               Expected sojourn in
                                                             partielle
=========================  ================================  ==========================

Four names needed care.

The notes write one symbol ``q`` with three subscripts for the three mortality rates.
:func:`mort_rate` is the **healthy-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 two dependent-state rates are :func:`mort_rate_partial`
and :func:`mort_rate_total`. Reading a dependent's mortality out of ``mort_rate`` is
precisely this product's largest available error, and the naming is there to prevent it.

``t`` is the policy month and ``z`` the months since **first** recognition. They are
different clocks and the model never mixes them: the *carence* takes ``t`` and the
*franchise* takes ``z``.

The notes call the whole paid-up ledger ``red`` and the amount it carries a frozen
``G(y) c(n)``. The ledger is :func:`pols_red` and the amount :func:`red_rente_pp`, a
probability-weighted mean over reduction cohorts. That is exact in expectation, because
incidence does not depend on the amount, and it is what the notes license an
implementation to do instead of carrying a per-reduction-cohort amount.

``carence_exit(t)`` is spelled :func:`pols_carence_exit`. It is a policy count and every
other policy count in the library starts ``pols_``; the cash flow it drives keeps the
notes' own name, :func:`refunds_carence`, because it is a **refund of premiums and not a
claim** and belongs on its own line.

.. rubric:: Five ledgers, and why the model needs all of them

The health chain is *autonome* → *dépendance partielle* / *dépendance totale* → *décès*,
with lapse as a further exit from autonomy, and a fifth in-force but paid-up ledger,
*réduite*, reached only by lapse from eight full years of premiums::

                     +------------- i_T -----------------+
                     |                                   v
    autonome ---- i_P + ---> partielle ---- i_A ---> totale ----> deces
       |                         |                     |
       |                         v                     v
       |                       deces                 deces
       |
       +-- lapse before 8 years --> nothing at all
       |
       +-- lapse from 8 years ----> reduite --- i_T ---> totale (reduced rente)

Three absences are product facts, not gaps. There is **no account value and no surrender
value**, so no ``cv_pp`` exists and a lapse before eight years carries no cash flow at
all. There is **no death benefit** on this composite, so no ``claims_death`` exists.
And there is **no maturity**: the cover is *viagère*.

:func:`pols_red` is the one ledger a naive model omits, and omitting it is a first-order
error: lapse from year 8 does **not** release the liability, it converts it into a
smaller one that keeps running for life. On the base cell that ledger peaks at 8.27% of
the original policy at month 194, attained age 86 — the largest state in the model after
:func:`pols_auto` at that duration — and dropping it understates lifetime claims by
4.57%.

Recovery out of a covered state is a **named input held at zero**, not an omission:
``recovery_rate`` is wired into the ledger roll and into :func:`pols_recovery`, and the
base run sets it to zero, as the only retrieved actuarial reference on this product
does. The direction of error is one-sided — claims are overstated — and no retrieved
source quantifies it.

.. rubric:: The two dependent ledgers are two-dimensional

A cohort must be indexed by the months since first recognition, ``z``, for two reasons
that have nothing to do with each other. The **franchise** drops the first three
instalments, so a cohort is paid only from ``z >= franchise_months() + 1``. And the
*rente* in payment is the guarantee of the policy year in which the cohort was
recognised, indexed forward at ``reval_rente`` — a different rate from the one that
indexes the guarantee before claim — so the amount depends on the cohort's vintage,
which is what ``z`` records.

:func:`dep_cohorts` holds all four vectors for one month and is the model's only
list-valued cells. The alternative — four two-argument recursions — would be
``4 proj_len() max_dur()`` separate cells, nearly a million on the base cell, each
with its own cache entry. Keeping them in one cells per month makes it ``proj_len()``
cells with a loop inside, and :func:`pols_part_dur` and its siblings read elements out of
it so that the notes' two-dimensional objects are still addressable by name. The lists
are rebuilt rather than mutated on each step, so a caller cannot corrupt the cache by
holding one.

The fourth vector is the *value* ledger of the reduced-rente claims: element ``z - 1`` is
the population at duration ``z`` **times the reduced rente it is being paid**. It exists
because those amounts are frozen individually at each reduction date, not derivable from
the policy year the way the other two ledgers' amounts are.

.. rubric:: The carence and the franchise are different things

The *carence* runs from **inception**, is cause-specific, blocks the benefit **and
terminates the membership with a full refund of premiums**. The *franchise* runs from
**recognition**, is three months, and only delays payment. They are the two easiest
things in this product to apply in each other's place, and they cost different amounts:
removing the *carence* raises lifetime claims by 3.99% and removing the *franchise* by
7.09%.

:func:`carence_factor` is the share of causes already covered at month ``t``, read from
the **[std]** cause mix against the model point's own three *carence* lengths: 0.10 in
policy year 1, 0.65 in years 2 and 3, 1.00 thereafter on the base cell. Note what it
does **not** touch: :func:`pols_auto` at ``t + 1`` does not depend on it, because a
*carence* claim ends the membership rather than deferring it. The blocked lives leave
the in-force ledger exactly as the covered ones do, and they take a refund of every
premium paid with them — in policy year 1 of the base cell that refund is three quarters
of the year's *rente* and *capital* claims combined.

The *franchise* is **not a premium holiday**. *Exonération* runs from recognition, so a
life inside the three-month *franchise* pays no premium and receives no *rente*.

.. rubric:: State-dependent mortality is the largest lever on this product

A dependent life's mortality is far heavier than a healthy life's at the same age.
:func:`mort_rate_partial` and :func:`mort_rate_total` apply proportional hazards on the
force, ``mort_partial_mult`` 1.75 and ``mort_total_mult`` 4.27, so the annual rates at
attained age 85 are 0.06179, 0.10562 and 0.23841. Applying healthy mortality to
dependent lives while leaving the incidence basis unchanged raises lifetime claims by
**159.7%**.

``mort_total_mult`` is **calibrated, not guessed**: :func:`sojourn_total` returns 2.9989
years from exact age 84 at 4.27, against the mean duration of about three years the CCSF
reports for heavy dependents. At 2.75 the same calculation gives 4.19 years and at 3.50,
3.50 — the sojourn is far more sensitive to the multiple than a first look suggests.
``mort_partial_mult`` has **no such anchor**: it must exceed 1 and sit well below
``mort_total_mult``, and at 1.75 :func:`sojourn_partial` gives 3.14 years from age 82,
the same order as the 29.2-month mean duration of APA receipt across all GIRs.

.. rubric:: Prevalence is not incidence, and the identity that converts them

Every public French number about dependence measures **receipt of the *allocation
personnalisée d'autonomie***. It is a **prevalence**, not an incidence, and it is a
**public** classification rather than the insurer's. Both conversions are explicit steps
here.

:func:`severity_share` is the first: the fractions of APA prevalence read as insured
*partielle* and *totale*, keyed by the contract's trigger grid, **[std]** against two
indirect anchors. :func:`inc_rate_partial` and :func:`inc_rate_total` are the second, and
they are an **identity** rather than an approximation — differentiating the state
proportions along the age axis gives entry forces in terms of the prevalence slope, the
aggravation force and all three mortality forces.

Three properties of that identity an implementation must respect, and this one does. The
mortality terms are **not refinements**: a rising prevalence understates incidence
because the dependent population is simultaneously being drained by its own excess
mortality. ``aggravation_rate`` and :func:`inc_rate_total` are **not independent
inputs**: raising the aggravation force lowers the direct-to-*totale* incidence, because
the stock of *totale* lives is pinned by the assumed prevalence — consistently varying
the rate from 0 to 0.20 to 0.40 moves lifetime claims by only +0.54% / 0 / -0.52%, while
adding it *without* re-deriving the incidence raises them 0.84% and puts the lives in the
wrong state. And :func:`inc_rate_partial` **can go negative at extreme ages**, where the
prevalence slope flattens while excess mortality does not; both rates are floored at
zero **[std]**, which never binds on the female base cell — :func:`inc_rate_partial` is
still 0.0040 at attained age 109 — and binds at attained age 109 on the male basis.

.. rubric:: Two indexations, two ledgers

``reval_guarantee`` moves the guarantee and the premium in the same proportion.
``reval_rente`` moves every *rente* in payment, whatever its vintage. The reduced
guarantee moves with **neither**. Collapsing the two rates into one happens to work only
when they are equal, and the base configuration deliberately sets them different — 1.0%
against 1.5% — so that a test can tell.

.. rubric:: Premium income rides on pols_auto, never on pols_if

Lives in a recognised state are exonerated and reduced lives are paid up, so neither band
pays anything. :func:`pols_prem` is the premium-paying population and it is
:func:`pols_auto` on every model point except a ``total_only`` one, where the *partielle*
ledger is not a recognised state and therefore keeps paying. Charging premium to the
whole in-force block overstates income by the whole reduced ledger plus the whole claim
ledger: on the base cell, at attained age 90 those two bands together are 44.6% of the
in-force block.

.. rubric:: What cover_type does, and the one thing it standardizes

``cover_type = total_and_partial`` is the composite the notes specify and the basis of
the worked example. ``cover_type = total_only`` buys the *rente totale* alone, and the
model reads that as: *partielle* is **not a recognised state**, so it pays no *rente*, it
carries no *capital*, it does not exonerate the premium — and the *carence* and the
*franchise* both attach at entry into *totale*, whether direct or by aggravation, since
that is then the first recognition. The health chain is untouched, which is what keeps
the prevalence identity intact.

One consequence is a **[std]** departure worth naming: on a ``total_only`` cell an
aggravating life starts a **fresh** duration cohort and therefore serves a *franchise*,
where on a ``total_and_partial`` cell the duration index runs from first recognition and
deterioration does not restart it. Both readings follow from the same principle — the
clock runs from first recognition of a **covered** state — and no retrieved document
addresses either directly.

.. rubric:: The capital d'équipement is paid once per membership

It is paid on **first entry into a covered state**, never twice, and a reduced membership
has lost the option. So it rides on :func:`pols_recognition` less the entrants out of the
reduced ledger, and an aggravation produces no *capital* on a ``total_and_partial`` cell.
Paying it again on aggravation would inflate capital claims by the whole aggravation
flow.
"""

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 policy_id(): """The policy identifier of the selected model point.""" return str(model_point()["policy_id"])
[docs] def age_at_entry(): """The entry age of the selected model point, *différence de millésimes*. Sourced band 40 to 75 inclusive at signature. On an in-force cell — ``status`` of ``partial``, ``total`` or ``reduced`` — 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 membership, so the two indexations step at ``t = 12, 24, ...`` from the valuation date rather than from the contractual anniversary. """ return int(model_point()["entry_age"])
[docs] def sex(): """The sex (M / F) of the selected model point. The **decrements** are sex-split — both the mortality proxy and the prevalence logistic have a row per sex — while the **premium is unisex**, compulsory since the 2004 EU directive, so ``premium_mth`` is a model point column and not a rate looked up by sex. """ v = model_point()["sex"] if v not in ("M", "F"): raise ValueError("invalid sex") return v
[docs] def cover_type(): """``total_and_partial`` or ``total_only``: which states the contract covers. The composite of the technical notes is ``total_and_partial`` and it is what the worked example runs. See the Space docstring for what ``total_only`` changes and for the one **[std]** departure it forces on the duration clock. """ v = model_point()["cover_type"] if v not in ("total_and_partial", "total_only"): raise ValueError("invalid cover_type") return v
[docs] def cover_partial(): """True when *dépendance partielle* is a covered, recognised state. The single switch the rest of the model consults, so that the four consequences of ``cover_type`` — the *rente*, the *capital*, the premium *exonération* and where the *carence* and the *franchise* attach — cannot drift apart. """ return cover_type() == "total_and_partial"
[docs] def trigger_grid(): """``avq5``, ``avq6`` or ``aggir``: the grid the contract triggers on. Three alternative *definitions of the same two states*, and the reference model has to price all three because its decrement basis is built from public GIR-graded APA data. The grid enters through :func:`severity_share` and nowhere else: a stricter grid is a smaller share of public prevalence, not a different chain. """ v = model_point()["trigger_grid"] if v not in ("avq5", "avq6", "aggir"): raise ValueError("invalid trigger_grid") return v
[docs] def rente_total_mth(): """G(1): the guaranteed *rente totale* at issue, EUR a month. 1,000 on the base cell, a **[std]** pick inside the sourced 500-3,000 band chosen because it is the cover for which the only age-graded French price point exists. On an in-force dependent cell it is the amount **in payment at the valuation date**, and on a ``reduced`` cell it is the **full guarantee** before the *barème* coefficient is applied. """ return float(model_point()["rente_total_mth"])
[docs] def partial_ratio(): """rho: the *rente partielle* as a fraction of the *rente totale*. 0.50, modal across five retrieved providers. The two *rentes* are mutually exclusive: recognition of *totale* never opens partial rights. """ return float(model_point()["partial_ratio"])
[docs] def partial_ratio_paid(): """The ratio actually paid: :func:`partial_ratio`, or zero on a ``total_only`` cell. Kept separate from :func:`partial_ratio` so that the model point still records the contractual ratio on a cell that does not buy the *rente partielle*. """ return partial_ratio() if cover_partial() else 0.0
[docs] def capital_option(): """Whether the optional *capital d'équipement* is bought.""" return bool(model_point()["capital_option"])
[docs] def capital_amount(): """CAP(1): the *capital d'équipement* at issue, EUR, 3,500 on the base cell. Observed amounts run from 3,000 to 10,000 across the retrieved contracts; the base cell's 3,500 is the AXA figure. Paid **once** per membership on first entry into a covered state, with no *franchise* **[std]**, and the guarantee is extinguished on payment regardless of later deterioration. """ return float(model_point()["capital_amount"]) if capital_option() else 0.0
[docs] def premium_mth(): """P(1): the monthly premium at issue, EUR, an **input and not a computed quantity**. No French insurer publishes a general individual LTC rate table. The base cell's 75 EUR a month is the CCSF's 2013 indicative price for exactly this cover at entry age 70 — dated and indicative, and used because inventing a premium would be worse. The premium is *viagère*: level for the entry age but payable for life, with no premium-paying term. """ return float(model_point()["premium_mth"])
[docs] def premium_mode(): """``monthly``, ``quarterly``, ``half_yearly`` or ``annual``; the base cell is monthly. Payment is always **in advance**. No fractional-payment loading is applied **[std]**: no retrieved document discloses one, so an annual payer pays exactly twelve monthly premiums at the start of the policy year. """ v = model_point()["premium_mode"] if v not in ("monthly", "quarterly", "half_yearly", "annual"): raise ValueError("invalid premium_mode") return v
[docs] def premium_months(): """The number of months one premium instalment covers: 1, 3, 6 or 12.""" return {"monthly": 1, "quarterly": 3, "half_yearly": 6, "annual": 12}[ premium_mode()]
[docs] def premium_due(t): """True when a premium instalment falls due at the start of month t. Instalments fall on months 0, ``premium_months()``, ``2 premium_months()``, ... of the projection, so an annual payer pays at each policy anniversary. """ return t % premium_months() == 0
[docs] def couple_discount(): """Whether the 10% *réduction couple* applies; off on the base cell. Real and common — both spouses joining within three months, lost if either membership is resiliated or reduced — but a rating adjustment with no cash-flow mechanics beyond scaling the premium, conditional on facts about a second life the model point does not carry. """ return bool(model_point()["couple_discount"])
[docs] def couple_factor(): """The premium multiplier the couple discount applies, 0.90 or 1.00.""" return 1.0 - couple_discount_rate if couple_discount() else 1.0 # noqa: F821
[docs] def carence_accident_months(): """The *carence* for accident, 0 months on every retrieved contract.""" return int(model_point()["carence_accident_months"])
[docs] def carence_illness_months(): """The *carence* for illness other than neurological or psychiatric, 12 months.""" return int(model_point()["carence_illness_months"])
[docs] def carence_neuro_months(): """The *carence* for neurological, neurodegenerative or psychiatric illness. 36 months, the longest of the three, on every retrieved contract — which is what an insurer does when a cause is both frequent and adversely selected, and every retrieved contract puts an MMSE overlay on exactly that cause. """ return int(model_point()["carence_neuro_months"])
[docs] def carence_months(cause): """The *carence* length in months for one of the three causes. ``accident``, ``illness`` and ``neuro``, the three keys of ``cause_mix_table.csv``. A membership on which dependence arises from a cause still inside its *carence* is **terminated and every premium refunded**; the benefit is not merely deferred. """ if cause == "accident": return carence_accident_months() if cause == "illness": return carence_illness_months() if cause == "neuro": return carence_neuro_months() raise ValueError("invalid cause")
[docs] def franchise_months(): """fr: the *franchise* in months, 3 on the base cell. Absolute and measured from recognition, so a cohort is paid from ``z >= fr + 1``: the cohort recognised at the end of month ``s`` is first paid at the end of month ``s + 4``. This is a **[std]** monthly reading of "le 91e jour", corroborated rather than assumed — one retrieved contract restores exactly three instalments at the first payment. """ return int(model_point()["franchise_months"])
[docs] def reduction_qualifying_years(): """The full consecutive years of premiums that qualify for *mise en réduction*. Eight on the base cell; the observed range is five to eight across the retrieved contracts. Below it a lapsed membership ends with no value at all. """ return int(model_point()["reduction_qualifying_years"])
[docs] def status(): """Which ledger the population starts in at ``t = 0``. ``autonomous`` the whole population is autonomous and premium-paying. ``partial`` / ``total`` a claim already in payment, seeded into the corresponding dependent ledger at :func:`claim_duration_months`. ``reduced`` a paid-up membership on a reduced *rente totale*, seeded with :func:`years_paid` years of premiums behind it. An in-force portfolio needs all four kinds of cell. """ v = model_point()["status"] if v not in ("autonomous", "partial", "total", "reduced"): raise ValueError("invalid status") return v
[docs] def claim_duration_months(): """z0: the months since recognition already elapsed on an in-force claim cell. The seeded population enters cohort ``z0 + 1``, since cohort 1 is a state recognised at the end of the month before the valuation date. A cell seeded at ``z0 = franchise_months()`` is therefore paid in its very first month. """ return int(model_point()["claim_duration_months"])
[docs] def years_paid(): """n: the completed years of premiums behind a ``reduced`` cell. It selects the *barème* coefficient the frozen reduced *rente* carries, and it is ignored on every other kind of cell. """ return int(model_point()["years_paid"])
[docs] def pols_if_init(): """Initial number of policies; 1.0 on a single-policy model point.""" return float(model_point()["pols_if_init"])
# --- the policy clock ---
[docs] def proj_len(): """The number of projected months: ``12 (terminal_age - age_at_entry())``. 480 on the base cell, and it is the **exclusive end** of the frame: the projection runs ``t = 0 ... proj_len() - 1`` and ``result_cf()`` has ``proj_len()`` rows, which is lifelib's ``range(proj_len())``. Cover is *viagère* with no age limit, so **what ends the projection is the terminal age of the decrement basis, not the contract** — there is no maturity, no expiry and no maturity benefit. ``terminal_age`` is 110 **[std]**, above which the mortality table forces the rate to 1. """ return 12 * (terminal_age - age_at_entry()) # noqa: F821
[docs] def duration(t): """Completed policy years at the start of month t: ``t // 12``. 0-based, as ``duration`` is throughout lifelib: 0 through the first policy year. """ return t // 12
[docs] def duration_mth(t): """Months elapsed from the start of the projection at the start of month t. Equal to ``t``, since ``t`` is 0-based. The cells exists so that the monthly models in this library share one vocabulary. """ return t
[docs] def policy_year(t): """y(t) = t // 12 + 1: the policy year containing month t; 1 for t = 0..11.""" return duration(t) + 1
[docs] def age(t): """x: the attained age in the policy year containing month t. ``age_at_entry() + t // 12``, advancing at each policy anniversary **[std]** — the entry age is by *différence de millésimes* and the model advances it annually rather than on a birthday it does not carry. """ return age_at_entry() + duration(t)
[docs] def max_dur(): """The longest claim duration the cohort vectors have to carry. ``proj_len() + claim_duration_months() + 1``, 481 on the base cell: a cohort seeded at duration ``z0 + 1`` reaches ``z0 + proj_len()`` in the last month ``t = proj_len() - 1``, and one recognised in month 0 reaches ``proj_len()``. The last element is therefore structurally zero, which is what makes the duration shift lossless. """ return proj_len() + claim_duration_months() + 1
[docs] def seed_dur(): """The duration cohort the initial population occupies, or 0 if it is not seeded.""" return claim_duration_months() + 1 if status() in ("partial", "total") else 0
[docs] def cohort_len(t): """The number of duration cohorts that can be non-zero at the start of month t. The vectors are truncated to this length rather than carried at full :func:`max_dur` from month zero. It is purely a cost decision — a full-length vector in every month is ``proj_len() max_dur()`` floats where this is half that — and :func:`pols_part_dur` returns zero past the end of the list, so nothing about the two-dimensional view changes. """ return min(max_dur(), t + seed_dur() + 1)
# --- guarantees, premium and the two indexations ---
[docs] def rente_total_pp(t): """G(y): the guaranteed *rente totale* in the policy year containing month t. Indexed at ``reval_guarantee`` on each policy anniversary, **before** claim. Once a *rente* is in payment it leaves this cells behind and moves at ``reval_rente`` instead — see :func:`rente_pay_pp`. """ return rente_total_mth() * (1.0 + reval_guarantee) ** duration(t) # noqa: F821
[docs] def rente_partial_pp(t): """rho G(y): the guaranteed *rente partielle*; zero on a ``total_only`` cell.""" return partial_ratio_paid() * rente_total_pp(t)
[docs] def capital_pp(t): """CAP(y): the guaranteed *capital d'équipement* in the policy year containing t. Indexed at ``reval_guarantee`` alongside the *rente*, and zero when the option is not bought. """ return capital_amount() * (1.0 + reval_guarantee) ** duration(t) # noqa: F821
[docs] def rente_pay_pp(t, z): """G_pay(t, z): the *rente totale* in payment at month t for the cohort at duration z. A cohort recognised at the end of month ``t - z`` entered on the guarantee of its own policy year ``y_e`` and has been indexed at ``reval_rente`` at every anniversary since, so the amount is ``G(y_e) (1 + reval_rente)^(y - y_e)``. **Two indexations, two ledgers**: ``reval_guarantee`` set the amount at recognition and ``reval_rente`` has moved it ever since. Collapsing them into one rate happens to work only when they are equal, and the base configuration sets them different so that a test can tell. A cohort seeded at ``t = 0`` on an in-force claim cell reads as policy year 1, which is the same **[std]** restart of the policy-year clock that :func:`age_at_entry` describes. """ y_e = max(1, policy_year(t - z)) return (rente_total_mth() * (1.0 + reval_guarantee) ** (y_e - 1) # noqa: F821 * (1.0 + reval_rente) ** (policy_year(t) - y_e)) # noqa: F821
[docs] def rente_pay_partial_pp(t, z): """rho G_pay(t, z): the *rente partielle* in payment for the cohort at duration z.""" return partial_ratio_paid() * rente_pay_pp(t, z)
[docs] def revision_rate(t): """r(y): the scheduled tariff revision in the policy year containing month t. Applied to the premium **on top of** ``reval_guarantee``, and capped at 10% a year excluding *revalorisation* by the only retrieved contract that states a cap. Nil for five years then 1.5% a year on the shipped path, which is arbitrary inside that band: **a real tariff revision is a management action, not a projected assumption**, and it takes a deliberate substitution of ``revision_table.csv`` to project a repricing. """ tbl = data.revision_table() # noqa: F821 return float(tbl.loc[min(policy_year(t), int(tbl.index.max())), "revision_rate"])
[docs] def premium_factor(y): """The compound premium index in policy year y; 1.0 in policy year 1. ``(1 + reval_guarantee)(1 + r(y))`` per anniversary. The premium rises **in the same proportion** as the *revalorisation* of the guarantees, which is contractual, and the tariff revision multiplies on top of that, which is discretionary. """ if y <= 1: return 1.0 return (premium_factor(y - 1) * (1.0 + reval_guarantee) # noqa: F821 * (1.0 + revision_rate(12 * (y - 1))))
[docs] def premium_mth_pp(t): """P(y): the **monthly** premium in the policy year containing month t. ``premium_mth() x couple_factor() x premium_factor(y)``. An instalment of ``premium_months()`` of these falls due whenever :func:`premium_due` is true. The ``_mth_`` is load-bearing: library-wide ``premium_pp`` is the *annual* premium per policy and ``premium_mth_pp`` the monthly one. Every recursion in this model works in months and so uses this cells, not :func:`premium_pp`. """ return premium_mth() * couple_factor() * premium_factor(policy_year(t))
[docs] def premium_pp(t): """12 P(y): the **annual** premium per policy in the policy year containing month t. ``12 x premium_mth_pp(t)``. A reporting convenience, not a cash flow: this contract is projected monthly and the instalments actually falling due are :func:`premiums`, built on :func:`premium_mth_pp` and :func:`premium_months`. A quarterly or annual payer pays the same annual amount in fewer, larger instalments, there being no fractional-payment loading **[std]**. """ return 12.0 * premium_mth_pp(t)
[docs] def cum_prem_pp(t): """cum_prem(t): premiums paid **per policy** up to and including the start of month t. The *contre-assurance* base: a membership terminated by the *carence* has every premium it ever paid refunded, and this is what is refunded. It is a per-policy amount and not a population-weighted one, which is why :func:`refunds_carence` multiplies it by the terminating population rather than adding to it. The base case sits at ``t = 0``, the first projected month, and nothing is ever indexed below it: :func:`premium_due` is true at ``t = 0`` on every payment mode, so the first instalment always falls on the first row. """ if t <= 0: return premium_mth_pp(0) * premium_months() if t == 0 else 0.0 paid = premium_mth_pp(t) * premium_months() if premium_due(t) else 0.0 return cum_prem_pp(t - 1) + paid
[docs] def years_premiums_paid(t): """n: the completed years of premiums at the **end** of month t, ``(t + 1) // 12``. Eight of them at ``t = 95``, which is the first month a lapse becomes a *mise en réduction* rather than an exit with nothing. """ return (t + 1) // 12
[docs] def reduction_coeff(n): """c(n): the *barème* coefficient at n completed years of premiums. Zero below :func:`reduction_qualifying_years`, then the CNP Banque de France scale — 25% at eight years rising about two points a year and capped at 70% from thirty. Years beyond the table take its last row. """ if n < reduction_qualifying_years(): return 0.0 tbl = data.reduction_table() # noqa: F821 lo, hi = int(tbl.index.min()), int(tbl.index.max()) if n < lo: return 0.0 return float(tbl.loc[min(n, hi), "coefficient"])
[docs] def inflation_factor(t): """The expense inflation factor in month t: ``(1 + inflation_rate)^(y - 1)`` **[std]**. Steps on policy anniversaries, not monthly, which is how the notes write it. It applies to the maintenance and assistance levels and **not** to the two claim expenses, which are per-event amounts held flat **[std]**. """ return (1.0 + inflation_rate) ** duration(t) # noqa: F821
# --- the decrement basis ---
[docs] def mort_rate(t): """The **healthy-life** annual mortality rate at the attained age in month t. A **[std]** Gompertz proxy shaped like a French population table, read from ``mort_table.csv`` by sex and age; **not** TH 00-02 / TF 00-02 or TGH05 / TGF05. The two dependent states carry heavier rates on different cells — :func:`mort_rate_partial` and :func:`mort_rate_total` — and reading a dependent's mortality out of this cells is the largest single error available on this product. """ return float(data.mort_table().loc[(sex(), age(t)), "mort_rate"]) # noqa: F821
[docs] def mort_rate_mth(t): """q_H(t) = 1 - (1 - mort_rate)^(1/12): monthly healthy mortality **[std]**.""" return 1.0 - (1.0 - mort_rate(t)) ** (1.0 / 12.0)
[docs] def mort_force(t): """mu_H(x) = -ln(1 - mort_rate): the healthy force of mortality in month t. The identity behind :func:`inc_rate_partial` is written on forces, not on annual probabilities, and the two state multiples are proportional hazards **on this force** — which is what makes ``1 - (1 - q)^(k/12)`` the right monthly conversion for a dependent life and ``k q`` the wrong one. """ q = mort_rate(t) return -math.log(1.0 - q) if q < 1.0 else float("inf") # noqa: F821
[docs] def mort_rate_partial(t): """The annual mortality of a life in *dépendance partielle* at the attained age. ``1 - (1 - mort_rate)^k`` with ``k = mort_partial_mult``, a proportional hazard on the force **[std]**. 0.10562 at age 85 against 0.06179 healthy. ``k`` has **no anchor**: it must exceed 1, because GIR 3-4 lives carry excess mortality, and sit well below ``mort_total_mult``; at 1.75 the expected sojourn in *partielle* entered at age 82 is 3.14 years, the same order as the 29.2-month mean duration of APA receipt across all GIRs. No impaired-life table for either French dependence state exists in any retrieved source. """ return 1.0 - (1.0 - mort_rate(t)) ** mort_partial_mult # noqa: F821
[docs] def mort_rate_partial_mth(t): """q_P(t) = 1 - (1 - mort_rate)^(k_P/12): monthly *partielle* mortality **[std]**.""" return 1.0 - (1.0 - mort_rate(t)) ** (mort_partial_mult / 12.0) # noqa: F821
[docs] def mort_rate_total(t): """The annual mortality of a life in *dépendance totale* at the attained age. ``1 - (1 - mort_rate)^k`` with ``k = mort_total_mult`` **[std]**. 0.23841 at age 85 against 0.06179 healthy, and 0.216 at 84 against 0.055 — the gap that makes flat state mortality this product's largest available error. ``k`` is **calibrated**: see :func:`sojourn_total`. """ return 1.0 - (1.0 - mort_rate(t)) ** mort_total_mult # noqa: F821
[docs] def mort_rate_total_mth(t): """q_T(t) = 1 - (1 - mort_rate)^(k_T/12): monthly *totale* mortality **[std]**.""" return 1.0 - (1.0 - mort_rate(t)) ** (mort_total_mult / 12.0) # noqa: F821
[docs] def mort_force_at(x): """mu_H at an **exact**, possibly fractional, age x. Log-linear in age between the integer ages of ``mort_table.csv``, which reproduces the shipped Gompertz force exactly, since a Gompertz force is exponential in age. Only the two sojourn calibrations use it: the projection itself reads the force at integer attained ages through :func:`mort_force`. """ tbl = data.mort_table() # noqa: F821 lo = int(math.floor(x)) # noqa: F821 hi = lo + 1 ages = [a for s, a in tbl.index if s == sex()] top = max(ages) if lo >= top: return float("inf") q_lo = float(tbl.loc[(sex(), lo), "mort_rate"]) q_hi = float(tbl.loc[(sex(), min(hi, top)), "mort_rate"]) if q_lo >= 1.0 or q_hi >= 1.0: return float("inf") m_lo = -math.log(1.0 - q_lo) # noqa: F821 m_hi = -math.log(1.0 - q_hi) # noqa: F821 return m_lo * (m_hi / m_lo) ** (x - lo)
[docs] def lapse_rate_base(t): """The table annual lapse rate in month t **[std]**, before the premium shock. 8 / 6 / 5 / 4 / 3 percent by policy band. No French LTC persistency study is public; the table's only anchor is that the individual book fell 9.9% in 2024 on 28,400 new subscribers, so gross exits — deaths, claim entries and lapses together — ran at roughly 11% of the opening portfolio, and a 3-8% lapse table leaves the balance for mortality and incidence. Policy years beyond the table take its last row. """ tbl = data.lapse_table() # noqa: F821 return float(tbl.loc[min(policy_year(t), int(tbl.index.max())), "lapse_rate"])
[docs] def revision_lapse_factor(t): """M_rev(y): the premium-shock lapse multiplier **[std]**; 1.0 in the base run. ``1 + revision_lapse_slope x max(0, r(y) - revision_lapse_threshold)``. The member may refuse a tariff revision by resiliating within two months of notification, with a possible *mise en réduction* at the same date, so a revision at the 10% cap gives 1.24. It is off in the base run because the shipped revision path never exceeds 1.5%, and it is the only place a projected repricing feeds back into the block. """ return 1.0 + revision_lapse_slope * max( # noqa: F821 0.0, revision_rate(t) - revision_lapse_threshold) # noqa: F821
[docs] def lapse_rate(t): """w(t): the **annual** lapse rate out of the autonomous ledger in month t. Applied to :func:`pols_auto` **only**. A recognised life pays no premium and a reduced membership pays none either, so neither can lapse for non-payment, and with no surrender value there is nothing to surrender for — a lapse here is genuinely a decision to walk away from everything. """ return min(1.0, lapse_rate_base(t) * revision_lapse_factor(t))
[docs] def lapse_rate_mth(t): """w(t) monthly = 1 - (1 - lapse_rate)^(1/12) **[std]**.""" return 1.0 - (1.0 - lapse_rate(t)) ** (1.0 / 12.0)
[docs] def aggravation_rate_mth(): """i_Am = 1 - exp(-i_A/12): the monthly aggravation probability **[std]**. Flat in age, and **not an independent input**: the prevalence identity ties it to :func:`inc_rate_total`, so raising it lowers the direct-to-*totale* incidence. There is no public transition law — the only retrieved actuarial reference models no such transition at all and prices two separate guarantees instead — and the contracts themselves do provide for deterioration, so this model carries it and states the cost of the missing law. """ return 1.0 - math.exp(-aggravation_rate / 12.0) # noqa: F821
[docs] def recovery_rate_mth(): """The monthly probability of returning to autonomy; **zero in the base run**. Contractually the *rente* stops on improvement out of a covered state, and one retrieved *notice* lets the level move in either direction. The only retrieved actuarial reference nonetheless sets the probability of return to autonomy to zero, and so does this model — as a **named input held at zero**, wired into the ledger roll and into :func:`pols_recovery`, not as an omission. Its direction of error is one-sided: claims are overstated, by an amount no retrieved source quantifies. """ return 1.0 - math.exp(-recovery_rate / 12.0) # noqa: F821
[docs] def carence_factor(t): """S(t): the share of causes whose *carence* has already expired at month t. Read from the **[std]** cause mix against the model point's own three *carence* lengths: 0.10 in policy year 1, 0.65 in policy years 2 and 3, 1.00 thereafter on the base cell — the ``S1 <= S2 <= S3 <= S4 = 100%`` shape the actuarial reference asks for. What it multiplies is the **claim**, not the decrement: a life whose dependence arises from a cause still inside its *carence* leaves the in-force ledger exactly as a covered one does, and takes :func:`refunds_carence` with it. """ tbl = data.cause_mix_table() # noqa: F821 return float(sum(tbl.loc[c, "share"] for c in tbl.index if carence_months(c) <= t))
[docs] def aggravation_carence(t): """The share of aggravations recognised at month t. 1.0 on a ``total_and_partial`` cell, where the *carence* was already applied at first recognition into *partielle*. On a ``total_only`` cell the aggravation **is** the first recognition of a covered state, so it carries the *carence* itself. """ return 1.0 if cover_partial() else carence_factor(t)
# --- prevalence and the incidence identity ---
[docs] def prev_param(name): """One parameter of the APA-prevalence logistic for this model point's sex. ``prev_ceil``, ``prev_beta`` or ``prev_x_mid``. The two slope parameters are pinned to sourced DREES rates; ``prev_ceil`` is **[std]**, unidentified by a two-anchor fit, and it governs the tail — where 65% of this product's lifetime claims fall. """ return float(data.prevalence_table().loc[(sex(), name), "value"]) # noqa: F821
[docs] def prev_rate(t): """prev(x): APA prevalence at the attained age in month t. ``prev_ceil / (1 + exp(-beta (x - x_mid)))``. This is a **prevalence of receipt of a public allowance**, not an incidence and not the insurer's definition of dependence. Multiplying it by a *rente* amount as though it were an annual claim frequency is the error that dominates this product; :func:`severity_share` and :func:`inc_rate_partial` are the two explicit steps that stand between them. APA is not available below age 60, so the curve has **no anchor at all** under 60 and every entry age below 60 runs on pure extrapolation. """ return prev_param("prev_ceil") / ( 1.0 + math.exp(-prev_param("prev_beta") # noqa: F821 * (age(t) - prev_param("prev_x_mid"))))
[docs] def prev_slope(t): """prev'(x) = beta prev (1 - prev / prev_ceil): the prevalence slope in age. A **rate per year**, which is why it can be added to a prevalence times a force of mortality in the identity below. The dimensional check this enforces is the one that catches the product's dominant error. """ p = prev_rate(t) return prev_param("prev_beta") * p * (1.0 - p / prev_param("prev_ceil"))
[docs] def severity_share(kind): """s_P or s_T: the share of APA prevalence read as one insured state. ``"partial"`` or ``"total"``, keyed by the contract's :func:`trigger_grid`. Public prevalence is APA take-up on GIR 1-4 and insurer definitions are deliberately stricter — the *notice* says the insurer is not bound by the decisions of the public services. Two sourced anchors bound the haircut on the base grid and neither pins it: the GIR 1-2 share of APA beneficiaries, 34.9%, and the market's own count of *rentes* in payment against lives covered, about 0.44 against the shipped 0.45. Holding the shares constant across ages is a standardization with a known direction of error: severity mix worsens with age, so the model **understates** *totale* prevalence at old ages and overstates it at young ones. """ if kind not in ("partial", "total"): raise ValueError("invalid kind") return float(data.severity_share_table().loc[ # noqa: F821 trigger_grid(), "share_" + kind])
[docs] def prev_partial(t): """pi_P = s_P prev(x): the proportion of living lives in insured *partielle*.""" return severity_share("partial") * prev_rate(t)
[docs] def prev_total(t): """pi_T = s_T prev(x): the proportion of living lives in insured *totale*.""" return severity_share("total") * prev_rate(t)
[docs] def mort_force_avg(t): """mubar: the mortality force averaged over the three living states. ``mu_H pi_H + mu_P pi_P + mu_T pi_T``. It appears in the incidence identity because the state proportions are proportions of a **living** population, which is itself being drained at this rate. """ pi_p, pi_t = prev_partial(t), prev_total(t) mu = mort_force(t) return mu * ((1.0 - pi_p - pi_t) + mort_partial_mult * pi_p # noqa: F821 + mort_total_mult * pi_t) # noqa: F821
[docs] def inc_rate_partial(t): """i_P(x): the annual force of entry into *dépendance partielle* from autonomy. Derived, not assumed. Differentiating the state proportions along the age axis gives the **identity** ``i_P = [pi_P' + (i_A + mu_P) pi_P - pi_P mubar] / pi_H`` with ``pi_P' = s_P beta prev (1 - prev / prev_ceil)``. The mortality terms are not refinements: dropping them understates incidence, because a rising prevalence is being fed against a dependent population that is simultaneously draining at its own excess mortality. Floored at zero **[std]** — the identity can go negative at extreme ages, where the prevalence slope flattens while excess mortality does not. The floor never binds on the female base cell — the rate is still 0.0040 at attained age 109 — and binds at attained age 109 on the male basis. """ pi_p, pi_t = prev_partial(t), prev_total(t) pi_h = 1.0 - pi_p - pi_t num = (severity_share("partial") * prev_slope(t) + (aggravation_rate + mort_partial_mult * mort_force(t)) # noqa: F821 * pi_p - pi_p * mort_force_avg(t)) return max(0.0, num / pi_h)
[docs] def inc_rate_partial_mth(t): """i_Pm(t) = 1 - exp(-i_P/12): the monthly entry probability into *partielle*.""" return 1.0 - math.exp(-inc_rate_partial(t) / 12.0) # noqa: F821
[docs] def inc_rate_total(t): """i_T(x): the annual force of entry into *dépendance totale* direct from autonomy. The second half of the same identity, ``i_T = [pi_T' - i_A pi_P + mu_T pi_T - pi_T mubar] / pi_H`` and the ``- i_A pi_P`` term is why ``aggravation_rate`` and this rate are **not independent inputs**: the stock of *totale* lives is pinned by the assumed prevalence, so aggravations arriving from *partielle* displace direct entries one for one. Adding an aggravation rate without re-deriving this one double-counts entries into *totale*. It overtakes :func:`inc_rate_partial` between ages 80 and 85 — the severity mix worsening with age, arriving through the mortality terms of the identity rather than through the constant severity shares, which cannot produce it. Floored at zero **[std]**. """ pi_p, pi_t = prev_partial(t), prev_total(t) pi_h = 1.0 - pi_p - pi_t num = (severity_share("total") * prev_slope(t) - aggravation_rate * pi_p # noqa: F821 + mort_total_mult * mort_force(t) * pi_t # noqa: F821 - pi_t * mort_force_avg(t)) return max(0.0, num / pi_h)
[docs] def inc_rate_total_mth(t): """i_Tm(t) = 1 - exp(-i_T/12): the monthly entry probability into *totale*.""" return 1.0 - math.exp(-inc_rate_total(t) / 12.0) # noqa: F821
# --- the five ledgers ---
[docs] def pols_auto(t): """auto(t): the autonomous, premium-paying population at the start of month t. ``pols_if_init()`` at ``t = 0`` on an ``autonomous`` cell and zero on every other kind, then survivors of mortality, of lapse and of incidence among the survivors, plus any returns to autonomy. Note what is **absent** from the recursion: :func:`carence_factor`. A *carence* claim terminates the membership rather than deferring it, so the blocked lives leave the in-force ledger exactly as the covered ones do and ``auto(t + 1)`` does not depend on ``S(t)`` at all. The guard lets the ledger answer **one month past the frame**, at ``t = proj_len()``, which is deliberate and not a leftover of a 1-based index: the population identity :func:`check_states` holds at the **start** of a month, and the last projected month, ``t = proj_len() - 1``, has one. :func:`pols_red` and :func:`red_rente_value` carry the same guard for the same reason. """ if t < 0 or t > proj_len(): return 0.0 if t == 0: return pols_if_init() if status() == "autonomous" else 0.0 base = pols_base(t - 1) stay = base * (1.0 - inc_rate_partial_mth(t - 1) - inc_rate_total_mth(t - 1)) return stay + pols_recovery(t - 1)
[docs] def pols_surv(t): """surv(t): autonomous lives surviving the month's mortality, before lapse.""" return pols_auto(t) * (1.0 - mort_rate_mth(t))
[docs] def pols_lapse(t): """lapse(t): lapses out of the autonomous ledger at the end of month t. Taken from the survivors of mortality. Pays **nothing**: there is no surrender value at any duration and the design is *fonds perdu*, so a lapse before the qualifying period destroys the whole accumulated value. From the qualifying period the same flow becomes :func:`pols_reduction` instead of an exit. """ return pols_surv(t) * lapse_rate_mth(t)
[docs] def pols_reduction(t): """The lapses of month t that become a *mise en réduction* rather than an exit. Zero until the membership has :func:`reduction_qualifying_years` full years of premiums behind it, and the whole of :func:`pols_lapse` thereafter. It is the **second decrement, not the absence of one**. """ if years_premiums_paid(t) < reduction_qualifying_years(): return 0.0 return pols_lapse(t)
[docs] def pols_lapse_exit(t): """The lapses of month t that leave the model outright, with no value at all.""" return pols_lapse(t) - pols_reduction(t)
[docs] def pols_base(t): """base(t): autonomous lives exposed to incidence, after mortality and lapse. The notes' order out of the autonomous state is **mortality, then lapse, then incidence among the survivors** **[std]**. """ return pols_surv(t) - pols_lapse(t)
[docs] def pols_entry_partial(t): """n_P(t): recognised entrants into *dépendance partielle* at the end of month t. ``base(t) i_Pm(t) S(t)``. On a ``total_only`` cell the *carence* does not enter here, because *partielle* is not a recognised state on that cell: the whole incidence flow moves into the ledger and the *carence* attaches later, at the aggravation that first recognises a covered state. """ s = carence_factor(t) if cover_partial() else 1.0 return pols_base(t) * inc_rate_partial_mth(t) * s
[docs] def pols_entry_total(t): """n_T(t): recognised entrants into *dépendance totale* direct from autonomy. ``base(t) i_Tm(t) S(t)``. """ return pols_base(t) * inc_rate_total_mth(t) * carence_factor(t)
[docs] def pols_entry_total_red(t): """n_Tr(t): entrants into *totale* out of the **reduced** ledger. The reduced cover is *dépendance totale* only, so there is no partial entry from it, and it carries **no *carence***: eight full years of premiums have been paid. These lives take the reduced *rente* they froze at the reduction date and **not** the *capital d'équipement*, which a reduced membership has lost. """ return pols_red(t) * (1.0 - mort_rate_mth(t)) * inc_rate_total_mth(t)
[docs] def pols_aggravation(t): """n_A(t): the gross flow *partielle* to *totale* at the end of month t. ``pols_part(t) (1 - q_P(t)) (1 - recovery) i_Am``, taken from the survivors of mortality and of recovery. On a ``total_and_partial`` cell every one of them is recognised already, and the cohort keeps its duration index so it does **not** serve a second *franchise*. """ return (pols_part(t) * (1.0 - mort_rate_partial_mth(t)) * (1.0 - recovery_rate_mth()) * aggravation_rate_mth())
[docs] def pols_aggravation_recog(t): """The aggravations of month t that are recognised, after the *carence*.""" return pols_aggravation(t) * aggravation_carence(t)
[docs] def pols_carence_exit(t): """carence_exit(t): memberships terminated because a *carence* was still running. A *carence* claim is a **decrement with a cash flow**, not a suppressed claim: modelling the *carence* as a multiplier on incidence alone leaves the terminated membership in force and omits the refund, and both errors run the same way — they overstate the liability at the front end and the premium income behind it. See :func:`refunds_carence` for the cash flow. """ blocked = 1.0 - carence_factor(t) out = pols_base(t) * inc_rate_total_mth(t) * blocked if cover_partial(): out += pols_base(t) * inc_rate_partial_mth(t) * blocked else: out += pols_aggravation(t) * blocked return out
[docs] def pols_recognition(t): """First recognitions of a covered state at the end of month t. ``n_P + n_T + n_Tr`` on a ``total_and_partial`` cell, and the recognised aggravations in place of ``n_P`` on a ``total_only`` one. It is what the claim adjudication expense rides on — a real, medically supervised process with a 45-working-day deadline and an arbitration route. """ out = pols_entry_total(t) + pols_entry_total_red(t) if cover_partial(): return out + pols_entry_partial(t) return out + pols_aggravation_recog(t)
[docs] def pols_capital_claims(t): """The recognitions of month t that carry the *capital d'équipement*. :func:`pols_recognition` less the entrants out of the reduced ledger, which have lost the option. It is paid **once per membership, not once per state**: a life that takes it on entering *partielle* takes nothing further on aggravating, which is why an aggravation appears here only on a ``total_only`` cell, where it is the first recognition. """ return pols_recognition(t) - pols_entry_total_red(t)
[docs] def pols_red(t): """red(t): the paid-up population on a reduced *rente totale* at the start of month t. No premium, *rente totale* only, no *capital*, no assistance and no further *revalorisation* of the guarantee. It is fed by :func:`pols_reduction` and drained by mortality and by entry into *totale*, and it never lapses, because there is no premium left to miss. **This is the ledger a naive model omits**, and omitting it turns every lapse from the qualifying period into a full release of liability. Answers one month past the frame, at ``t = proj_len()``, for the reason given under :func:`pols_auto`. """ if t < 0 or t > proj_len(): return 0.0 if t == 0: return pols_if_init() if status() == "reduced" else 0.0 prev = pols_red(t - 1) surv = prev * (1.0 - mort_rate_mth(t - 1)) return (surv - pols_entry_total_red(t - 1) + pols_reduction(t - 1) + pols_recovery_red(t - 1))
[docs] def red_rente_value(t): """The reduced ledger's population **times** the frozen *rente* it carries. Carried as a value rather than as a per-cohort amount: reductions happen in every month from the qualifying period and each freezes ``G(y) c(n)`` at its own date, so the ledger holds a distribution of amounts. Tracking the probability-weighted total is **exact in expectation**, because incidence does not depend on the amount, and it is what the notes license an implementation to do instead of carrying a per-reduction-cohort amount. The frozen amount is never revalued **before** claim; it becomes a *rente en service* and starts moving at ``reval_rente`` only once it is in payment, which happens on the fourth vector of :func:`dep_cohorts`. Answers one month past the frame, at ``t = proj_len()``, for the reason given under :func:`pols_auto`. """ if t < 0 or t > proj_len(): return 0.0 if t == 0: if status() != "reduced": return 0.0 return (pols_if_init() * rente_total_mth() * reduction_coeff(years_paid())) carried = (red_rente_value(t - 1) * (1.0 - mort_rate_mth(t - 1)) * (1.0 - inc_rate_total_mth(t - 1))) fresh = (pols_reduction(t - 1) * rente_total_pp(t - 1) * reduction_coeff(years_premiums_paid(t - 1))) return carried + fresh + red_value_recovered(t - 1)
[docs] def red_rente_pp(t): """The mean frozen reduced *rente* carried by the reduced ledger at month t. ``red_rente_value(t) / pols_red(t)``, and zero on an empty ledger. It is the amount a life entering *totale* out of the reduced ledger takes into payment. """ p = pols_red(t) return red_rente_value(t) / p if p > 0.0 else 0.0
[docs] def dep_cohorts(t): """The four dependent-ledger vectors at the start of month t, as lists. ``(partielle, totale, totale-on-a-reduced-rente, the value of that third ledger)``. Element ``z - 1`` of each is the state at duration ``z``, for ``z = 1 ... cohort_len(t)``; the fourth is a **population times amount** rather than a population, because the reduced *rentes* are frozen individually at each reduction date and cannot be recovered from the policy year the way the other amounts can. The model's only list-valued cells, and the reason is cost: four two-argument recursions would be ``4 proj_len() max_dur()`` separate cells — nearly a million on the base cell — where this is ``proj_len()`` cells with a loop inside. :func:`pols_part_dur` and its siblings read elements out of it, so the notes' two-dimensional objects are still addressable by name. At ``t = 0`` the vectors are the seeded state: all zeros on an ``autonomous`` or ``reduced`` cell, and ``pols_if_init()`` at cohort ``claim_duration_months() + 1`` on an in-force claim cell. Thereafter cohort 1 is the previous month's recognitions and every other cohort is the previous cohort survived one month, aggravated and — at an anniversary — revalued. A new list is built on each step rather than the previous one mutated, so holding a returned list cannot corrupt the cache. """ n = cohort_len(t) part = [0.0] * n tot = [0.0] * n totr = [0.0] * n val = [0.0] * n if t <= 0: z0 = seed_dur() if z0 and z0 <= n: if status() == "partial": part[z0 - 1] = pols_if_init() else: tot[z0 - 1] = pols_if_init() return part, tot, totr, val prev_p, prev_t, prev_tr, prev_v = dep_cohorts(t - 1) surv_p = (1.0 - mort_rate_partial_mth(t - 1)) * (1.0 - recovery_rate_mth()) surv_t = (1.0 - mort_rate_total_mth(t - 1)) * (1.0 - recovery_rate_mth()) ia = aggravation_rate_mth() recog = aggravation_carence(t - 1) restart = not cover_partial() fresh = 0.0 for z in range(1, min(len(prev_p), n - 1) + 1): ps = prev_p[z - 1] * surv_p na = ps * ia part[z] = ps - na carried = prev_t[z - 1] * surv_t if restart: tot[z] = carried fresh += na * recog else: tot[z] = carried + na * recog totr[z] = prev_tr[z - 1] * surv_t val[z] = prev_v[z - 1] * surv_t part[0] = pols_entry_partial(t - 1) tot[0] = pols_entry_total(t - 1) + fresh totr[0] = pols_entry_total_red(t - 1) val[0] = totr[0] * red_rente_pp(t - 1) if t % 12 == 0: g = 1.0 + reval_rente # noqa: F821 val = [v * g for v in val] return part, tot, totr, val
[docs] def pols_part_dur(t, z): """pols_part(t, z): the population in *partielle* at duration z at the start of t.""" v = dep_cohorts(t)[0] return v[z - 1] if 1 <= z <= len(v) else 0.0
[docs] def pols_tot_dur(t, z): """pols_tot(t, z): the population in *totale* at duration z at the start of t.""" v = dep_cohorts(t)[1] return v[z - 1] if 1 <= z <= len(v) else 0.0
[docs] def pols_totr_dur(t, z): """pols_totr(t, z): the reduced-rente *totale* population at duration z.""" v = dep_cohorts(t)[2] return v[z - 1] if 1 <= z <= len(v) else 0.0
[docs] def pols_part(t): """The whole population in *dépendance partielle* at the start of month t.""" return sum(dep_cohorts(t)[0])
[docs] def pols_tot(t): """The whole population in *dépendance totale* on a full *rente*, at the start of t.""" return sum(dep_cohorts(t)[1])
[docs] def pols_totr(t): """The whole population in *totale* on a **reduced** *rente*, at the start of t. A separate ledger from :func:`pols_tot` because these lives entered from :func:`pols_red` and carry a frozen reduced amount rather than the policy year's guarantee. """ return sum(dep_cohorts(t)[2])
[docs] def totr_rente_value(t): """The reduced-rente *totale* ledger's population times the amount it is paid.""" return sum(dep_cohorts(t)[3])
[docs] def pols_recovery(t): """Returns to autonomy out of the two full-cover dependent ledgers; zero in the base. Taken from the survivors of the month's mortality. See :func:`recovery_rate_mth` for why this is a named input held at zero rather than an omission. """ r = recovery_rate_mth() if r == 0.0: return 0.0 return (pols_part(t) * (1.0 - mort_rate_partial_mth(t)) + pols_tot(t) * (1.0 - mort_rate_total_mth(t))) * r
[docs] def pols_recovery_red(t): """Returns out of the reduced-rente *totale* ledger; zero in the base run. They go back to :func:`pols_red` and not to :func:`pols_auto`, because a paid-up membership that recovers is still paid up. """ r = recovery_rate_mth() if r == 0.0: return 0.0 return pols_totr(t) * (1.0 - mort_rate_total_mth(t)) * r
[docs] def red_value_recovered(t): """The frozen-*rente* value returning to the reduced ledger on recovery. Zero in the base run. A recovering life takes back the amount it was **being paid** rather than the amount it originally froze **[std]**: the value ledger does not carry the two separately, and the difference is immaterial while ``recovery_rate`` is zero. """ r = recovery_rate_mth() if r == 0.0: return 0.0 return totr_rente_value(t) * (1.0 - mort_rate_total_mth(t)) * r
[docs] def pols_if(t): """The number of policies in force at the start of month t: every ledger added. ``pols_auto + pols_red + pols_part + pols_tot + pols_totr``. It is the weight on maintenance expense and the count a reader of ``result_cf()`` reconciles the rest of the row against. It is **not** the weight on premium income, which is :func:`pols_prem`. """ return (pols_auto(t) + pols_red(t) + pols_part(t) + pols_tot(t) + pols_totr(t))
[docs] def pols_prem(t): """The population actually paying premium at the start of month t. :func:`pols_auto` on every cell whose *partielle* is a covered state, because a recognised life is exonerated and a reduced membership is paid up. On a ``total_only`` cell the *partielle* ledger is **not** recognised, so those lives keep paying and are added here. *Exonération* runs from **recognition**, not from the start of *rente* payment, so a life inside the three-month *franchise* pays no premium and receives no *rente*. Carrying the *franchise* the way an income-protection deferred period is carried — premium-paying, benefit-free — overstates premium income. """ if cover_partial(): return pols_auto(t) return pols_auto(t) + pols_part(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, outright lapses and the *carence* terminations have been taken. Equal to ``pols_if(t + 1)``. The intermediate points of the other models have no single-population meaning here, because five ledgers are moving at once; the ledgers themselves expose them. """ if timing == "BEF_DECR": return pols_if(t) if timing == "AFT_DECR": if t < 0 or t >= proj_len(): return 0.0 return pols_if(t + 1) raise ValueError("invalid timing")
[docs] def pols_death(t): """Deaths at the end of month t, from all five ledgers. Three different rates on the same clock: the healthy rate on the autonomous and reduced ledgers, ``q_P`` on *partielle*, ``q_T`` on both *totale* ledgers. It is exact even though the dependent ledgers are cohort-indexed, because the mortality rates do not depend on the duration. """ return ((pols_auto(t) + pols_red(t)) * mort_rate_mth(t) + pols_part(t) * mort_rate_partial_mth(t) + (pols_tot(t) + pols_totr(t)) * mort_rate_total_mth(t))
[docs] def pols_dead_cum(t): """Cumulative deaths from every ledger before the start of month t.""" if t <= 0: return 0.0 return pols_dead_cum(t - 1) + pols_death(t - 1)
[docs] def pols_lapse_cum(t): """Cumulative outright lapses before the start of month t. Lapses that became a *mise en réduction* are **not** here: they never left. """ if t <= 0: return 0.0 return pols_lapse_cum(t - 1) + pols_lapse_exit(t - 1)
[docs] def pols_carence_cum(t): """Cumulative memberships terminated by the *carence* before the start of month t.""" if t <= 0: return 0.0 return pols_carence_cum(t - 1) + pols_carence_exit(t - 1)
# --- cash flows ---
[docs] def premiums(t): """Premium income at the start of month t, an inflow. ``P(y) x premium_months() x pols_prem(t)`` when an instalment falls due. Carried on :func:`pols_prem` and **never** on :func:`pols_if`: lives in a recognised state are exonerated and reduced lives are paid up, so charging premium to the whole in-force block overstates income by the whole of both bands. """ if not premium_due(t): return 0.0 return premium_mth_pp(t) * premium_months() * pols_prem(t)
[docs] def instalments(t): """The number of *rente* instalments paid at the end of month t. The population of every ledger past its *franchise* that survived the month. It drives the per-instalment handling expense, which pays for the annual proof of life and of the persisting state. """ part, tot, totr, _ = dep_cohorts(t) s_p = (1.0 - mort_rate_partial_mth(t)) if cover_partial() else 0.0 s_t = 1.0 - mort_rate_total_mth(t) out = 0.0 for z in range(franchise_months() + 1, len(part) + 1): out += part[z - 1] * s_p + (tot[z - 1] + totr[z - 1]) * s_t return out
[docs] def claims(t, kind=None): """Benefit outgo at the end of month t, by kind; the total when kind is omitted. ``"RENTE"`` the monthly *rente*, paid **in arrears** to the cohorts past their *franchise* that survived the month. Three ledgers contribute at three amounts: *partielle* at ``rho`` times its vintage's indexed guarantee, *totale* at the whole of it, and the reduced-rente ledger at its own frozen amounts. ``"CAPITAL"`` the *capital d'équipement*, paid once per membership on :func:`pols_capital_claims`. ``"LAPSE"`` zero, in every month of every model point. **There is no surrender value at any duration**, and that zero is a product fact worth publishing rather than leaving to be inferred from a missing column. There is deliberately **no** ``"DEATH"`` kind: this composite carries no death benefit at all, and the optional *capital décès* rider is out of scope. """ if kind is None: return sum(claims(t, k) for k in ("RENTE", "CAPITAL", "LAPSE")) if kind == "RENTE": part, tot, totr, val = dep_cohorts(t) s_p = 1.0 - mort_rate_partial_mth(t) s_t = 1.0 - mort_rate_total_mth(t) rho = partial_ratio_paid() amounts = {} out = 0.0 for z in range(franchise_months() + 1, len(part) + 1): p, o, v = part[z - 1], tot[z - 1], val[z - 1] if p == 0.0 and o == 0.0 and v == 0.0: continue y_e = max(1, policy_year(t - z)) a = amounts.get(y_e) if a is None: a = rente_pay_pp(t, z) amounts[y_e] = a out += (rho * a * p * s_p + a * o * s_t + v * s_t) return out if kind == "CAPITAL": return capital_pp(t) * pols_capital_claims(t) if kind == "LAPSE": return 0.0 raise ValueError("invalid kind")
[docs] def refunds_carence(t): """The premiums returned when a *carence* terminates a membership at month t. ``pols_carence_exit(t) x cum_prem_pp(t)``. It is **not a claim** — it is a return of premium, and it belongs on its own line because it is the only cash flow that runs backwards through the *carence*. In policy year 1 of the base cell it is 0.6141 EUR, three quarters of the year's *rente* and *capital* claims combined: during the *carence* the largest benefit-side cash flow is a premium refund. """ return pols_carence_exit(t) * cum_prem_pp(t)
[docs] def expenses(t): """Maintenance, assistance and acquisition expense at the start of month t **[std]**. 3.00 EUR a month on every policy in force plus 1.20 EUR a month on every policy in force **except the reduced ones**, both inflating at 1.5% a year, plus 150 EUR of acquisition at ``t = 0``. There is **no observed range for any expense level on this product**: no retrieved document discloses an expense assumption, a loading or a commission rate. Only the structure is sourced — *prestations d'assistance* end on *mise en réduction*, which is why the second base excludes :func:`pols_red`. The two per-event claim expenses are on :func:`claim_expenses`, published as a separate ``result_cf()`` column. """ f = inflation_factor(t) out = (expense_maint * f * pols_if(t) # noqa: F821 + expense_assist * f * (pols_if(t) - pols_red(t))) # noqa: F821 if t == 0: out += expense_acq # noqa: F821 return out
[docs] def claim_expenses(t): """Claim adjudication and *rente* handling expense at the end of month t **[std]**. 250 EUR per first recognition and 10 EUR per instalment paid, both flat rather than inflating **[std]**. The adjudication load is an order of magnitude above the per-instalment one because recognition is a real, medically supervised process — a medical attestation completed with the treating doctor, a *médecin-conseil* ruling within 45 working days of a complete file, and a medical arbitration route — while the handling load pays for an annual proof of life and of the persisting state. """ return (expense_claim_adj * pols_recognition(t) # noqa: F821 + expense_rente * instalments(t)) # noqa: F821
[docs] def net_cf(t): """The net cash flow of month t, insurer perspective, **income positive**. ``premiums - claims - refunds_carence - expenses - claim_expenses``. The notes' own sign and the library-wide one, so there is no outgo-positive ``liability_cf`` companion. Undiscounted: a market-consistent valuation applies EIOPA's monthly risk-free term structure to exactly this stream, and that is a layer above this model. """ return (premiums(t) - claims(t) - refunds_carence(t) - expenses(t) - claim_expenses(t))
# --- calibration companions ---
[docs] def sojourn_total(x0): """The expected sojourn in *dépendance totale*, in years, entered at exact age x0. Mortality at ``mort_total_mult`` and no other decrement, in monthly steps, on a **continuously advancing exact age** — which is the calibration convention and not the projection's own age basis, where the attained age steps once a policy year. This is what calibrates ``mort_total_mult``: 2.9989 years from exact age 84 at 4.27, against the mean duration of receipt of about three years the CCSF reports for heavy dependents at a mean age at onset of 84 for women. At 2.75 the same calculation gives 4.19 years and at 3.50, 3.50 — the sojourn is far more sensitive to the multiple than a first look suggests, which is why this is a calibration and not a pick. """ p, s, m = 1.0, 0.0, 0 while p > 1e-15: x = x0 + m / 12.0 if x >= terminal_age: # noqa: F821 break p *= math.exp(-mort_total_mult * mort_force_at(x) / 12.0) # noqa: F821 s += p m += 1 return s / 12.0
[docs] def sojourn_partial(x0): """The expected sojourn in *dépendance partielle*, in years, entered at exact age x0. Mortality at ``mort_partial_mult`` **and** aggravation at ``aggravation_rate``, since a life leaves *partielle* by dying or by deteriorating. Same continuous-age convention as :func:`sojourn_total`. 3.14 years from exact age 82 on the shipped basis — the same order of magnitude as the 29.2-month mean duration of APA receipt across all GIRs, which is the only comparator there is. ``mort_partial_mult`` is **not** calibrated to it: it has no anchor at all, and this is a sanity check rather than a fit. """ p, s, m = 1.0, 0.0, 0 ia = aggravation_rate_mth() while p > 1e-15: x = x0 + m / 12.0 if x >= terminal_age: # noqa: F821 break p *= math.exp(-mort_partial_mult * mort_force_at(x) / 12.0) # noqa: F821 p *= (1.0 - ia) s += p m += 1 return s / 12.0
# --- checks ---
[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 all five ledgers, less the lapses that left outright, less the memberships the *carence* terminated. Three flows are deliberately **absent** because they move lives between ledgers rather than out of the policy count: incidence, aggravation, and the *mise en réduction* — which is the whole point of running the check on the sum rather than on any one ledger. A model that treated a qualifying lapse as an exit would fail this check, not merely understate the liability. """ return (pols_if(t) - pols_if(t + 1) - pols_death(t) - pols_lapse_exit(t) - pols_carence_exit(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. """ tol = roll_fwd_tol * max(pols_if_init(), 1.0) # noqa: F821 return all(abs(check_pols_roll_fwd_resid(t)) <= tol for t in range(proj_len()))
[docs] def check_states_resid(t): """The five-ledger population identity residual at the start of month t; zero. ``pols_if + cumulative deaths + cumulative outright lapses + cumulative carence terminations`` 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 a dependent ledger with no corresponding exit, and nothing else in the model would notice. """ return (pols_if(t) + pols_dead_cum(t) + pols_lapse_cum(t) + pols_carence_cum(t) - pols_if_init())
[docs] def check_states(): """True when the five-ledger 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. The sweep runs ``t = 0 ... proj_len()``, one point past the frame, because the identity holds at the **start** of a month and the last projected month, ``proj_len() - 1``, still has one. """ tol = roll_fwd_tol * max(pols_if_init(), 1.0) # noqa: F821 return all(abs(check_states_resid(t)) <= tol for t in range(proj_len() + 1))
[docs] def check_part_roll_fwd_resid(t): """The *partielle* ledger's aggregate roll-forward residual in month t; zero. ``pols_part(t+1)`` against ``pols_part(t) (1 - q_P)(1 - recovery)(1 - i_Am) + n_P``, which is the same population computed **without** the cohort machinery. It is a real check and not an identity: the two sides are built differently, so a duration shift that dropped or duplicated a cohort would show up here even though the total policy count still closed. """ expected = (pols_part(t) * (1.0 - mort_rate_partial_mth(t)) * (1.0 - recovery_rate_mth()) * (1.0 - aggravation_rate_mth()) + pols_entry_partial(t)) return pols_part(t + 1) - expected
[docs] def check_part_roll_fwd(): """True when the *partielle* ledger closes against its aggregate recursion.""" tol = roll_fwd_tol * max(pols_if_init(), 1.0) # noqa: F821 return all(abs(check_part_roll_fwd_resid(t)) <= tol for t in range(proj_len()))
[docs] def check_tot_roll_fwd_resid(t): """The two *totale* ledgers' aggregate roll-forward residual in month t; zero. ``pols_tot(t+1) + pols_totr(t+1)`` against the same population rolled forward without the cohort machinery: survivors of ``q_T`` and of recovery, plus the direct entrants, plus the entrants out of the reduced ledger, plus the recognised aggravations. The aggravation term is what makes this check bite — an implementation that added aggravations to *totale* without removing them from *partielle*, or that recognised them twice, fails here. """ expected = ((pols_tot(t) + pols_totr(t)) * (1.0 - mort_rate_total_mth(t)) * (1.0 - recovery_rate_mth()) + pols_entry_total(t) + pols_entry_total_red(t) + pols_aggravation_recog(t)) return pols_tot(t + 1) + pols_totr(t + 1) - expected
[docs] def check_tot_roll_fwd(): """True when the two *totale* ledgers close against their aggregate recursion.""" tol = roll_fwd_tol * max(pols_if_init(), 1.0) # noqa: F821 return all(abs(check_tot_roll_fwd_resid(t)) <= tol for t in range(proj_len()))
[docs] def check_model_point(): """True when the selected model point is one the contract could have written. Unlike the three roll-forward checks this is a **validation of the input** rather than an identity of the projection: the *rente* inside its sourced 500-3,000 band, the entry age inside the sourced 40-75 band on a new-business cell, the three *carences* in the non-decreasing order the actuarial reference asks for, a reduced cell with enough years of premiums behind it to have qualified, and a cause mix that sums to one — without which :func:`carence_factor` would silently scale every claim. """ tbl = data.cause_mix_table() # noqa: F821 if abs(float(tbl["share"].sum()) - 1.0) > 1e-9: return False if not 500.0 <= rente_total_mth() <= 3000.0: return False if not 0.0 <= partial_ratio() <= 1.0: return False if premium_mth() <= 0.0 or capital_amount() < 0.0: return False if franchise_months() < 0 or reduction_qualifying_years() < 1: return False if not (carence_accident_months() <= carence_illness_months() <= carence_neuro_months()): return False if status() == "autonomous" and not 40 <= age_at_entry() <= 75: return False if status() == "reduced" and years_paid() < reduction_qualifying_years(): return False return age_at_entry() < terminal_age # noqa: F821
# --- result tables ---
[docs] def result_cf(): """Result table of cash flows, indexed by policy month t. ``pols_if`` is every ledger added at the start of the month, and the five ledgers are published beside it because the reader needs to know which of them is paying premium, which is receiving a *rente* and which is doing neither. ``refunds_carence`` has its own column because it is a return of premium and not a claim, and ``claim_expenses`` has its own because it is a per-event cost rather than a per-policy one. Nothing here is discounted. """ ts = list(range(proj_len())) return pd.DataFrame( # noqa: F821 { "pols_if": [pols_if(t) for t in ts], "pols_auto": [pols_auto(t) for t in ts], "pols_red": [pols_red(t) for t in ts], "pols_part": [pols_part(t) for t in ts], "pols_tot": [pols_tot(t) for t in ts], "pols_totr": [pols_totr(t) for t in ts], "premiums": [premiums(t) for t in ts], "claims_rente": [claims(t, "RENTE") for t in ts], "claims_capital": [claims(t, "CAPITAL") for t in ts], "claims_lapse": [claims(t, "LAPSE") for t in ts], "refunds_carence": [refunds_carence(t) for t in ts], "expenses": [expenses(t) for t in ts], "claim_expenses": [claim_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(proj_len())) return pd.DataFrame( # noqa: F821 { "pols_entry_partial": [pols_entry_partial(t) for t in ts], "pols_entry_total": [pols_entry_total(t) for t in ts], "pols_entry_total_red": [pols_entry_total_red(t) for t in ts], "pols_aggravation": [pols_aggravation(t) for t in ts], "pols_carence_exit": [pols_carence_exit(t) for t in ts], "pols_lapse": [pols_lapse(t) for t in ts], "pols_reduction": [pols_reduction(t) for t in ts], "pols_death": [pols_death(t) for t in ts], "instalments": [instalments(t) for t in ts], "mort_rate": [mort_rate(t) for t in ts], "mort_rate_partial": [mort_rate_partial(t) for t in ts], "mort_rate_total": [mort_rate_total(t) for t in ts], "inc_rate_partial": [inc_rate_partial(t) for t in ts], "inc_rate_total": [inc_rate_total(t) for t in ts], "lapse_rate": [lapse_rate(t) for t in ts], "carence_factor": [carence_factor(t) for t in ts], }, index=pd.Index(ts, name="t"), # noqa: F821 )
# --------------------------------------------------------------------------- # References data = ("Interface", ("..", "Data"), "auto") point_id = 1 terminal_age = 110 mort_partial_mult = 1.75 mort_total_mult = 4.27 aggravation_rate = 0.20 recovery_rate = 0.0 reval_guarantee = 0.010 reval_rente = 0.015 couple_discount_rate = 0.10 revision_lapse_slope = 3.0 revision_lapse_threshold = 0.02 expense_acq = 150.0 expense_maint = 3.0 expense_assist = 1.2 expense_claim_adj = 250.0 expense_rente = 10.0 inflation_rate = 0.015 roll_fwd_tol = 1e-12 math = ("Module", "math") pd = ("Module", "pandas")