Source code for frlib.products.rente_viagere.Rente_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-contract projection of the :mod:`~.Rente_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 scenario
    >>> Projection.point_id = 2            # the same contract, probability-weighted

``t`` counts **months from the effective date**, 0-based: ``t = 0`` is the first
projected month — the first whole civil month of service, at the end of which the first
*arrérage* falls due — and the frame is ``t = 0, 1, …, proj_len() - 1``. Month t runs
from time t to time t + 1, so ``duration(t) = t // 12`` completed policy years have run
at its start and ``policy_year(t) = t // 12 + 1``.

Survival is carried on the **time-point** clock rather than on the period one:
``lives_if(k, life)`` is the probability that a life is alive **at time k**, with
``k = 0`` at the effective date and ``lives_if(0, life) = 1``. Month t therefore opens
at ``lives_if(t, life)`` and closes at ``lives_if(t + 1, life)`` — which is what
:func:`payment_surv_mth` selects between — and nothing is indexed before time 0.

.. rubric:: Input data

Inputs are **external files**: plain CSVs living in the model folder's parent directory,
``products/rente_viagere/``, 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
``Rente_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:`~.Rente_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
reversion_coeff_file    data.reversion_coeff_table()          reversion_coeff_table.csv
======================  ====================================  ========================

.. rubric:: Naming

Cells names follow lifelib's ``basiclife.BasicTerm_S`` and ``savings.CashValue_SE``
wherever those models have an analogue, and follow :mod:`.PA_UK_S` and :mod:`.SPIA_US_S`
— this library's UK and US payout chassis — wherever the three products share machinery,
so that the same concept has the same name whichever country's model a reader opens. The
technical notes use compact actuarial symbols instead. The mapping is:

=========================  ==============================  ==========================
Notes symbol               Cells                           Meaning
=========================  ==============================  ==========================
t                          (the cells argument)            Month from the effective date,
                                                           0-based
(none)                     duration(t)                     Completed policy years
(none)                     duration_mth(t)                 Months elapsed at start of month t
(none)                     policy_year(t)                  Policy year containing month t
C                          purchase_price()                *Capital constitutif*
Y0, M0                     effective_year(),               Calendar year and civil month
                           effective_month()               of the effective date
k(t)                       cal_year_index(t)               Completed 31 Decembers
(none)                     calendar_year(t)                Calendar year of month t
(none)                     civil_month(t)                  Civil month of month t
x_a0, x_r0                 age_at_entry(life)              Entry ages (ALB), life 1 or 2
x_a(t), x_r(t)             age(t, life)                    Attained age (ALB)
g_a, g_r                   birth_year(life)                *Millésimes*
(none)                     sex(life)                       M, F or mix
rho                        annuity_rate()                  *Taux de rente*
kappa                      option_coeff()                  Definitive option coefficient
(reversion table)          reversion_coeff()               *Réversion* coefficient [S6]
(guarantee)                guarantee_coeff()               *Annuités garanties* coefficient
delta                      reversion_pct()                 *Taux de réversion*
n                          guarantee_mths()                *Annuités garanties*, in months
i                          technical_rate()                *Taux technique*
m                          payment_freq()                  Payments per year
timing                     payment_timing()                arrears or advance
T                          is_payment_mth(t)               Month t is a payment date
(payment point)            payment_surv_mth(t)             Time survival is measured at
nu                         revalo_rate                     Revalorisation rate
R(t)                       revalo_factor(t)                Cumulative revalorisation index
Pi(t)                      palier_factor(t)                *Palier* step multiplier
A0                         annual_income_init()            Gross annual *rente* at conversion
A(t)                       annual_income(t)                Gross annual *rente* in force
A(t)/m                     annuity_pp(t)                   Scheduled *arrérage*
h(t)                       mths_since_payment(t)           Complete months since last payment
(prorata)                  prorata_pp(t)                   Accrued *prorata d'arrérages*
q(s, g, x)                 mort_rate_at_age(...)           Generational table lookup
q(t, life)                 mort_rate(t, life)              Annual rate applied to a life
q_mth                      mort_rate_mth(t, life)          Monthly rate 1-(1-q)^(1/12)
theta                      portfolio_male_share            **[std]** portfolio mix
(none)                     mort_basis()                    table or scenario run **[std]**
(none)                     death_mth(life)                 Scenario month of death **[std]**
l_a(t), l_r(t)             lives_if(t, life)               Survival probability to time t
d_a(t), d_r(t)             lives_death(t, life)            Death density of month t
gamma(t)                   certain_floor(t)                Annuity-certain floor indicator
max(gamma, l_a)            payment_factor(t)               Annuitant stream's factor
(none)                     payment_factor_life(t)          l_a alone, before the floor
delta(1-l_a)l_r            reversion_factor(t)             *Réversion* stream's factor
(prorata factor)           prorata_factor(t)               Death-settlement factor
G(t)                       cum_annuity_pp(t, kind)         Cumulative gross *arrérages*
(tariff factor)            annuity_factor(table_sex)       Annuity factor at conversion
(none)                     taux_rente_tariff()             *Taux de rente* the table implies
(none)                     taux_rente_own_table()          The same on the life's own table
(none)                     unisex_gap()                    Cost of the unisex rule
omega                      omega_age                       Limiting age, 120
(stopping rule)            horizon_mths()                  Months to the age stop rule
(none)                     proj_len()                      Projection length in months
IF(t)                      pols_if(t)                      Any payment obligation open
(none)                     pols_if_init()                  Contracts in force at outset
E[ANN(t)]                  annuity_payments(t)             Expected *arrérages* outgo
E[PRO(t)]                  claims(t, "PRORATA")            *Prorata d'arrérages* on death
E[FRA(t)]                  arrerage_charges(t)             *Frais d'arrérages* retained
E[EXP(t)]                  expenses(t)                     Maintenance expense
c_e, pi                    expense_maint, inflation_rate   Expense level and inflation
CF(t)                      liability_cf(t)                 Total gross liability outgo
(none)                     net_cf(t)                       -liability_cf(t), insurer sign
=========================  ==============================  ==========================

Five names needed care.

``kappa`` is one symbol for two different coefficients. The notes carry it as a model
point attribute; here it is **derived**, because both of its values are derivable and a
derived coefficient cannot drift out of step with the table that implies it.
:func:`reversion_coeff` reads the published [S6] age-difference table shipped as a CSV,
and :func:`guarantee_coeff` computes the certain-period loading off the tariff table.
:func:`option_coeff` is the one the conversion applies, and it is 1 when no option is
elected.

``q`` is used for two different objects: the **tariff** rate, which is the more prudent
single table for every life, and the **best-estimate** rate, which is the life's own
table. They are separate objects here — :func:`mort_rate_at_age` takes the table as an
argument, :func:`mort_rate` is the best estimate for a covered life, and
:func:`annuity_factor` takes the table it is priced on. Collapsing them destroys the
unisex mechanic; see below.

``l`` denotes two different survival paths as well. :func:`lives_if` is the *projection*
path of a covered life on the best-estimate basis, and it honours the scenario switch;
:func:`tariff_lives` is the annuitant's survival on a *pricing* table, and it does not.
They are different objects and the model keeps them apart.

``pols_if`` is not a policy count in the usual sense. It is the notes' ``IF(t)``, the
probability that *any* payment obligation remains — the guarantee certain, the annuitant
alive, or the reversion stream in payment — and it exists to carry the maintenance
expense. The name is kept because it is what the rest of the library calls the expense
weight.

There is **no** ``lapse_rate``, ``improve_factor``, ``improve_rate``, ``surr_charge`` or
``av_pp_at`` of any kind, and the absence of each is deliberate; see below.

.. rubric:: The table is generational, so there is no improvement scale

``mort_rate(t, life)`` is a **pure table lookup**::

    q(t, life) = mort_rate_at_age(sex(life), birth_year(life), age(t, life))

with no factor of any kind applied on top of it and no calendar-year argument. TGH05 and
TGF05 are prospective generation tables: ``q(sex, generation, age)`` already gives the
rate the life will experience at that age, in calendar year ``generation + age``. The
trend is inside the table. An improvement scale on top of it — which the UK sibling
needs, because its ONS base table is a *period* table — would double-count the trend, so
this model has no ``improve_factor`` cells and must not acquire one.

The *millésime* is a model point attribute and is **never derived from the projection
year**. A period-table implementation reads the rate for age 66 in calendar year 2027 and
walks diagonally across generations; a generational one reads ``(g = 1961, x = 66)``
whatever the projection year. Two model points with the same entry age and different
*millésimes* must therefore give different rates at the same attained age, and the tests
assert exactly that.

.. rubric:: The tariff table and the best-estimate table are different objects

The Code forces the single table applied to all lives to be the more prudent of the two,
which for an annuity is the female table; the shipped ``tariff_table_sex`` is ``"F"``.
The projection meanwhile decrements each life on its **own** table, or on a **[std]**
portfolio blend at ``portfolio_male_share`` where the model point carries ``mix``. For a
male annuitant the two differ by construction, and the gap — :func:`unisex_gap`, about
13% of income on the shipped anchor — is the systematic technical surplus that the
eight-year profit-sharing rule requires to flow back to policyholders, which in this
model it does through the revalorisation rate ``nu``. Pricing a male life on the female
table *and* projecting him on it makes the prudence margin invisible; projecting him on
the male table without crediting the surplus back shows a permanent retained profit the
rule does not allow.

.. rubric:: Two mortality bases: table and scenario

The notes' worked example is not a probability-weighted run. It is a **scenario**: "the
annuitant dies in month 25 — the 26th month of service — and the reversionary survives
throughout", evaluated at ``l_a = 0`` from time 26 and ``l_r = 1`` throughout. The rest
of the notes projects on an expected basis. Both readings are shipped, and which one applies is a model point
column — the same device :mod:`.PA_UK_S` and :mod:`.SPIA_US_S` use for the same reason:

``mort_basis = "table"``
    ``lives_if`` runs the monthly recursion off the shipped generational table. Model
    points 2, 4, 5, 7, 8, 9, 10 and 11; point 2 is the worked configuration on this basis
    and is the run to read for a realistic cash flow shape.

``mort_basis = "scenario"`` **[std]**
    ``lives_if(t, life)`` is the deterministic step function ``1{t <= death_mth(life)}``
    — survival to time t, so a life dying in month ``d`` is alive at time ``d``, the
    start of that month, and gone from time ``d + 1`` — with a blank or negative
    ``death_mth`` meaning the life survives the whole projection. Model points 1, 3
    and 6, which reproduce the worked example and its variants exactly.

The scenario switch is a **[std]** modelling device, not a product feature; it exists
because the notes' verification anchor is a scenario and retuning assumptions to force a
probability-weighted run onto it would be dishonest. It never reaches the *pricing*
side: :func:`tariff_lives` and :func:`annuity_factor` always run off the table.

.. rubric:: Revalorisation is a calendar event, and it is pro-rated once

``revalo_factor`` steps at each **31 December**, never on a policy anniversary, and the
uplift reaches instalments payable from the following 1 January. The first step is
pro-rated ``nu x (13 - M0)/12`` for the part-year of service, which degenerates to the
full ``nu`` for a 1 January effective date. Both halves are notes' pitfalls: an
anniversary convention holds the annuity at its initial level for twelve months instead
of nine on the worked configuration and shifts every later step by three months, and
dropping the pro-rata overstates the annuity for the whole of its remaining life, because
``R(t)`` is a running product. ``nu`` is floored at zero, which is the only contractual
bound any retrieved document states.

The *frais sur encours de rentes* appear in **no** recursion here, by design: they bite on
the *provision mathématique* and reduce the profit-sharing base — hence ``nu`` — and never
an instalment. Netting them off an instalment would cut the annuitant's income, which no
retrieved contract does.

.. rubric:: The guarantee is a floor, the reversion is a second stream

``payment_factor(t) = max(certain_floor(t), payment_factor_life(t))`` makes the *annuités
garanties* an annuity-**certain floor** rather than a second stream: while the guarantee
runs the full instalment is payable regardless of survival, and an additive construction
would pay ``1 + l_a``. The *réversion* is genuinely a second stream, gated on
``(1 - l_a(t))`` — the annuitant's survival to the **start** of month t — and **not** on
``(1 - l_a(t + 1))``, its survival to the end: the survivor's first instalment falls in
the month *after* the month of death, immediately after the *prorata d'arrérages* has
settled it. Gating on the end of the month pays the reversion and the *prorata* in the
same month, so the month of death is paid ``1 + delta`` times.

The two options are **not cumulative**; :func:`check_options_xor` asserts no model point
carries both, and :func:`option_coeff` raises if one does.

.. rubric:: The month of death is paid in full

``prorata_pp(t)`` is the accrued instalment settled on death. With
``h(t) = t mod (12/m)`` complete months since the last payment date it is
``((h(t) + 1)/(12/m)) x A(t)/m``, so at ``m = 12`` it is exactly **one full instalment** —
the French rule is that instalments cease from the 1st day of the month *following* the
death — and at ``m = 4`` a death in the first month of a quarter settles one third of the
quarterly instalment. The ``(1 - gamma(t))`` gate suppresses it while the guarantee runs,
the full instalment being payable there already, and the second term of
:func:`prorata_factor` is the symmetric settlement on the reversionary's own death. There
is no "with or without proportion" election in France: the *prorata* is the rule. On the
unobserved ``advance`` variant nothing has accrued unpaid at death, so the *prorata* is
zero **[std]**.

.. rubric:: The taux technique is not a discount rate

``i`` reaches the projection **only through** ``rho``: it prices the annuity at
conversion, through :func:`annuity_factor`, and thereafter functions as a lifetime
minimum guaranteed return. It appears in no cash flow recursion and is not a valuation
rate. The best estimate discounts at the risk-free term structure, which this library
does not compute at all — every ``technical-notes.md`` in it specifies *gross* liability
cash flows and leaves discounting and reserves to a layer that consumes them.

.. rubric:: There is no policyholder behaviour to model

No lapse decrement, no dynamic behaviour formulas, no surrender value at any duration, no
alteration of options and no premium flexibility. That is a cited product feature — the
Code gives a *rente viagère* in payment no surrender value at all — and it is why this
model has no ``lapse_rate`` of any kind. The one election that survives conversion is not
a cash flow but an admission test: below the statutory commutation threshold the insurer
may pay a capital instead, with the annuitant's agreement, so there is no annuity to
project, and :func:`check_commutation_floor` rejects such a model point rather than
projecting it. Behaviour otherwise enters the *basis*, not the projection: voluntary
annuitants self-select for longevity, which is already inside an annuitant-experience
table, and the unisex tariff deters male annuitants, which is the direction of
``portfolio_male_share`` sitting below one half.

.. rubric:: Sign convention

The notes define ``CF(t)`` as total gross liability **outgo**, which is
:func:`liability_cf`. :func:`net_cf` is its negative, the library-wide income-positive
convention, so a ``result_cf()["net_cf"]`` column can be summed or compared across every
model in the library. Both are published as columns rather than one being made to stand
for the other. There is no premium income in the projection at all: the *capital
constitutif* is a pricing input at the effective date, not a projected cash flow. The one
component that runs the other way is :func:`arrerage_charges`, which the insurer
**retains** out of each *quittance*: it is published as a positive column and
**subtracted** in :func:`liability_cf`.
"""

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 purchase_price(): """C: the *capital constitutif* actually applied to the annuity. For a wrapper exit this is the *valeur atteinte* net of social and tax levies, with any entry charge taken before the model starts. A pricing input at the effective date, not a projected cash flow: the projection carries no premium income. It enters the projection only through :func:`annual_income_init`. """ return float(model_point()["purchase_price"])
[docs] def effective_year(): """Y0: the calendar year of the effective date. The model carries the calendar and not merely the duration, because revalorisation and expense inflation step at 31 December. """ return int(model_point()["effective_year"])
[docs] def effective_month(): """M0: the civil month of the effective date, 1 for January. The annuity always takes effect on the 1st day of a civil month, so the effective date needs no day part. M0 fixes the length of the first partial calendar year, 13 - M0 months, over which the first revalorisation is pro-rated. """ v = int(model_point()["effective_month"]) if not 1 <= v <= 12: raise ValueError("invalid effective_month") return v
[docs] def age_at_entry(life): """x(0): the entry age of the annuitant (``life = 1``) or reversionary (``life = 2``). **Age last birthday** at the effective date, chosen to index the shipped single-year-of-age generational table **[std]**. It is not derived from the *millésime* and does not derive it: the two are separate attributes, because a table keyed on year of birth and an age keyed on birthday do not determine one another inside a calendar year. """ if life == 1: return int(model_point()["annuitant_age"]) if life == 2 and reversion_pct() > 0.0: return int(model_point()["reversion_age"]) raise ValueError("invalid life")
[docs] def birth_year(life): """g: the *millésime* of ``life``, the generational table's key. **Never derived from the projection year.** The table is generational, so the rate a life experiences at an attained age depends on the year it was born and on nothing else about the calendar. """ if life == 1: return int(model_point()["annuitant_birth_year"]) if life == 2 and reversion_pct() > 0.0: return int(model_point()["reversion_birth_year"]) raise ValueError("invalid life")
[docs] def sex(life): """The best-estimate table of ``life``: ``M``, ``F`` or ``mix``. ``mix`` is the **[std]** portfolio blend of assumption (vi), for a model point that stands for a cohort rather than a person. This selects the **best-estimate** table only; the tariff is ``tariff_table_sex`` for every life, whatever this returns. """ if life == 1: v = model_point()["annuitant_sex"] elif life == 2 and reversion_pct() > 0.0: v = model_point()["reversion_sex"] else: raise ValueError("invalid life") if v not in ("M", "F", "mix"): raise ValueError("invalid sex") return v
[docs] def annuity_rate(): """rho: the *taux de rente* struck at conversion, per unit of capital per annum. A model point attribute, because it is what a real *barème* quotes and no French insurer publishes one. Every shipped model point carries the rate the shipped tariff table implies at its own age, *millésime* and *taux technique*, rounded to six decimals; :func:`check_taux_rente` asserts that agreement, and :func:`taux_rente_tariff` is the derivation. """ return float(model_point()["annuity_rate"])
[docs] def reversion_pct(): """delta: the *taux de réversion*; 0 where no *réversion* is elected. The survivor receives delta times the *rente atteinte* at death, for life, from the 1st day of the month following it. Mutually exclusive with the *annuités garanties* in the representative design. """ v = model_point()["reversion_pct"] return 0.0 if pd.isna(v) else float(v) # noqa: F821
[docs] def guarantee_mths(): """n: the *annuités garanties* in months, 0 or 60 to 300 in 60-month steps. Instalments are certain for n months to the designated beneficiaries, at the same amount and rising with the same revalorisation index. No lump-sum commutation of the remaining term is offered. """ return 12 * int(model_point()["guarantee_years"])
[docs] def palier_scheme(): """The *rente par paliers* scheme: ``none``, ``inc1``, ``inc2``, ``dec1`` or ``dec2``. A *rente par paliers* is a level-within-step function of duration, not an escalation: nothing compounds and the steps are contractual percentages of the initial level. """ v = model_point()["palier_scheme"] if v not in ("none", "inc1", "inc2", "dec1", "dec2"): raise ValueError("invalid palier_scheme") return v
[docs] def palier_step_years(): """S: the length of the first *palier* step in years, 5 or 10; 0 when none. The second step is "d'une durée égale" to the first, which is why one number parameterizes both. """ v = int(model_point()["palier_step_years"]) if palier_scheme() == "none": return 0 if v not in (5, 10): raise ValueError("invalid palier_step_years") return v
[docs] def payment_freq(): """m: payments per year, in {12, 4, 2, 1}.""" v = int(model_point()["payment_freq"]) if v not in (12, 4, 2, 1): raise ValueError("invalid payment_freq") return v
[docs] def payment_timing(): """Whether instalments fall in *arrears* (*terme échu*) or in *advance*. Every retrieved French carrier pays *terme échu*; ``advance`` is retained as an unobserved model variant only. Arrears instalments require survival at the end of the payment month and advance instalments at the start of it; using end-of-period survival for advance payments understates the liability by roughly one period's mortality per payment, which is material at annuitant ages. """ v = model_point()["payment_timing"] if v not in ("arrears", "advance"): raise ValueError("invalid payment_timing") return v
[docs] def arrerage_charge_rate(): """f: the *frais d'arrérages* retained out of each gross *quittance*. A percentage of the payment, deducted per *quittance d'arrérages* and not off the annualised *rente*. At a flat percentage the two coincide; at a per-instalment cap or a flat per-instalment fee they do not, and the payment frequency then changes the total. Ranges from 0.00% to 3% across the retrieved carriers. """ return float(model_point()["arrerage_charge_rate"])
[docs] def technical_rate(): """i: the *taux technique* the *taux de rente* was struck on. It reaches the projection **only through** rho, by way of :func:`annuity_factor`, and appears in no cash flow recursion. It is **not** a discount rate: the best estimate discounts at the risk-free term structure, and reusing i for that produces neither a price nor a reserve. It is carried so a reader can see which rate rho was struck on. """ return float(model_point()["technical_rate"])
[docs] def mort_basis(): """Whether the run is probability-weighted (*table*) or deterministic (*scenario*). *table* runs the monthly recursion off the shipped generational table; *scenario* **[std]** replaces it with the step function ``1{t <= death_mth(life)}`` so the notes' worked example - which is a scenario, not an expectation - reproduces exactly. It never reaches the pricing side. See the Space docstring. """ v = model_point()["mort_basis"] if v not in ("table", "scenario"): raise ValueError("invalid mort_basis") return v
[docs] def death_mth(life): """The scenario month of death of ``life``; -1 if the life survives throughout. Read only when ``mort_basis() == "scenario"``. The month index is on the projection frame's own 0-based clock, so the shipped worked example dies in month 25, the 26th month of service. A blank cell in the model point table means the life never dies in the scenario and is returned as -1, a value no month can take: with ``t`` 0-based, month 0 is a projectable event and cannot double as the sentinel. A death in month d is decremented at the end of that month, so ``lives_if(t, life)`` - survival to time t - is 0 from ``t = d + 1`` and the *arrérage* of month d is nevertheless due in full, as the *prorata*. """ v = model_point()["death_mth_1" if life == 1 else "death_mth_2"] return -1 if pd.isna(v) else int(v) # noqa: F821
[docs] def pols_if_init(): """Initial number of contracts in force; 1.0 on a single-contract model point.""" return float(model_point()["pols_if_init"])
[docs] def duration(t): """Completed policy years at the start of month t: ``duration_mth(t) // 12``. 0-based, as ``duration`` is throughout lifelib: 0 through the first policy year. """ return duration_mth(t) // 12
[docs] def duration_mth(t): """Months elapsed from the effective date at the start of month t; equal to t. ``t`` is 0-based and counted from the effective date, so the identity is trivial - the cells exists so the monthly models in this library share one vocabulary, and so that ``duration(t) = duration_mth(t) // 12`` reads as lifelib writes it. """ return t
[docs] def policy_year(t): """The policy year containing month t; 1 for t = 0..11. Nothing in this product happens on a policy anniversary: the *paliers* and the attained ages step on 12-month multiples of the effective date, and revalorisation and expense inflation step at 31 December. The cells exists for the shared vocabulary and for the *palier* boundaries. """ return duration(t) + 1
[docs] def age(t, life): """The attained age (ALB) of ``life`` in month t: ``x(0) + t // 12`` **[std]**. Age increments on each 12-month multiple of the effective date, not on a birthday: the model point carries an age last birthday at the effective date and a *millésime*, and neither fixes the birthday within the year. """ return age_at_entry(life) + duration(t)
[docs] def calendar_year(t): """The calendar year containing month t. Derived from the effective date's year and civil month, ``Y0 + (M0 + t - 1) // 12``, ``t`` being 0-based so that month 0 is the civil month of the effective date itself. It is **not** an argument to any mortality lookup: the table is generational. """ return effective_year() + (effective_month() + t - 1) // 12
[docs] def civil_month(t): """The civil month containing month t, 1 for January.""" return (effective_month() + t - 1) % 12 + 1
[docs] def cal_year_index(t): """k(t): completed 31 Decembers strictly before the start of month t. The annuity is in service for ``13 - M0`` months of its first calendar year - months ``t = 0 .. 12 - M0`` on the 0-based clock - so ``k(t) = 0`` while ``t < 13 - M0`` and ``1 + (t - (13 - M0)) // 12`` afterwards. It counts 31 Decembers **strictly before** month t, because the uplift credited at 31 December reaches instalments payable from the following 1 January. :func:`check_calendar_index` asserts that this agrees with the calendar the model carries independently, ``calendar_year(t) - Y0``. """ first = 13 - effective_month() if t < first: return 0 return 1 + (t - first) // 12
[docs] def mort_rate_at_age(table_sex, gen, x): """q(s, g, x): the annual mortality rate of the shipped generational table. A **pure table lookup** keyed on sex, *millésime* and attained age - no improvement factor, no rating factor, no calendar-year argument. Rates at and above the limiting age are 1. ``table_sex = "mix"`` returns the **[std]** portfolio blend ``theta q(M) + (1 - theta) q(F)``, which is a best-estimate device for a model point standing for a cohort and is never a tariff basis. """ if x >= omega_age: # noqa: F821 return 1.0 if table_sex == "mix": return (portfolio_male_share * mort_rate_at_age("M", gen, x) # noqa: F821 + (1.0 - portfolio_male_share) # noqa: F821 * mort_rate_at_age("F", gen, x)) if table_sex not in ("M", "F"): raise ValueError("invalid table_sex") return float(data.mort_table().loc[(table_sex, gen, x), "mort_rate"]) # noqa: F821
[docs] def mort_rate(t, life): """q: the annual best-estimate mortality rate applied to ``life`` in month t. ``mort_rate_at_age(sex(life), birth_year(life), age(t, life))`` and nothing else. The table is generational, so the trend is inside it and there is no improvement scale to apply on top; the projection year does not enter. This is the **best-estimate** rate, not the tariff rate - see :func:`annuity_factor`. """ return mort_rate_at_age(sex(life), birth_year(life), age(t, life))
[docs] def mort_rate_mth(t, life): """q_m = 1 - (1 - q)^(1/12): the monthly mortality rate **[std]**. Uniform force within the year of age, which is the standard reading of an annual table on a monthly grid and is strictly below the annual rate wherever that is positive. """ return 1.0 - (1.0 - mort_rate(t, life)) ** (1.0 / 12.0)
[docs] def lives_if(t, life): """l(t): the probability that ``life`` is alive at **time** t, t = 0 at the outset. A **time-point** cells, not a period one: ``l(0) = 1`` at the effective date, month t opens at ``l(t)`` and closes at ``l(t + 1)``, and no index is ever negative. On the *table* basis ``l(t) = l(t-1)(1 - q_m(t-1))``, the rate of month ``t - 1`` decrementing over that month. On the *scenario* basis **[std]** the survival path is the step function ``1{t <= death_mth(life)}``, with a negative ``death_mth`` meaning the life survives the whole projection - which is what the notes' worked example specifies. Returns 0 for life = 2 where no *réversion* is elected. """ if life == 2 and reversion_pct() <= 0.0: return 0.0 if t <= 0: return 1.0 if mort_basis() == "scenario": d = death_mth(life) return 0.0 if (d >= 0 and t > d) else 1.0 return lives_if(t - 1, life) * (1.0 - mort_rate_mth(t - 1, life))
[docs] def lives_death(t, life): """d(t) = l(t) - l(t+1): the death density of ``life`` in month t. A period quantity built from the two time points month t runs between: the deaths of month t are decremented at its end. """ return lives_if(t, life) - lives_if(t + 1, life)
[docs] def tariff_horizon_mths(): """The months the tariff annuity factor runs over: ``12 (omega - x_a0)``. The annuitant's own horizon, not the projection's: an annuity factor is a property of the life being priced. """ return 12 * (omega_age - age_at_entry(1)) # noqa: F821
[docs] def tariff_lives(t, table_sex): """The annuitant's survival to **time** t on a **pricing** table, t = 0 at conversion. A different object from :func:`lives_if`: it runs on whichever table it is asked for, it always runs off the table, and it ignores the scenario switch entirely - a scenario is a statement about one realisation, and pricing is not. Like :func:`lives_if` it is a time-point cells, and the step from ``t - 1`` to ``t`` takes the rate of month ``t - 1``, at the attained age ``age(t - 1, 1)``. """ if t <= 0: return 1.0 q = mort_rate_at_age(table_sex, birth_year(1), age(t - 1, 1)) return tariff_lives(t - 1, table_sex) * (1.0 - (1.0 - (1.0 - q) ** (1.0 / 12.0)))
[docs] def annuity_factor(table_sex): """The annuity factor at conversion on ``table_sex``, per unit of annual *rente*. ``sum(l(t) v^t) / 12`` over the annuitant's horizon, monthly in arrears, with ``v = (1 + i)^(-1/12)`` at the *taux technique*. This is the **only** place i enters the model, and it enters the pricing side, not a cash flow. At the shipped zero *taux technique* the factor is the residual life expectancy of the table. The sum runs over the **payment times** ``t = 1 .. 12(omega - x_a0)`` on the pricing clock - the instants an arrears instalment falls due, counted from conversion - and not over the projection frame's month indices, so it does not move with the frame. """ v = (1.0 + technical_rate()) ** (-1.0 / 12.0) return sum(tariff_lives(t, table_sex) * v ** t for t in range(1, tariff_horizon_mths() + 1)) / 12.0
[docs] def taux_rente_tariff(): """The *taux de rente* the tariff table implies: ``1 / (a x (1 + loading))``. Struck on ``tariff_table_sex`` for **every** life, whatever the annuitant's own sex, because the Code requires the single table applied to all lives to be the more prudent one. ``rate_loading`` is the **[std]** margin between the pure factor and the quoted rate, at a level no source publishes. The shipped mortality proxy is anchored so that this reproduces the technical notes' placeholder rho exactly at the worked configuration's age and *millésime*. """ return 1.0 / (annuity_factor(tariff_table_sex) # noqa: F821 * (1.0 + rate_loading)) # noqa: F821
[docs] def taux_rente_own_table(): """The *taux de rente* the annuitant's **own** table would imply. The counterfactual the unisex rule forbids. It is not used in any cash flow; it exists so that :func:`unisex_gap` can be read off the model rather than asserted. """ return 1.0 / (annuity_factor(sex(1)) * (1.0 + rate_loading)) # noqa: F821
[docs] def unisex_gap(): """The income a life gives up to the unisex rule, as a fraction of what it receives. ``taux_rente_own_table() / rho - 1``. Positive for a male annuitant - about 13% on the shipped anchor - and it is the systematic technical surplus the eight-year profit-sharing rule requires to flow back to policyholders, which in this model it does through the revalorisation rate. Negative or zero for a female annuitant, whose own table *is* the tariff table. """ return taux_rente_own_table() / annuity_rate() - 1.0
[docs] def certain_excess_years(): """The years of annuity factor the *annuités garanties* add, on the tariff table. ``sum((1 - l(t)) v^t) / 12`` over the guaranteed months: the certain-period annuity factor exceeds the life factor by exactly the payments made to beneficiaries after a death inside the term. Zero where no guarantee is elected. Like :func:`annuity_factor` this is a pricing sum over payment times ``t = 1 .. n`` and not over the projection frame. """ n = guarantee_mths() if n <= 0: return 0.0 v = (1.0 + technical_rate()) ** (-1.0 / 12.0) return sum((1.0 - tariff_lives(t, tariff_table_sex)) * v ** t # noqa: F821 for t in range(1, n + 1)) / 12.0
[docs] def guarantee_coeff(): """The definitive coefficient the *annuités garanties* cost, ``a / (a + excess)``. **[std]**: no retrieved document publishes the cost of *annuités garanties*, so it is derived here from the tariff table by the only construction the notes give - the certain-period factor exceeds the life factor by the payments made after a death inside the term. Derived rather than carried as a model point column so that it cannot drift out of step with the table that implies it, and so that a 25-year term costs more than a 5-year one, which a single published figure cannot express. Substituting a licensed mortality basis therefore moves this coefficient too. """ if guarantee_mths() <= 0: return 1.0 a = annuity_factor(tariff_table_sex) # noqa: F821 return a / (a + certain_excess_years())
[docs] def reversion_coeff(): """The definitive *réversion* coefficient, from the published [S6] table. Keyed on the *taux de réversion* and on the difference in *millésime* between the reversionary and the annuitant, a positive difference meaning the reversionary is the younger life. The three published columns are 60%, 80% and 100%; a *taux de réversion* off that grid has no retrieved coefficient and raises rather than being guessed at. The reduction is **definitive** and applies to the annuitant's own annuity once, at conversion: it does not also scale the reversion stream, which is delta times the *already reduced* annuity reached at death, and it is not released if the reversionary predeceases the annuitant. """ if reversion_pct() <= 0.0: return 1.0 d = birth_year(2) - birth_year(1) tbl = data.reversion_coeff_table() # noqa: F821 hit = tbl[((tbl["reversion_pct"] - reversion_pct()).abs() < 1e-9) & (tbl["gen_diff_lo"] <= d) & (d <= tbl["gen_diff_hi"])] if len(hit) != 1: raise ValueError("no published reversion coefficient for this model point") return float(hit["reversion_coeff"].iloc[0])
[docs] def option_coeff(): """kappa: the definitive coefficient the elected option costs; 1 with no option. The *réversion* coefficient where a *réversion* is elected and the **[std]** guarantee coefficient where *annuités garanties* are. The options are **not cumulative**, so exactly one of them may be non-trivial and a model point carrying both raises rather than compounding two coefficients. """ if reversion_pct() > 0.0 and guarantee_mths() > 0: raise ValueError("reversion and annuites garanties are not cumulative") if reversion_pct() > 0.0: return reversion_coeff() if guarantee_mths() > 0: return guarantee_coeff() return 1.0
[docs] def annual_income_init(): """A0 = C rho kappa: the gross annual *rente* at conversion. Derived, not carried: rho and kappa are the two quantities a real *barème* computes, and the model shows the arithmetic rather than taking its answer as an input. """ return purchase_price() * annuity_rate() * option_coeff()
[docs] def revalo_factor(t): """R(t): the cumulative revalorisation index in month t; R = 1 while k(t) = 0. Steps at each **31 December** and reaches instalments payable from the following 1 January, never on a policy anniversary. The first step is pro-rated ``nu (13 - M0)/12`` for the part-year of service, which degenerates to the full nu for a 1 January effective date. The uplift is floored at zero, the only contractual bound any retrieved document states, so R is non-decreasing. The ``k(t) = 0`` branch is the base case of the recursion: it holds over the whole first partial calendar year, which always contains month 0, so no month before the first is ever read. """ k = cal_year_index(t) if k == 0: return 1.0 if k == cal_year_index(t - 1): return revalo_factor(t - 1) if k == 1: step = revalo_rate * (13 - effective_month()) / 12.0 # noqa: F821 else: step = revalo_rate # noqa: F821 return revalo_factor(t - 1) * (1.0 + max(0.0, step))
[docs] def palier_factor(t): """Pi(t): the *rente par paliers* step multiplier; 1 where no scheme is elected. A step function of **duration**, not an escalation: nothing compounds, and the steps are contractual percentages of the initial level. The first step runs 12S months - ``t = 0 .. 12S - 1`` - and the second, where the scheme has three levels, an equal further 12S months. """ scheme = palier_scheme() if scheme == "none": return 1.0 if scheme == "inc1": steps = (1.0, 2.0) elif scheme == "inc2": steps = (1.0, 1.25, 1.50) elif scheme == "dec1": steps = (1.0, 0.50) else: steps = (1.0, 0.75, 0.50) s = 12 * palier_step_years() if t < s: return steps[0] if len(steps) == 2 or t < 2 * s: return steps[1] return steps[2]
[docs] def annual_income(t): """A(t) = A0 R(t) Pi(t): the gross annualised *rente* in force in month t. **Deterministic** given the assumption set: the annuity level does not depend on survival, only the payment factors do. The *frais sur encours de rentes* are not netted from it - they bite on the *provision mathématique* and reduce the profit-sharing base, hence nu, and never an instalment. """ return annual_income_init() * revalo_factor(t) * palier_factor(t)
[docs] def is_payment_mth(t): """Whether month t is a payment date. Arrears (*terme échu*): the k-th instalment falls at the end of month ``12k/m - 1``, so ``t = 2, 5, 8, ...`` at m = 4. Advance: it falls one full payment period earlier, at the start of month ``12(k-1)/m``, so t = 0, 3, 6, ... At m = 12 every month is a payment month on either convention. """ if t < 0: return False step = 12 // payment_freq() if payment_timing() == "arrears": return (t + 1) % step == 0 return t % step == 0
[docs] def payment_surv_mth(t): """The **time** at which survival is measured for the instalment falling in month t. An index into :func:`lives_if`, which is a time-point cells. Arrears: the end of month t, time ``t + 1``. Advance: the start of month t, time ``t``, because an advance instalment falls at the start of the month; 0 for the first instalment, where ``lives_if`` is 1. """ return t + 1 if payment_timing() == "arrears" else t
[docs] def annuity_pp(t): """A(t)/m: the scheduled *arrérage* per contract in month t, zero outside T. This is the annuitant's instalment. The reversionary's is delta times it, on the *rente atteinte* - the same schedule - which is what "delta of the annuity reached at death" reduces to under a schedule that does not depend on survival. """ if not is_payment_mth(t): return 0.0 return annual_income(t) / payment_freq()
[docs] def mths_since_payment(t): """h(t) = t mod (12/m): complete months since the last payment date, at the start of t. The accrual base of the *prorata d'arrérages*. At m = 12 it is 0 for every t, so the *prorata* is a whole instalment; at m = 4 a death in the first month of a quarter gives h = 0 and settles one third of the quarterly instalment, the second month h = 1 and two thirds. """ return t % (12 // payment_freq())
[docs] def prorata_pp(t): """The *prorata d'arrérages* accrued and unpaid at a death in month t. ``((h(t) + 1)/(12/m)) A(t)/m``: the arrears that have accrued up to and including the month of death, which belong to the heirs. At m = 12 that is exactly **one full instalment**, the French rule being that instalments cease from the 1st day of the month *following* the death. Zero on the unobserved ``advance`` variant **[std]**, where the instalment covering the month of death was already paid at its start and nothing has accrued unpaid. """ if payment_timing() != "arrears": return 0.0 step = 12.0 / payment_freq() return (mths_since_payment(t) + 1) / step * annual_income(t) / payment_freq()
[docs] def certain_floor(t): """gamma(t) = 1{t < n}: the annuity-certain floor of the *annuités garanties*. The guarantee covers the n months ``t = 0 .. n - 1`` and is spent from month n. """ return 1.0 if t < guarantee_mths() else 0.0
[docs] def payment_factor_life(t): """l_a measured at the payment point: the annuitant's survival factor alone.""" return lives_if(payment_surv_mth(t), 1)
[docs] def payment_factor(t): """max(gamma(t), l_a): the annuitant stream's payment factor. The ``max`` makes the *annuités garanties* an annuity-**certain floor** rather than a second stream: while the guarantee runs the full instalment is payable regardless of survival, and an additive form would pay ``1 + l_a`` for the whole term. """ return max(certain_floor(t), payment_factor_life(t))
[docs] def reversion_factor(t): """delta (1 - l_a(t)) l_r: the *réversion* stream's payment factor. The gate is ``(1 - l_a(t))``, the annuitant's survival to the **start** of month t, and **not** ``(1 - l_a(t + 1))``, its survival to the end: the survivor's first instalment falls in the month *after* the month of death, immediately after the *prorata d'arrérages* has settled it. The only source that dates the reversion start gives it as the 1st day of the "month **or** quarter" following death, so a one-month gate at every payment frequency is a **[std]** reading of its monthly limb - exact at m = 12, and up to a quarter early on a quarterly contract. No shipped model point combines a *réversion* with m < 12. The survivor's own survival is measured at the payment point. Zero where no *réversion* is elected. """ if reversion_pct() <= 0.0: return 0.0 return (reversion_pct() * (1.0 - lives_if(t, 1)) * lives_if(payment_surv_mth(t), 2))
[docs] def prorata_factor(t): """The probability-weight of a *prorata d'arrérages* settlement in month t. ``d_a(t)(1 - gamma(t)) + delta (1 - l_a(t)) d_r(t)``: the annuitant's own death, suppressed while the *annuités garanties* run because the full instalment is already payable there, plus the symmetric settlement on the reversionary's death once the reversion stream is running - gated, like :func:`reversion_factor`, on the annuitant's survival to the **start** of month t. """ res = lives_death(t, 1) * (1.0 - certain_floor(t)) if reversion_pct() > 0.0: res += (reversion_pct() * (1.0 - lives_if(t, 1)) * lives_death(t, 2)) return res
[docs] def cum_annuity_pp(t, kind): """G(t): cumulative gross *arrérages* per contract through month t. ``"ANNUITANT"`` the **deterministic as-if-alive** annuitant schedule, which needs no path simulation precisely because it ignores survival. It is the *rente* the contract promises, and it is what a reader should compare a *barème* against. ``"ALL"`` the expected total actually paid across both streams, including the *prorata* settled on death. On a probability-weighted run it is an *expectation* rather than a path; in a scenario run the two coincide for a surviving annuitant. Month 0 is the first projected month, so the accumulation opens there rather than carrying a month-zero row: ``G(0)`` is what month 0 itself pays. """ prev = 0.0 if t <= 0 else cum_annuity_pp(t - 1, kind) if kind == "ANNUITANT": return prev + annuity_pp(t) if kind == "ALL": paid = (annuity_pp(t) * (payment_factor(t) + reversion_factor(t)) + prorata_pp(t) * prorata_factor(t)) return prev + paid raise ValueError("invalid kind")
[docs] def horizon_mths(): """The number of months over which some covered life is still below the limiting age. ``12 (omega - min x_i)``: the notes stop once ``t/12 + x_i > omega`` for every covered life, so the months projected are ``t = 0 .. 12(omega - min x_i) - 1``, the last of them the last month of age ``omega - 1`` for the youngest life. Stopping on the annuitant's age alone would truncate a younger reversionary's tail. """ ages = [age_at_entry(1)] if reversion_pct() > 0.0: ages.append(age_at_entry(2)) return 12 * (omega_age - min(ages)) # noqa: F821
[docs] def proj_len(): """Projection length in months: the mortality horizon, or the guarantee if longer. The **number of months projected**, so the frame is ``range(proj_len())`` and its last index is ``proj_len() - 1``. """ return max(horizon_mths(), guarantee_mths())
[docs] def annuity_payments(t): """E[ANN(t)]: expected *arrérages* outgo in month t. ``A(t)/m x [max(gamma, l_a) + delta (1 - l_a) l_r]``, scaled by ``pols_if_init``: the annuitant stream with its certain floor, plus the *réversion* stream. """ return (pols_if_init() * annuity_pp(t) * (payment_factor(t) + reversion_factor(t)))
[docs] def claims(t, kind=None): """Expected death-settlement outgo in month t, by kind; the total when kind is omitted. ``"PRORATA"`` the *prorata d'arrérages*: the arrears accrued and unpaid at a death, which belong to the heirs. At m = 12 that is a whole instalment, so losing it understates the outgo by one full *arrérage* per death. It is the **only** death benefit this product has: the representative design is *capital aliéné*, with no death capital and no refund of any part of the *capital constitutif*. """ if kind is None: return sum(claims(t, k) for k in ("PRORATA",)) if kind == "PRORATA": return pols_if_init() * prorata_pp(t) * prorata_factor(t) raise ValueError("invalid kind")
[docs] def arrerage_charges(t): """E[FRA(t)]: the *frais d'arrérages* the insurer retains in month t. ``f x (E[ANN(t)] + E[PRO(t)])`` - per *quittance*, on every payment including the *prorata* settled on death, and **not** on the annualised *rente*. Published as a positive column and **subtracted** in :func:`liability_cf`, because the insurer keeps it: it is the one component of this statement that runs the other way. """ return arrerage_charge_rate() * (annuity_payments(t) + claims(t, "PRORATA"))
[docs] def pols_if(t): """IF(t): the probability that any payment obligation remains in month t. ``min(1, max(gamma(t), l_a(t+1)) + 1{delta>0}(1 - l_a(t)) l_r(t+1))`` **[std]** - the guarantee certain, the annuitant alive, or the reversion stream in payment. Survival is read at the **end** of month t, the point the arrears instalment is measured at, and the reversion leg is gated on the annuitant's survival to its start. This is the weight the maintenance expense is carried on, which is why it keeps the library's name for the expense weight even though it is not a policy count. Note the one-month gap the notes' own formula produces: in the month of an annuitant's death the annuitant leg is already 0 and the reversion leg has not yet opened, so no maintenance expense is accrued in that month even though the *prorata* is being settled in it. The formula is implemented as the notes write it rather than smoothed, and the gap is one month of a EUR 30 a year expense. """ la = lives_if(t + 1, 1) obligation = max(certain_floor(t), la) if reversion_pct() > 0.0: obligation += (1.0 - lives_if(t, 1)) * lives_if(t + 1, 2) return pols_if_init() * min(1.0, obligation)
[docs] def inflation_factor(t): """The expense inflation factor in month t: ``(1 + pi)^k(t)`` **[std]**. It steps at each 31 December like the revalorisation index, but **without** the first-year pro-rating: an expense base is restated at a full year's inflation whenever the calendar year turns, whereas the pro-rating of the revalorisation is a contractual rule about a *rente* in service for part of a year. """ return (1.0 + inflation_rate) ** cal_year_index(t) # noqa: F821
[docs] def expenses(t): """E[EXP(t)]: maintenance expense in month t **[std]**. ``(c_e / 12)(1 + pi)^k IF(t)``: a round placeholder for in-payment administration, paid monthly while any payment obligation remains. No French insurer publishes expense assumptions. Note that at the composite's 3% *frais d'arrérages* the charge on the worked configuration is about EUR 150 a year against this EUR 30, so the French charging structure recovers far more than in-payment administration and the balance funds distribution and margin. Acquisition cost is out of scope: the premium is single and the cost is priced in. """ return expense_maint / 12.0 * inflation_factor(t) * pols_if(t) # noqa: F821
[docs] def liability_cf(t): """CF(t): total gross liability outgo in month t, the notes' cash flow definition. ``E[ANN] + E[PRO] - E[FRA] + E[EXP]``. **Outgo positive**, the notes' own sign. The *frais d'arrérages* carry a minus because the insurer retains them out of the payment. There is no premium income in the projection - the *capital constitutif* is a pricing input at the effective date - no surrender outgo, because the contract has no surrender value at any duration, and no death capital, the representative design being *capital aliéné*. """ return (annuity_payments(t) + claims(t) - arrerage_charges(t) + expenses(t))
[docs] def net_cf(t): """Net cash flow to the insurer in month t: income less outgo, so ``-liability_cf``. **Income positive**, the sign convention every model in this library carries, kept even though this product has no projected income so that every model's ``net_cf`` can be compared or summed across the library. :func:`liability_cf` carries the opposite, outgo-positive sign of the technical notes; both are published as columns of :func:`result_cf` rather than one being made to stand for the other. """ return -liability_cf(t)
[docs] def check_lives_roll_fwd_resid(t): """Residual between :func:`lives_if` and an independently rebuilt survival path. Deliberately **not** the telescoping identity ``l(t) - d(t) - l(t + 1)``: :func:`lives_death` is *defined* as that difference, so the identity is identically zero whatever :func:`lives_if` returns and constrains nothing. Each life's survival is rebuilt here from the assumptions instead, with no reference to the recursion - on the *table* basis as the **annual** form ``prod (1 - q_x)`` over the completed years of age and ``(1 - q)^(rest/12)`` over the part-year, which the monthly recursion must telescope to. A recursion that reads the age one month early or late, or that indexes the table by the projection year instead of the *millésime*, breaks it. ``t`` is a **time**, as in :func:`lives_if`: the survival rebuilt here is the one to time t. """ res = 0.0 scenario = mort_basis() == "scenario" for life in (1, 2): if life == 2 and reversion_pct() <= 0.0: continue if scenario: d = death_mth(life) built = 0.0 if (d >= 0 and t > d) else 1.0 else: built = 1.0 full = t // 12 rest = t - 12 * full for j in range(full): built *= 1.0 - mort_rate_at_age( sex(life), birth_year(life), age_at_entry(life) + j) if rest: q = mort_rate_at_age( sex(life), birth_year(life), age_at_entry(life) + full) built *= (1.0 - q) ** (rest / 12.0) res += built - lives_if(t, life) return res
[docs] def check_lives_roll_fwd(): """Whether the survival recursion closes at **every** projected month. Takes no argument and returns a ``bool``, the library-wide shape of a ``check_*`` cells, so one test can call the same check across every model. The signed residual of a failing month stays available as :func:`check_lives_roll_fwd_resid`. It sweeps the **time points** the frame reads, ``t = 0 .. proj_len()``: the last projected month, ``proj_len() - 1``, closes at time ``proj_len()``. """ return bool(all(abs(check_lives_roll_fwd_resid(t)) < 1e-10 for t in range(proj_len() + 1)))
[docs] def check_revalo_roll_fwd_resid(t): """Residual between :func:`revalo_factor` and its closed form. The recursion steps whenever ``k(t)`` advances; the closed form is ``(1 + nu (13 - M0)/12)(1 + nu)^(k - 1)`` for ``k >= 1`` and 1 for ``k = 0``. The two are independent constructions of the same index, and the residual catches both of the notes' revalorisation pitfalls: a step taken on the policy anniversary instead of at 31 December moves ``k`` by ``13 - M0`` months, and dropping the first-year pro-rating scales every later month by ``(1 + nu)/(1 + nu (13 - M0)/12)`` for the whole of the annuity's remaining life, because R is a running product. """ k = cal_year_index(t) if k == 0: built = 1.0 else: first = max(0.0, revalo_rate * (13 - effective_month()) / 12.0) # noqa: F821 built = ((1.0 + first) * (1.0 + max(0.0, revalo_rate)) ** (k - 1)) # noqa: F821 return built - revalo_factor(t)
[docs] def check_revalo_roll_fwd(): """Whether the revalorisation index closes against its closed form at every month.""" return bool(all(abs(check_revalo_roll_fwd_resid(t)) < 1e-12 for t in range(proj_len())))
[docs] def check_cum_annuity_roll_fwd_resid(t): """Residual between :func:`cum_annuity_pp` on ``"ANNUITANT"`` and a direct sum. The recursion is rebuilt here as an explicit sum of the scheduled instalments over the payment months up to t, with no reference to the accumulation. A cumulation that starts at the wrong month, double-counts a payment month, or reads ``annuity_pp`` at ``t - 1`` shows up here; the telescoping form would not, because it is the recursion written twice. The first payment month is ``12/m - 1`` in arrears and month 0 in advance. """ step = 12 // payment_freq() start = step - 1 if payment_timing() == "arrears" else 0 built = sum(annuity_pp(s) for s in range(start, t + 1, step)) return built - cum_annuity_pp(t, "ANNUITANT")
[docs] def check_cum_annuity_roll_fwd(): """Whether the cumulative *arrérages* schedule closes at every projected month.""" return bool(all(abs(check_cum_annuity_roll_fwd_resid(t)) < 1e-8 for t in range(proj_len())))
[docs] def check_calendar_index_resid(t): """``k(t) - (calendar_year(t) - Y0)``: two constructions of the same count. :func:`cal_year_index` counts the 31 Decembers from the length of the first partial calendar year, ``13 - M0``; :func:`calendar_year` steps the year from the civil month of the effective date. They are independent and must agree at every month. An implementation that counts anniversaries instead of 31 Decembers disagrees from the first turn of the year. """ return cal_year_index(t) - (calendar_year(t) - effective_year())
[docs] def check_calendar_index(): """Whether the calendar index agrees with the calendar at every projected month.""" return bool(all(check_calendar_index_resid(t) == 0 for t in range(proj_len())))
[docs] def check_payment_factor_resid(t): """``payment_factor(t) - max(gamma(t), l_a)``: the guarantee double-count guard. Zero by construction, and asserted anyway: an additive floor - ``gamma + l_a`` instead of ``max(gamma, l_a)`` - is a listed pitfall and would pay ``1 + l_a`` for the whole guaranteed term. It would show up here as ``min(gamma, l_a)``. """ return payment_factor(t) - max(certain_floor(t), payment_factor_life(t))
[docs] def check_payment_factor(): """Whether the guarantee stays a floor rather than a second stream, at every month.""" return bool(all(abs(check_payment_factor_resid(t)) < 1e-12 for t in range(proj_len())))
[docs] def check_revalo_floor(): """Whether the revalorisation index is non-decreasing at every month. The contractual floor on the uplift is zero in every retrieved formulation, so a *rente* in payment can rise and can stand still but can never fall. A negative ``revalo_rate`` fed into the model must therefore leave the annuity where it was rather than cutting it. The sweep starts at month 1, month 0 having no predecessor. """ return bool(all(revalo_factor(t) >= revalo_factor(t - 1) - 1e-15 for t in range(1, proj_len())))
[docs] def check_options_xor(): """Whether the contract carries at most one of a *réversion* and *annuités garanties*. The representative design offers one or the other, never both, and the coefficients are definitive reductions of the same annuity: compounding two of them would price an option pair no retrieved carrier sells. """ return not (reversion_pct() > 0.0 and guarantee_mths() > 0)
[docs] def check_commutation_floor(): """Whether the gross *quittance* clears the statutory commutation threshold. ``A0 Pi(0) / m > 110 x (12/m)``: below it the insurer may, with the annuitant's agreement, pay a capital instead, so there is no annuity to project. This is an **admission test**, not a cash flow - the one policyholder election that survives conversion - and a model point that fails it is a defect in the table rather than a projection with a small answer. """ gross = annual_income_init() * palier_factor(0) / payment_freq() return bool(gross > commutation_floor * (12.0 / payment_freq())) # noqa: F821
[docs] def check_taux_rente(): """Whether the carried *taux de rente* agrees with the one the tariff table implies. The shipped mortality proxy is anchored so that ``taux_rente_tariff()`` reproduces the technical notes' placeholder rho exactly at the worked configuration, and every other model point carries the rate the same construction gives at its own age, *millésime* and *taux technique*, rounded to six decimals. The check ties the model point table to the mortality table: editing one without the other fails here rather than silently pricing on a basis the projection does not use. """ return bool(abs(taux_rente_tariff() - annuity_rate()) < 1e-6)
[docs] def result_cf(): """Result table of cashflows, indexed by month t. ``pols_if`` is the probability any payment obligation remains, which is the expense weight rather than a policy count. ``arrerage_charges`` is the one column the insurer **retains**: it is positive here and subtracted in ``liability_cf``. Both signs of the net flow are published: ``net_cf`` is income-positive, the library-wide convention, and ``liability_cf`` is the technical notes' outgo-positive ``CF(t)``. The index is the 0-based month, ``t = 0 .. proj_len() - 1``. """ ts = list(range(proj_len())) return pd.DataFrame( # noqa: F821 { "pols_if": [pols_if(t) for t in ts], "annuity_payments": [annuity_payments(t) for t in ts], "claims_prorata": [claims(t, "PRORATA") for t in ts], "arrerage_charges": [arrerage_charges(t) for t in ts], "expenses": [expenses(t) for t in ts], "liability_cf": [liability_cf(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 survival probabilities and payment factors, indexed by month t. ``lives_if_1`` and ``lives_if_2`` are the survival probabilities to the **end** of month t, ``lives_if(t + 1, life)``: :func:`lives_if` is a time-point cells and this is a table of periods, so the row carries the closing value, which is also the point an arrears instalment is measured at. The index is ``t = 0 .. proj_len() - 1``. """ ts = list(range(proj_len())) return pd.DataFrame( # noqa: F821 { "lives_if_1": [lives_if(t + 1, 1) for t in ts], "lives_if_2": [lives_if(t + 1, 2) for t in ts], "revalo_factor": [revalo_factor(t) for t in ts], "palier_factor": [palier_factor(t) for t in ts], "annual_income": [annual_income(t) for t in ts], "certain_floor": [certain_floor(t) for t in ts], "payment_factor": [payment_factor(t) for t in ts], "reversion_factor": [reversion_factor(t) for t in ts], "prorata_factor": [prorata_factor(t) for t in ts], "annuity_pp": [annuity_pp(t) for t in ts], "cum_annuity_all": [cum_annuity_pp(t, "ALL") for t in ts], "pols_if": [pols_if(t) for t in ts], }, index=pd.Index(ts, name="t"), # noqa: F821 )
# --------------------------------------------------------------------------- # References data = ("Interface", ("..", "Data"), "auto") point_id = 1 omega_age = 120 tariff_table_sex = "F" portfolio_male_share = 0.45 rate_loading = 0.0227 revalo_rate = 0.015 commutation_floor = 110.0 expense_maint = 30.0 inflation_rate = 0.015 pd = ("Module", "pandas")