# 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:`~.PER_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 monthly frame
>>> Projection[1].result_state_annual() # the notes' worked-example table
>>> Projection.point_id = 6 # the cell whose annuity is not commuted
.. rubric:: The time index
``t`` counts **plan months** from the valuation date, **0-based**, the same clock
``ADE_FR_S``, ``Dep_FR_S`` and ``Obseques_FR_S`` run on: ``t = 0`` is the first projected
month, month ``t`` runs from time ``t`` to time ``t + 1``, ``pols_if(t)`` is the count at
time ``t`` (so ``pols_if(0) == pols_if_init()``), and the frame is
``t = 0 … proj_len() - 1`` with ``proj_len() = 12 x proj_years()`` the **number** of
projected months. The projection stops there: the plan is liquidated in the last projected
month and nothing this model projects happens after it. An in-force cell opens the frame
at ``t = 0`` like any other — its history is carried in :func:`duration_ifo`, not in a
frame offset, so there is no ``proj_start`` cells here.
Everything contractual about this product is nevertheless on an **annual** cycle — the
*versement*, the glide-path rebalancing, the management charge, the liquidation at the
horizon — so the plan year is derived and used as a lookup key throughout:
``duration_mth(t) = t`` is the months elapsed, ``duration(t) = duration_ifo() +
duration_mth(t) // 12`` the completed *ancienneté* years, ``plan_year(t) = duration(t) + 1``
the contractual 1-based label, ``years_to_horizon(t) = proj_years() - duration_mth(t) // 12``
the key into the glide path, and the attained age ``age(t) = age_init() + duration_mth(t)
// 12``. The frame is indexed by ``t``; the plan year is never indexed by.
**The two annual events of this product land on different months of the plan year**, and
that is why there are two anniversary predicates rather than one.
``is_plan_boy(t)`` — ``duration_mth(t) % 12 == 0`` — marks the month that **opens** a plan
year, where the *versement* arrives and the balance is rebalanced onto the glide path.
``is_anniv(t)`` — ``duration_mth(t) % 12 == 11`` — marks the month that **closes** it,
where the management charge is levied on the post-crediting balance and, at
``t = proj_len() - 1``, where the plan is liquidated. The *garantie plancher* base moves at
those two months and at no other.
The two-speed structure that follows is the library's convention. ``mort_rate(t)``,
``early_release_rate(t)`` and ``transfer_out_rate(t)`` are the **annual** rates of the plan
year containing month ``t`` — the vectors the technical notes tabulate and the CSVs hold —
and ``mort_rate_mth(t)``, ``early_release_rate_mth(t)`` and ``transfer_out_rate_mth(t)``
are the monthly rates actually applied, ``1 - (1 - q)^(1/12)``. The two credited returns
take the same constant-force treatment in interest form, ``(1 + r)^(1/12) - 1``, in
:func:`return_euro_mth` and :func:`return_uc_mth`.
.. rubric:: Input data
Inputs are **external files**: plain CSVs living in the model folder's parent directory,
``products/per_assurance/``, 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
``PER_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:`~.PER_FR_S.Data`,
reached here through the ``data`` Reference:
====================== ================================== ==========================
Reference Cells File
====================== ================================== ==========================
model_point_file data.model_point_table() model_point_table.csv
allocation_grid_file data.allocation_grid() allocation_grid.csv
mort_table_file data.mort_table() mort_table.csv
exit_table_file data.exit_table() exit_table.csv
annuity_factor_file data.annuity_factor_table() annuity_factor.csv
====================== ================================== ==========================
.. rubric:: Naming
Cells names follow lifelib's ``basiclife.BasicTerm_S`` and ``savings.CashValue_SE``
wherever those models have an analogue — ``pols_*`` for policy counts, ``av_*`` for
account values, plural nouns for cash flows, ``*_rate`` for **annual** rates and
``*_rate_mth`` for the monthly ones derived from them, ``*_pp`` for per-policy amounts,
``*_at(t, timing)`` for a quantity read at a point inside the month,
``claims(t, kind)`` and ``claim_pp(t, kind)`` with an uppercase ``kind`` string, and the
``duration_mth`` / ``duration`` / ``plan_year`` triple the monthly models in this library
share. The technical notes use compact symbols instead. The mapping is:
========================= ================================== ==========================
Notes symbol Cells Meaning
========================= ================================== ==========================
(the row of the table) model_point() The selected model point
t (the cells argument) Plan MONTH index, 0-based
T = 12n proj_len() Number of months projected
n proj_years() Number of plan years projected
(t) duration_mth(t) Months elapsed at BOM, = t
k(t) years_to_horizon(t) Whole years to the horizon
(BOM of a plan year) is_plan_boy(t) t % 12 == 0: versement, rebal
(anniversary month) is_anniv(t) t % 12 == 11: charge, horizon
a(t) alloc_euro(t) Target euro share in month t
1 - a(t) alloc_uc(t) Target UC share, from the file
(profile) allocation_profile() Which ladder of the grid
x age_init() Attained age at t = 0
x + t//12 age(t) Attained age; steps at the anniv
duration_ifo duration_ifo() Completed years at t = 0
duration(t) duration(t) Completed years at month t
y(t) = duration(t) + 1 plan_year(t) Plan year of month t, 1-based
V premium_pp(t) Gross versement, at the plan BOM
V_net prem_to_av_pp(t) Versement net of the loading
load load_rate Entry loading, 2.50%
m(t) switch_pp(t) Amount switched, at the plan BOM
arb(t) arbitrage_charge_pp(t) Charge on the switch
arb_rate arb_rate 0.30% of the amount switched
E_eu-(t) av_euro_pp_at(t, "BEF_REBAL") Euro balance carried into t
E_uc-(t) av_uc_pp_at(t, "BEF_REBAL") UC balance carried into t
E_eu(t) av_euro_pp_at(t, "BOM") Euro balance after the BOM steps
E_uc(t) av_uc_pp_at(t, "BOM") UC balance after the BOM steps
r_eu, r_uc return_euro, return_uc ANNUAL gross asset returns
r_eu,m(t) return_euro_mth() (1 + r_eu)^(1/12) - 1
r_uc,m(t) return_uc_mth() (1 + r_uc)^(1/12) - 1
c_eu, c_uc charge_euro, charge_uc Management charges, per year
(gross return credited) inv_income_pp(t) Investment return in month t
(charges levied) mgmt_charge_pp(t) Charge; anniversary month only
A(t) av_pp(t) Account value at EOM t
A-(t) av_pp_at(t, "BEF_REBAL") Value carried into month t
(post-crediting) av_pp_at(t, "BEF_CHARGE") Balance the charge is taken on
(the steps) av_pp_at(t, timing) BEF_REBAL / BOM / BEF_CHARGE /
EOM, inside month t
l(t) x A(t) av_at(t, timing) In-force weighted account
g(t) death_floor_pp(t) Garantie plancher base
g-(t) death_floor_pp_at(t, "BEF_REBAL") Base carried into month t
(cover in force) floor_in_force(t) False from the 70th birthday
max(A(t), g(t)) death_benefit_pp(t) Death benefit, floored
q(t) mort_rate(t) ANNUAL mortality rate
q_m(t) mort_rate_mth(t) The monthly rate applied
w_e(t) early_release_rate(t) ANNUAL deblocage decrement
w_e,m(t) early_release_rate_mth(t) The monthly rate applied
w_r(t) transfer_out_rate(t) ANNUAL transfer-out decrement
w_r,m(t) transfer_out_rate_mth(t) The monthly rate applied
iota(t) transfer_indemnity_rate(t) 1% while y(t) < 5
l-(t) pols_if(t) In force at the START of month t
l(t) pols_if_at(t, "AFT_DECR") In force at the END of month t
(intra-month l) pols_if_at(t, timing) BEF_DECR / BEF_RELEASE /
BEF_TRANSFER / AFT_DECR
d_death(t) pols_death(t) Deaths in month t
d_release(t) pols_release(t) Early releases in month t
d_transfer(t) pols_transfer(t) Transfers out in month t
l(T-1) pols_maturity(t) Survivors settling at t = T - 1
theta annuity_share() Share converted to a rente
a_x annuity_factor() Conversion factor, undiscounted
capital_leg capital_leg_pp() (1 - theta) A(n-1)
annuity_cap annuity_cap_pp() theta A(n-1)
rente_gross rente_gross_pp() annuity_cap / a_x
rente_net rente_net_pp() After the frais d'arrerages
c_arr arrear_charge_rate 1.50% of each instalment
C_thr commute_threshold_mth EUR 110 a month
(the test) is_commuted() Whether the annuity commutes
commuted commuted_pp() The commutation lump sum
(instalment) capital_instalment_pp() Capital leg / instalments
(payouts) claim_pp(t, kind) Benefit per policy by kind
(cash flows) premiums, claims, expenses Probability-weighted flows
annuity_conversion annuity_conversion(t) Capital handed to Rente_FR_S
E(t) expenses(t) Maintenance expense, per month
CF(t) liability_cf(t) The notes' outgo-positive CF
(none) net_cf(t) Its negative, income-positive
(the notes' table) result_state_annual() result_state on the plan year
(none) result_cf_annual() result_cf summed into plan years
========================= ================================== ==========================
Six names needed care.
**There is no** ``lapse_rate`` **and there is no** ``claims_lapse``, and that is a
product statement rather than an omission. The house vocabulary reserves those names for
a contractual surrender right, and this contract has none: the accumulation phase carries
no surrender right except in the statutory cases, the plan being blocked until the
L. 224-1 maturity. The two exits are named for what they are —
:func:`early_release_rate` and :func:`transfer_out_rate`, paying
``claims_early_release`` and ``claims_transfer`` — because they are not the same event
and they do not pay the same amount. An early release requires a listed triggering event,
bears **no charge**, may be partial, and has its main-residence limb closed to
compartment 3. A transfer out ends the plan for this insurer but does **not** take the
savings out of the regime: the *blocage*, the compartments and the exit conditions travel
with the money, and it pays a transfer value that differs from the release amount by the
1% indemnity in the first five years. Using one decrement for both, or calling either a
lapse, silently attaches the wrong payment formula to half the exits.
``pols_if(t)`` is the in force at the **start** of month ``t``, with
``pols_if(0) = pols_if_init()``, and it is the weight on that same ``result_cf()`` row's
cash flows. This is the library's settled convention, shared with ``MYGA_US_S``,
``WP_UK_S`` and every other model in the country libraries: divide a flow by its own
row's ``pols_if`` and you get a per-policy amount for the same period.
**The end-of-period count was renamed.** The technical notes index the in-force
probability at the **end** of the period and call it ``l(t)``; every identity in the notes is written
against that indexing, and the notes' worked-example table prints it. It used to be
published here as ``pols_if(t)``, which put the *next* period's exposure on each
``result_cf()`` row while the flows beside it were weighted, correctly, by the period's
own. Nothing raised and nothing went NaN — the failure was silent, and a reader dividing
a cash flow by that row's ``pols_if`` got a one-period-stale exposure. The quantity
survives unchanged as ``pols_if_at(t, "AFT_DECR")``, in the house ``BEF_*`` / ``AFT_DECR``
timing vocabulary of ``savings.CashValue_SE``, and :func:`result_state` publishes it as
the ``pols_if_eoy`` column so that the notes' table can still be read off the model. The
two series are one period apart: ``pols_if_at(t, "AFT_DECR") == pols_if(t + 1)``. No cash
flow changed: the maturity settlement is taken at
``pols_if_at(proj_len() - 1, "AFT_DECR")`` and the *versement* and the expense at
``pols_if(t)``, exactly the counts they were taken at before.
**The account-value timing strings are month strings.** ``"BOY"`` and ``"EOY"`` became
``"BOM"`` and ``"EOM"`` on the move to the monthly grid, and ``"BEF_CHARGE"`` was added
between them: crediting and charging no longer happen in the same month, so the
post-crediting balance the charge is taken on needed a name of its own. An ``"EOY"``
returned for month five of a plan year would be a label that lies, which is the kind of
staleness this library's docstrings exist to prevent. ``"BEF_REBAL"`` keeps its name and
its meaning — the balance carried in — and the decrement chain's
``BEF_DECR`` / ``BEF_RELEASE`` / ``BEF_TRANSFER`` / ``AFT_DECR`` are untouched.
``switch_pp`` is signed, and the sign decides which balance bears the arbitrage charge,
because the charge is taken from the **source** support in both directions. Under a
de-risking profile ``alloc_euro`` is non-decreasing and the euro support grows more
slowly than the UC bucket, so the switch is positive — UC to euro — in almost every year
of almost every cell. It turns negative where a cell arrives holding **more** euro
support than the grid's minimum asks for, which is what an incoming transfer can do:
model point 2 opens at 40% euro against a 20% minimum nine years out, and its first
rebalancing sells euro down to the grid. See :func:`check_euro_share_min` for what that
direction costs.
``annuity_factor`` is an **undiscounted expected-instalment count**, not a discounted
annuity factor, because a PER tariff may not use a technical rate above 0%. Nothing in
this model discounts it, and :func:`rente_gross_pp` is a plain division. A 2% rate would
shorten the factor to 17.66 and so inflate the annuity — which is ``annuity_cap`` divided
by the factor — by roughly a quarter on the anchor cell, which is the whole reason the
cap exists.
``annuity_conversion`` is an **outgo**, not a memo item. Where the annuity is not
commuted the converted capital leaves this model and is handed to ``Rente_FR_S``, so it
sits inside :func:`liability_cf` alongside the claims. Where it *is* commuted the same
money leaves as ``claims_maturity`` instead, and ``annuity_conversion`` is nil. The two
are mutually exclusive by construction and only one of them is ever non-zero on a cell.
.. rubric:: The glide path is an input table
::
k(t) = n - t // 12
a(t) = allocation_grid[allocation_profile, k(t)].euro_share
The grid's key is **whole years** to the horizon, so ``a(t)`` is constant through the
twelve months of a plan year and steps only at the anniversary. That is also the argument
for keeping the rebalancing annual on a grid that could now express any frequency: a
sub-annual rebalancing re-imposes the *same* target inside the year, so it corrects drift
rather than de-risking faster. See the rebalancing rubric below for what it would cost.
The *équilibré* ladder is 0% for ``k > 10``, 20% for ``10 >= k > 5``, 50% for
``5 >= k > 2`` and 70% for ``k <= 2``, and **the boundary belongs to the tighter band**
**[std]**. That convention is the model's own. The arrêté's part (a) grid *was* extracted,
percentages and band headings both, but the headings as rendered read "≥ 10 years out"
and "from 10 years out" — which overlap at ``k = 10`` and so do not say which side of a
boundary year belongs to which band. Reading the boundaries the other way understates the
euro share for a full year at each of three transitions. :func:`alloc_euro` reads the file
rather than computing anything, so an insurer ladder on twenty one-year bands, or one of
the other three qualified profiles, substitutes without touching a formula.
"Low risk" is realised wholly as the euro support **[std]**. The definition of *actifs
présentant un profil d'investissement à faible risque* is delegated to an arrêté that was
not retrieved, and the two contract definitions found disagree — SRRI at most 3 against
at most 2 including the euro fund. Realising the bucket as the euro support is the most
conservative reading of both and keeps the model to two supports.
.. rubric:: The rebalancing, and which support pays for it
::
m(t) = a(t) A-(t) - E_eu-(t) (is_plan_boy(t) only)
arb(t) = arb_rate |m(t)|
E_eu = E_eu-(t) + m(t) + a(t) V_net (m >= 0)
E_uc = E_uc-(t) - m(t) - arb(t) + (1 - a(t)) V_net
with the roles of the two supports exchanged when ``m(t) < 0``, and with ``m(t)``,
``arb(t)`` and ``V_net`` all nil in the eleven months of the plan year that are not its
first. Two conventions, both **[std]** and both load-bearing.
A trailing ``-`` marks a balance **carried into** month ``t``: the model point's opening
state at ``t = 0`` and the previous month's closing balance at every later ``t``. That is
the ``"BEF_REBAL"`` timing of :func:`av_euro_pp_at`, :func:`av_uc_pp_at`,
:func:`av_pp_at` and :func:`death_floor_pp_at`, and it is the one place the frame's
opening state enters. Nothing in this model is indexed at ``t = -1``.
The arbitrage charge is taken from the **source** support, so the destination receives
the full switch and, on a de-risking switch, the post-rebalancing euro share lands at or
just above the regulatory minimum rather than just below it. On the anchor cell's band
crossing, the rebalancing month ``t = 84``, the BOM euro share is 50.0427% against a 50%
target, and taking the charge from the destination instead would put it under, at every
crossing, by the charge.
The two halves of that sentence come apart on a **reverse** switch, and the notes do not
say which half wins. Where the euro support is the source, charging the source leaves the
share below the minimum by ``(1 - a) arb`` — 9.60 on 19 988 for model point 2, which is
0.05% of the balance. This model implements the notes' formula literally, symmetric in
the two supports, rather than quietly charging the UC side in both directions to keep the
share above the line; :func:`check_euro_share_min` therefore asserts the property that is
actually true, direction by direction, instead of one that holds only one way. A firm
resolving the gap the other way would change one branch of :func:`av_euro_pp_at` and
nothing else.
The *versement* is allocated **directly at the target mix** and bears no arbitrage
charge: new money is not a switch. :func:`arbitrage_charge_pp` is therefore nil in a plan
year where the account opens exactly on target even though a *versement* was paid — the
first two plan years of the anchor cell, months ``t = 0`` and ``t = 12``, where the
*équilibré* grid asks for no euro support at all.
The minimum binds **at the rebalancing date**, not continuously, and the monthly grid is
what makes that sentence bite. Between dates the mix drifts with relative performance, and
there are now **eleven months** of drift rather than an instant: the anchor cell is at
70.0006% euro after the rebalancing that opens its last plan year, month 132, and 69.67%
at the anniversary, month 143. :func:`check_euro_share_min` therefore iterates over
``is_plan_boy`` months alone; looping it over the whole frame fails on every model point
while nothing is wrong, and re-imposing the target every month would invent a rebalancing
frequency the contract does not have. Measured, that alternative is small but no longer
unmeasurable: moving the anchor cell to semi-annual, quarterly or monthly rebalancing
changes its final balance by −0.0095%, −0.0142% and −0.0175% and its twelve-year arbitrage
charge from 95.75 to 96.06, 96.21 and 96.40.
.. rubric:: The garantie plancher is not a floor at gross premiums
::
g(t) = g-(t) + V_net - arb(t) - mgmt_charge_pp(t)
*Versements* net of entry loading, less the management charges levied over the plan's
life, less benefits already paid. That is **one of two contractual draftings in the
sample** and the one this model implements: the anchor contract's, with no interest limb,
and another contract says expressly that its guarantee is not a floor at gross premiums.
A second contract drafts the same guarantee with the euro fund's interest net of
management charges *added*, which accumulates faster and is not what is computed here —
the technical notes carry the comparison. It follows that
::
A(t) - g(t) = [A-(0) - g-(0)] + sum of gross investment return credited to date
so the floor bites only where cumulative investment return is negative.
:func:`check_floor_identity` asserts that, and it is the check that catches the wrong
drafting: a floor accumulated at gross ``V`` rather than ``V_net``, or one that forgets
the arbitrage charge, or one charged something other than what the account was actually
charged, all break the identity in the first year. On the anchor cell the floor never
bites — the gap is 22 821.04 at the horizon against an opening gap of 600.00 — and model
point 10 is the same cell with a higher opening floor, where it bites for two years until
cumulative investment return overtakes it.
The cover ceases at the member's **70th birthday** and is capped at €762 245 across
contracts. Both are contractual, both are in :func:`death_benefit_pp`, and the age-70
cliff is not incidental: it is also the age at which a PER death benefit stops being
taxed as life insurance and enters the inheritance-duty base in its entirety.
.. rubric:: What the monthly grid changes, and what it does not
**A monthly grid is not a monthly product.** Every contractual mechanic of this plan is
annual and the model keeps all of them there: the *versement* and the glide-path
rebalancing land whole in the month that opens the plan year, the management charge lands
whole in the month that closes it, and the liquidation lands whole at the horizon. What
the finer grid adds is everything that is *not* contractually annual — exits settled in
the month they happen and valued on the balance held **then**, the two supports accruing
month by month so that a mid-year exit is not paid a year-end balance, and maintenance
expense falling where it is incurred.
Four timing decisions were not forced by the sources and are recorded here because they
are the ones a reader would otherwise have to reverse-engineer.
**The two credited returns are spread, geometrically.** ``(1 + r)^(1/12) - 1`` each month
**[std]**, not ``r / 12``. The euro fund is contractually credited once a year with an
*effet cliquet*, which argues for the anniversary; but every sampled contract that says
what happens to a mid-year exit revalues it *pro rata temporis* at the served rate, or
credits weekly, or compounds daily. Spreading is the monthly realisation of that, and
spreading *geometrically* is what makes twelve months compound back to the published
annual rate exactly.
**The management charge is NOT spread.** It falls whole in the anniversary month, on the
post-crediting balance, which is the [S3] convention the product spec already adopts. This
is the decision the conversion turns on. Levying it monthly would leave the account value
at the anniversary unchanged — the monthly factors still compound — but the *garantie
plancher* base accumulates a **sum** of charges rather than a product of factors, and
twelve monthly charges do not add to the annual one: measured, the base at the anniversary
moves by up to 71.95 EUR on the anchor cell, 88.13 EUR on model point 3 and 531.57 EUR on
model point 12. The price of the decision is stated rather than hidden: a mid-year exit is
valued **gross** of the plan year's charge, which on the anchor cell's last plan year is
0.387% above the anniversary value at month 142. A monthly levy remains a documented
variant; it is a new assumption, not a finer grid.
**The rebalancing stays annual, and is now a choice rather than a constraint.** See the
rebalancing rubric for the measured cost of the alternatives.
**The cover cessation at 70 stays a plan-year rule.** :func:`floor_in_force` tests
``age(t) + 1 < 70``, so the cover is off for the whole plan year that ends on the 70th
birthday — the notes' age basis is an integer attained age incremented once per plan year,
and a month-exact rule would need a sub-annual age the product does not have.
.. rubric:: Anniversary equivalence
Because the three monthly decrement rates compound back to their annual values, the two
monthly credit factors compound back to theirs, and the charge sits at the year boundary,
the recursion collapses over the twelve months of a plan year to the annual-step
recursion, term for term. **Every anniversary state and the entire settlement therefore
reproduce the annual-step model this one replaced, to floating point** — measured across
all twelve shipped model points against a pre-conversion snapshot, worst relative
deviation 1.8e-14 on ``av_euro_pp``, ``av_uc_pp``, ``av_pp``, ``death_benefit_pp`` and
``mgmt_charge_pp`` at ``t = 12y + 11``, 2.5e-15 on ``death_floor_pp``, 1.4e-14 absolute on
the in force, and the same on every field of :func:`result_settlement`. So do the
plan-year totals of ``inv_income_pp`` and ``premiums``, and every quantity that is one
value per plan year — ``years_to_horizon``, ``alloc_euro``, ``switch_pp``,
``arbitrage_charge_pp``, ``mort_rate``, ``plan_year``, ``age``.
The cash flows are not identical and are not meant to be. Claims now fall at the end of
the month of exit and are valued on the balance held then, so on the anchor cell
``claims_death`` falls 2.49%, ``claims_early_release`` 1.54% and ``claims_transfer`` 0.35%;
maintenance expense accrues monthly on a decrementing block, so the aggregate falls 0.61%
while the per-policy total rises 0.82% on the monthly-compounded inflation factor; and the
**split** between the three decrements moves — deaths −1.19%, releases −0.23%, transfers
+0.98% over the anchor's first plan year — while their total does not, because the chain is
walked twelve times with small steps instead of once with large ones. ``liability_cf`` and
``net_cf`` follow. Those timing differences are the reason for the finer grid.
:func:`result_cf_annual` and :func:`result_state_annual` sum and regroup the frame into
plan years so that the two grids can be laid side by side.
.. rubric:: Settlement: a capital leg, and a rente that usually is not one
At ``t = proj_len() - 1``, the last projected month and the horizon anniversary, the
survivors settle. The settlement is a contractual event on a contractual date and is
**not spread** over the final plan year: the capital leg, the annuity conversion and the
commutation test all fall whole in that month, on the survivors of that month, and every
figure in :func:`result_settlement` is the annual-step model's to floating point. The
capital leg bears **no exit charge**. The annuity leg is converted at :func:`annuity_factor`, charged the
*frais d'arrérages*, and then tested against the €110 monthly *quittance* threshold —
remembering that €110 is a **monthly** figure scaled by the months in the payment period,
so an annual frequency tests against €1 320.
Commuting at the conversion basis returns the converted capital less the *arrérage*
charge exactly::
commuted = rente_net a_x = annuity_cap (1 - c_arr)
:func:`check_commutation_identity` asserts it. Commutation is therefore nearly
value-neutral, which is why it is common — a quarter of 2024 individual-PER benefit
amounts were small annuities commuted at outset — and why commuting at a *book* value
instead would manufacture a gain out of nothing. On the anchor cell the annuity is
€78.45 a month against the €110 threshold and duly commutes; the cliff sits at
``annuity_share = 42.06%``, and model point 6 is the same cell at 50%, where the annuity
is €130.75 a month, is not commuted, and leaves as ``annuity_conversion``.
.. rubric:: What is simplified, and where the rest of it lives
**The rente is cross-referenced, not re-implemented.** Where the annuity is not commuted
this model hands ``annuity_conversion`` to ``Rente_FR_S`` and records the amount. The
annuity reserve, the 0.80% p.a. charge on annuity reserves, reversion, *annuités
garanties* and revaluation through the profit-sharing account are specified in
``products/rente_viagere/technical-notes.md``, and duplicating the payout chassis here
would give the library two of them to keep in step.
**Staged capital is settled at the horizon.** The *capital fractionné* option changes
*when* the capital leg is paid, not how much: there is no exit charge, and the technical
notes fix the frame at the declared horizon. The finer grid does not make the *fractionné*
schedule expressible, because its instalments fall **after** the horizon and the frame ends
there — the reason for the simplification is the frame's end, not the grid's coarseness.
The model therefore records the whole capital leg at ``t = proj_len() - 1``, publishes the instalment size as
:func:`capital_instalment_pp`, and credits nothing to the unpaid balance — a **[std]**
simplification that is exact on an undiscounted gross-cash-flow basis and is not exact
for anything that discounts. A discounting layer consuming these flows needs the
schedule, and this is where it would go.
**No PPB stock.** The euro support is credited at the asset return and charged on the
post-crediting balance; no *provision pour participation aux bénéfices* is carried. The
effective euro rate net of charge is ``1.0338 x 0.9930 - 1 = 2.6563%`` — not
``3.38 - 0.70 = 2.68%``, and not the 2.75% actually served in 2025, whose extra seven
basis points came from a PPB release this model does not carry. A PER's PPB release
horizon is fifteen years rather than eight, because the commitments sit in a
*comptabilité auxiliaire d'affectation*; modelling that stock is a fund-level scenario
extension, and four of the seven sampled contracts have no contractual profit-sharing
clause at all.
The machinery this stands in for is implemented next door in ``Euro_FR_S`` and specified
in ``products/assurance_vie_euro/technical-notes.md`` — the *compte de participation aux
résultats*, the PPB dotation-and-release lever and its vintage clock. Two things change on
the way across: the release deadline is eight years there and fifteen here, and a PER euro
fund carries a *garantie plancher* and a glide path that the euro-fund product does not.
Take the crediting chassis from those notes, not the liability.
**Tax is outside the projection.** The deductibility election changes the *holder's* exit
taxation, not the insurer's gross benefit. :func:`deduction_elected` is carried on the
model point so a downstream tax layer can find it and enters no cash flow anywhere in
this Space.
Also out of scope, per the notes: partial early release leaving the plan in force, the
15% transfer-value reduction on euro-denominated rights, profile and horizon changes
during the projection — which is the largest behavioural lever on this product and has no
public calibration — the unlisted-asset minimum inside the UC bucket, and the *provision
de diversification* supports, which are the ``eurocroissance`` product.
"""
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 model point; printed, never used in a formula."""
return str(model_point()["policy_id"])
[docs]
def sex():
"""The sex (M / F) of the member, the key into the mortality and annuity tables."""
v = str(model_point()["sex"])
if v not in ("M", "F"):
raise ValueError("invalid sex")
return v
[docs]
def age_init():
"""x: the attained age of the member at ``t = 0``, integer **[std]**.
Attained age at the valuation date, incremented once per plan year. No retrieved
French document fixes a model age basis; the regulatory non-annuity tables are
applied with the annexed *décalage d'âge* age shifts, which the shipped proxy does
not reproduce.
"""
return int(model_point()["age"])
[docs]
def retirement_age():
"""The declared *horizon* — the retirement age the member has declared.
The holder may move it at any time, which re-cuts the whole allocation immediately;
the base model holds it fixed and a scenario overlay would shift it and re-read the
grid. It is the single most important number on the model point: it sets both the
length of the projection and, through ``years_to_horizon``, every year's target mix.
"""
return int(model_point()["retirement_age"])
[docs]
def duration_ifo():
"""Completed years since the **first *versement*** at the valuation date.
Not the same thing as elapsed projection years, and the distinction is the whole
content of the transfer indemnity window: the 1% indemnity runs to the fifth
anniversary of the first *versement*, not of the projection start. The anchor cell
carries 2, so its window closes after two projected years rather than five.
"""
return int(model_point()["duration_ifo"])
[docs]
def compartment():
"""``c1``, ``c2`` or ``c3`` — the origin of the money, and two operative rules.
Compartment 3 holds rights arising from compulsory contributions, and it changes two
things rather than one: those rights may be delivered **only** as a life annuity, so
:func:`exit_form` refuses any other election on a c3 cell; and they are **excluded**
from the main-residence early-release case, which is the only discretionary limb of
the seven, so ``exit_table.csv`` carries a lower ``early_release_rate`` for them.
"""
v = str(model_point()["compartment"])
if v not in ("c1", "c2", "c3"):
raise ValueError("invalid compartment")
return v
[docs]
def allocation_profile():
"""The *gestion pilotée par horizon* profile: which ladder of the grid applies.
``prudent``, ``equilibre``, ``dynamique`` or ``offensif`` — the four qualified
profiles of the de-risking arrêté. Validated against the shipped grid rather than
against a hard-coded list, so adding an insurer profile to ``allocation_grid.csv``
is enough to make it usable.
"""
v = str(model_point()["allocation_profile"])
if v not in set(data.allocation_grid().index.get_level_values(0)): # noqa: F821
raise ValueError("allocation_profile not in allocation_grid.csv")
return v
[docs]
def premium_init():
"""V: the gross annual *versement*, paid at the start of each plan year.
Zero on a cell that has stopped contributing — a c2 or c3 compartment reached by an
incoming transfer holds acquired rights and receives nothing further.
"""
return float(model_point()["premium"])
[docs]
def av_euro_init():
"""The per-policy euro-support balance carried into the projection."""
return float(model_point()["av_euro_init"])
[docs]
def av_uc_init():
"""The per-policy *unités de compte* balance carried into the projection."""
return float(model_point()["av_uc_init"])
[docs]
def death_floor_init():
"""g-(0): the *garantie plancher* base carried into the projection.
On the anchor cell it is €16 000 against a €16 600 balance, and the €600 gap is the
investment return accumulated before the valuation date. A cell whose accumulated
return is *negative* carries a base **above** its balance, which is the only state in
which the floor bites; model point 10 is that cell.
"""
return float(model_point()["death_floor_init"])
[docs]
def death_floor_flag():
"""Whether the *garantie plancher* is in force on this cell at all.
Included as standard in two sampled contracts, optional in three and an age-rated
rider in one, so it is a model point column rather than a product constant.
"""
return bool(model_point()["death_floor_flag"])
[docs]
def annuity_share():
"""theta: the share of the settlement balance converted to a *rente viagère*.
Derived from :func:`exit_form` and cross-checked against the model point column, so
the table cannot say ``capital_single`` in one column and 30% in the next. A
``mixed`` election must be strictly between the two ends; at either end it is one of
the pure forms and should say so.
"""
theta = float(model_point()["annuity_share"])
form = exit_form()
if form == "mixed":
if not 0.0 < theta < 1.0:
raise ValueError("a mixed exit needs 0 < annuity_share < 1")
return theta
expected = 1.0 if form == "annuity" else 0.0
if abs(theta - expected) > 1e-12:
raise ValueError("annuity_share contradicts exit_form")
return expected
[docs]
def capital_instalments():
"""The number of annual instalments the capital leg is paid in.
1 under ``capital_single``, and the *fractionné* count under ``capital_staged``. It
changes :func:`capital_instalment_pp` and nothing else — see the Space docstring on
why staged capital is settled at the horizon.
"""
j = int(model_point()["capital_instalments"])
if j < 1:
raise ValueError("capital_instalments must be at least 1")
if j > 1 and exit_form() != "capital_staged":
raise ValueError("only a capital_staged exit is paid in instalments")
return j
[docs]
def deduction_elected():
"""Whether the holder deducted the *versements* from taxable income.
**Carried and never used.** The election is the pivot of the whole exit tax
treatment — pension regime with the 10% abatement against *rente viagère à titre
onéreux* on an age-graded fraction — but it changes what the holder keeps, not what
the insurer pays. It appears in no recursion in this Space and exists so a
downstream tax layer can find it.
"""
return bool(model_point()["deduction_elected"])
[docs]
def mort_basis():
"""``flat`` or ``table``: which mortality basis this cell is run on.
``flat`` reads the model point's own :func:`mort_rate_flat`, which is what the
technical notes' worked example specifies — a flat 0.00500 placeholder, held constant
across the twelve years so that the printed ``pols_if`` column can be re-derived by
hand. ``table`` reads the shipped **[std]** proxy at the attained age.
The two are anchored to agree in the anchor cell's **first** year: the table's level
is set so that ``mort_be_factor x q(M, 52)`` is exactly 0.00500. They diverge after
that, because a table has a slope and a placeholder does not.
"""
v = str(model_point()["mort_basis"])
if v not in ("flat", "table"):
raise ValueError("invalid mort_basis")
return v
[docs]
def mort_rate_flat():
"""The flat annual mortality rate of a ``flat``-basis cell **[std]**.
0.00500 on the worked-example cells. No sampled contract publishes a mortality
basis, and the one published rate card in the sample is a *gross premium* scale on a
no-underwriting death rider, which must not be read as one.
"""
return float(model_point()["mort_rate_flat"])
[docs]
def pols_if_init():
"""l-(0): the in-force probability carried in; 1.0 on a single-policy model point."""
return float(model_point()["pols_if_init"])
[docs]
def proj_years():
"""n: the number of projected **plan years** — the declared horizon.
``retirement_age - age_init()``. The contract states the horizon in whole years and
this cells keeps it in years: it is what :func:`years_to_horizon` counts down, and
``allocation_grid.csv`` is keyed by it. :func:`proj_len` multiplies it by twelve to
get the frame. The validation lives here because this is where the horizon is read.
"""
n = retirement_age() - age_init()
if n < 1:
raise ValueError("the declared horizon is not in the future")
return n
[docs]
def proj_len():
"""T: the **number** of projected plan **months**, ``12 x proj_years()``.
The frame is ``t = 0 … proj_len() - 1``, so ``T`` is a row count and not a row label
and ``len(result_cf()) == proj_len()``: the last projected month is ``t = T - 1``, the
horizon anniversary. The plan is liquidated there and nothing this model projects
happens afterwards: no *versement* arrives, ``years_to_horizon`` does not go negative,
and a staged capital settlement is still recorded at ``t = T - 1``.
:func:`check_horizon` asserts it. On the anchor cell ``proj_years() = 12`` and
``proj_len() = 144``.
"""
return 12 * proj_years()
[docs]
def duration_mth(t):
"""Months elapsed from the valuation date at the start of month t; equal to ``t``.
``t`` is 0-based and counts from the valuation date, so the identity is trivial — the
cells exists so the monthly models in this library share one vocabulary, and so that
:func:`duration` and :func:`age` read as ``duration_mth(t) // 12`` rather than as a
bare ``t // 12``. An in-force cell opens the frame at ``t = 0`` like any other: its
history is carried in :func:`duration_ifo`, not in a frame offset.
"""
return t
[docs]
def age(t):
"""The attained age in month t: ``x + duration_mth(t) // 12``.
The age **steps at the plan anniversary** — not on the birthday and not monthly. It
is the attained age at the valuation date incremented once per plan year **[std]**,
which is the notes' age basis and the only basis the shipped tables are keyed on.
Built from ``duration_mth(t) // 12`` and **not** from :func:`duration`, which carries
the cell's :func:`duration_ifo` *ancienneté* and is a different clock: an in-force cell
is three plan years old and still 52.
Every month of a plan year reads :func:`mort_rate` at ``age(t)``, the age the year
opens with, and the *garantie plancher* ceases when the member turns 70 — tested at
the end-of-year age ``age(t) + 1``, so the cover is off for the whole plan year that
ends on the 70th birthday.
"""
return age_init() + duration_mth(t) // 12
[docs]
def duration(t):
"""Completed years since the first *versement* at the start of month t.
``duration_ifo + duration_mth(t) // 12``, 0-based in lifelib's sense: nil through the
plan's first year. It steps once a plan year, at the anniversary, and is constant
through the twelve months in between. Through :func:`plan_year` it keys the exit
decrements and the five-year transfer indemnity window.
"""
return duration_ifo() + duration_mth(t) // 12
[docs]
def plan_year(t):
"""y(t): the plan's own year containing month t, 1-based — ``duration(t) + 1``.
The contractual label the *ancienneté* schedules are written in: the plan is in its
first year while ``duration(t) = 0``. It is **not** the projection index ``t``, it is
derived from it and never indexed by, and on an in-force cell it is ahead of the
projected plan year by ``duration_ifo``.
It is what ``exit_table.csv`` is keyed by — that file's ``duration`` column runs
``1, 2, …`` and its first row is the plan's first year — and what the transfer
indemnity window is measured in.
"""
return duration(t) + 1
[docs]
def years_to_horizon(t):
"""k(t): the whole years remaining to the declared horizon in month t.
``n - duration_mth(t) // 12``, so ``k = n`` through the twelve months of the first
plan year and ``k = 1`` through the twelve months of the last. It is **constant
inside a plan year**, because ``allocation_grid.csv`` is keyed by whole years to the
horizon: the glide path steps at the anniversary and nowhere else.
Floored at zero rather than allowed to go negative, because a plan does not run past
its own horizon and a negative key into the glide path is a symptom rather than a
value.
"""
return max(0, proj_years() - duration_mth(t) // 12)
[docs]
def is_plan_boy(t):
"""True in the **first month of a plan year**: ``duration_mth(t) % 12 == 0``.
The month the plan-year cycle opens, and where the two events that open it land whole:
the *versement* arrives and the glide path is re-read and the balance rebalanced onto
it. It is **not** the same month as :func:`is_anniv`, and that is why both exist —
the annual events of this product sit at opposite ends of the plan year.
"""
return duration_mth(t) % 12 == 0
[docs]
def is_anniv(t):
"""True in the **last month of a policy year**: ``duration_mth(t) % 12 == 11``.
The month the plan-year cycle closes, and where the management charge is levied whole
on the post-crediting balance; at ``t = proj_len() - 1`` it is also where the plan is
liquidated. The anniversary date itself sits between ``is_anniv(t)`` and
``is_plan_boy(t + 1)``.
"""
return duration_mth(t) % 12 == 11
[docs]
def alloc_euro(t):
"""a(t): the target euro (low-risk) share in month t, read from the grid.
A lookup, not a formula, and one that steps **once a plan year**: its key
:func:`years_to_horizon` is whole years, so every month of a plan year reads the same
row and the glide path never moves between anniversaries. The *équilibré* ladder is 0 / 20 / 50 / 70% as the horizon
closes, and **the boundary belongs to the tighter band** — ``k = 10`` reads 20%,
``k = 5`` reads 50%, ``k = 2`` reads 70% — a **[std]** convention, because the
arrêté's band headings as extracted overlap at each boundary year and do not settle
which band it falls in.
Years beyond the last row of the grid take that row, which is the ``k > 10`` band in
the shipped file.
"""
grid = data.allocation_grid().loc[allocation_profile()] # noqa: F821
k = max(1, min(years_to_horizon(t), int(grid.index.max())))
return float(grid.loc[k, "euro_share"])
[docs]
def alloc_uc(t):
"""1 - a(t): the target *unités de compte* share in month t, from the grid's column.
Constant through a plan year, like :func:`alloc_euro`. Read rather than computed, so that the file's two columns are both live and a grid
whose rows do not close is caught by :func:`check_glide_path_closes` instead of being
silently half-ignored.
"""
grid = data.allocation_grid().loc[allocation_profile()] # noqa: F821
k = max(1, min(years_to_horizon(t), int(grid.index.max())))
return float(grid.loc[k, "uc_share"])
[docs]
def premium_pp(t):
"""V: the gross *versement* received at the beginning of month t.
The *versement* is an **annual** amount and falls whole in the month that opens each
plan year, ``is_plan_boy(t)``; it is nil in the other eleven **[std]**. The model
point column is an amount per year, the entry loading is a flat percentage that does
not vary with frequency in any sampled *notice*, and no sourced modal factor exists
for this product — so collecting it in instalments would be a new assumption rather
than a finer grid. Monthly *versements programmés* are common in the market and are a
documented extension: a ``premium_freq`` model point column and a modal factor, not a
change here.
Nil after the horizon. A plan that has been liquidated takes no further
contributions, and from 1 January 2026 contributions after the holder's 70th birthday
stop being deductible in any case.
"""
if t < 0 or t >= proj_len() or not is_plan_boy(t):
return 0.0
return premium_init()
[docs]
def prem_to_av_pp(t):
"""V_net: the *versement* net of the entry loading, available to allocate.
``V (1 - load)``, at 2.50% **[std]**, and therefore nil in eleven months of twelve. The sampled entry loadings span 0% to 4.80%
and the *encadré* discloses maxima rather than capping levels, so this is a
standardization and not a contractual constant. It is also the amount that accrues
to the *garantie plancher* base — the guarantee is a floor at *versements net of
loading*, never at gross premiums.
"""
return premium_pp(t) * (1.0 - load_rate) # noqa: F821
[docs]
def switch_pp(t):
"""m(t): the gross amount switched between supports at the plan-year rebalancing.
``a(t) A-(t) - E_eu-(t)`` in the month that opens a plan year, and **nil in the other
eleven**: the rebalancing is a contractual annual event and is not spread over the
year **[std]**. It could be — the finer grid can express any frequency — but
``allocation_grid.csv`` is keyed by whole **years** to the horizon, so a sub-annual
rebalancing re-imposes the *same* target inside the year: it corrects drift rather
than de-risking faster, and it is an assumption change and not a grid change.
What it is: what it takes to bring the balance carried into the month — the model
point's opening state at ``t = 0``, last month's closing balance afterwards — onto the
plan year's target mix. **Signed** — positive on the ordinary de-risking switch from
UC to euro, negative if the euro support has run above target — and the sign decides
which support bears :func:`arbitrage_charge_pp`.
Note what it is measured on: the **carried-in balance alone**. The year's
*versement* is allocated at the target mix directly and is not part of the switch,
which is why a year that opens exactly on target carries no arbitrage charge however
large the contribution.
"""
if not is_plan_boy(t):
return 0.0
return (alloc_euro(t) * av_pp_at(t, "BEF_REBAL")
- av_euro_pp_at(t, "BEF_REBAL"))
[docs]
def arbitrage_charge_pp(t):
"""arb(t): the *frais d'arbitrage* on the plan year's rebalancing.
``arb_rate |m(t)|`` at 0.30% **[std]**, and therefore nil outside the rebalancing
month, where ``m(t)`` is itself nil. Horizon arbitrage is free at five of the
eight sampled contracts and charged at 0.30% and 1% at the other two; a non-nil rate
makes the cost of the glide path a visible line rather than an invisible one.
Taken from the **source** support — see :func:`av_euro_pp_at`.
"""
return arb_rate * abs(switch_pp(t)) # noqa: F821
[docs]
def return_euro_mth():
"""r_eu,m: the monthly euro-support return, ``(1 + r_eu)^(1/12) - 1`` **[std]**.
0.277395% at the base run's 3.38% annual rate. Derived **geometrically** and not by
dividing by twelve, so that twelve months compound back to exactly the annual rate the
(b)-table publishes — ``r_eu / 12`` would credit 3.4335% a year against a stated
3.38%, and would move every anniversary balance off the annual model's.
The euro fund is contractually credited once a year with an *effet cliquet*, which
argues for the anniversary; but every sampled contract that says what happens to a
**mid-year exit** revalues it *pro rata temporis* at the served rate, credits weekly
and definitively each Friday, or compounds daily. Spreading geometrically is the
monthly realisation of exactly that: without it, every mid-year death, release and
transfer would be paid on a balance carrying no return since the last anniversary.
"""
return (1.0 + return_euro) ** (1.0 / 12.0) - 1.0 # noqa: F821
[docs]
def return_uc_mth():
"""r_uc,m: the monthly UC return, ``(1 + r_uc)^(1/12) - 1`` **[std]**.
0.407412% at the base run's 5.00% annual rate. Same geometric conversion as
:func:`return_euro_mth`, and for the same reason: unit values move continuously, and
twelve months of it compound back to the annual rate exactly.
"""
return (1.0 + return_uc) ** (1.0 / 12.0) - 1.0 # noqa: F821
[docs]
def av_euro_pp_at(t, timing):
"""The per-policy euro-support balance at a point inside month t.
``"BEF_REBAL"``
the balance **carried into** the month, before any rebalancing and
before any *versement*: :func:`av_euro_init` in the first projected
month, ``av_euro_pp(t - 1)`` afterwards. This is where the frame's
opening state enters, so that nothing is indexed at ``t = -1``.
``"BOM"``
after the rebalancing and the allocation of the *versement*, both of
which happen only in the month that opens a plan year. The switch
arrives in full and the arbitrage charge is deducted here **only
when the euro support is the source**, that is when ``m(t) < 0``.
In the other eleven months ``m``, ``arb`` and ``V_net`` are all nil
and this equals ``"BEF_REBAL"``.
``"BEF_CHARGE"``
after the month's investment return at :func:`return_euro_mth`,
before the management charge. Every month credits; only the
anniversary month charges, so this timing is where the two are
separated.
``"EOM"``
after the management charge, which is levied **only in the
anniversary month** on this post-crediting balance; the same number
as :func:`av_euro_pp`. In the other eleven months it equals
``"BEF_CHARGE"``.
The three timings renamed on the move to the monthly grid — ``"BOY"`` and ``"EOY"``
marked points in a *year* and would now be labels that lie. ``"BEF_CHARGE"`` is new
and exists because crediting and charging no longer happen in the same month.
The euro support is **not** monotone under this convention, and it is worth being
exact about why. The 0% of A. 142-1 is the *maximum* technical rate a PER tariff may
use, not a floor on what is credited; what the sampled contracts guarantee is a
capital floor gross of charges plus profit sharing, not an accumulation rate. Since
the charge is taken on the post-crediting balance, the balance grows over a plan year
only while ``r_eu > charge_euro / (1 - charge_euro)`` — 0.7049% here. The base run's
3.38% clears that comfortably, so the euro support does rise every year; at a credited
rate of 0% it would fall by the 0.70% charge.
"""
if timing == "BEF_REBAL":
if t <= 0:
return av_euro_init()
return av_euro_pp(t - 1)
if timing == "BOM":
m = switch_pp(t)
bom = (av_euro_pp_at(t, "BEF_REBAL") + m
+ alloc_euro(t) * prem_to_av_pp(t))
if m < 0.0:
bom -= arbitrage_charge_pp(t)
return bom
if timing == "BEF_CHARGE":
return av_euro_pp_at(t, "BOM") * (1.0 + return_euro_mth())
if timing == "EOM":
if not is_anniv(t):
return av_euro_pp_at(t, "BEF_CHARGE")
return (av_euro_pp_at(t, "BEF_CHARGE")
* (1.0 - charge_euro)) # noqa: F821
raise ValueError("invalid timing")
[docs]
def av_uc_pp_at(t, timing):
"""The per-policy *unités de compte* balance at a point inside month t.
Same four timings as :func:`av_euro_pp_at`, and the mirror image of it: the UC bucket
bears the arbitrage charge on the ordinary de-risking switch, where it is the source.
``"BEF_REBAL"`` is :func:`av_uc_init` in the first projected month and
``av_uc_pp(t - 1)`` afterwards; ``"BEF_CHARGE"`` credits :func:`return_uc_mth` every
month; ``"EOM"`` charges only in the anniversary month.
There is no guarantee on this bucket at all. The insurer commits to the **number**
of units, not to their value, and ``return_uc`` is stated net of the fund-level
charges inside the units, which are large relative to the wrapper charge.
"""
if timing == "BEF_REBAL":
if t <= 0:
return av_uc_init()
return av_uc_pp(t - 1)
if timing == "BOM":
m = switch_pp(t)
bom = (av_uc_pp_at(t, "BEF_REBAL") - m
+ alloc_uc(t) * prem_to_av_pp(t))
if m >= 0.0:
bom -= arbitrage_charge_pp(t)
return bom
if timing == "BEF_CHARGE":
return av_uc_pp_at(t, "BOM") * (1.0 + return_uc_mth())
if timing == "EOM":
if not is_anniv(t):
return av_uc_pp_at(t, "BEF_CHARGE")
return (av_uc_pp_at(t, "BEF_CHARGE")
* (1.0 - charge_uc)) # noqa: F821
raise ValueError("invalid timing")
[docs]
def av_pp_at(t, timing):
"""A(t) at a point inside month t: the two supports added together.
``"BEF_REBAL"`` is the balance ``A-(t)`` carried into the month, ``"BOM"`` the balance
the month invests, ``"BEF_CHARGE"`` that balance after the month's return, and
``"EOM"`` the balance the month's decrements are valued on. The ``"BOM"`` split of a
**rebalancing month** is what :func:`check_euro_share_min` measures the regulatory
minimum against, because the minimum binds at the rebalancing date and not
continuously — and on a monthly grid there are eleven months between those dates in
which the residual is negative by construction.
"""
return av_euro_pp_at(t, timing) + av_uc_pp_at(t, timing)
[docs]
def av_euro_pp(t):
"""The per-policy euro-support balance at the end of month t.
The balance the month *opens* with is ``av_euro_pp_at(t, "BEF_REBAL")``, which is
:func:`av_euro_init` at ``t = 0``.
"""
return av_euro_pp_at(t, "EOM")
[docs]
def av_uc_pp(t):
"""The per-policy *unités de compte* balance at the end of month t.
The balance the month *opens* with is ``av_uc_pp_at(t, "BEF_REBAL")``, which is
:func:`av_uc_init` at ``t = 0``.
"""
return av_uc_pp_at(t, "EOM")
[docs]
def av_pp(t):
"""A(t): the per-policy account value at the end of month t.
**Per policy, and already net of decrements in the sense that matters**: it is the
balance one surviving plan holds, not the balance the block holds. Multiplying a
claims column by ``pols_if`` again understates every benefit by the square of the
survival factor, which is the quietest way to get this product wrong.
:func:`av_at` is the in-force weighted quantity where one is wanted.
This is the balance a mid-month exit is valued on, and it is the one thing the monthly
grid buys that the annual grid could not express: a death in month five of a plan year
is now paid the balance the plan actually holds in month five, not the one it would
have held at the anniversary. At the anniversary month itself it reproduces the
annual-step model's year-end value exactly.
The balance the month *opens* with is ``av_pp_at(t, "BEF_REBAL")``, which at ``t = 0``
is the model point's opening state ``av_euro_init() + av_uc_init()``.
"""
return av_pp_at(t, "EOM")
[docs]
def av_at(t, timing):
"""The in-force weighted account value at a point inside month t.
``av_pp_at(t, timing)`` times the in force at that point — ``pols_if(t)``, the
start-of-month count, at ``"BOM"``, and ``pols_if_at(t, "AFT_DECR")``, the
end-of-month count, at ``"EOM"``. Published for a reader who wants a block-level
balance; no cash flow in this model reads it, because every claim is already the
product of a decrement and a per-policy amount.
"""
if timing == "BOM":
return av_pp_at(t, timing) * pols_if(t)
if timing == "EOM":
return av_pp_at(t, timing) * pols_if_at(t, "AFT_DECR")
raise ValueError("invalid timing")
[docs]
def inv_income_pp(t):
"""The gross investment return credited to the two supports in month t.
``E_eu r_eu,m + E_uc r_uc,m`` on the start-of-month balances, before the management
charge. Credited **every month** at the geometric monthly rates, so the twelve months
of a plan year sum to exactly the year's credit on the annual grid — nothing else
touches the balance in between, so the monthly credits telescope.
It is the only term that opens the gap between the account value and the *garantie
plancher* base, which is what :func:`check_floor_identity` uses it for.
"""
return (av_euro_pp_at(t, "BOM") * return_euro_mth()
+ av_uc_pp_at(t, "BOM") * return_uc_mth())
[docs]
def mgmt_charge_pp(t):
"""The management charge levied on the two supports in month t.
**A contractual annual event, and it is not spread**: it falls whole in the
anniversary month, ``is_anniv(t)``, on the post-crediting balance
``"BEF_CHARGE"``, at 0.70% on each support **[std]**, and is nil in the other eleven
months. That is the [S3] convention the product spec adopts — "annually at
31 December on both" — and it is the same number, in the same place in the sequence,
that the annual-step model levied.
It is also the lever the whole conversion turns on. Levying it monthly at
``1 - (1 - c)^(1/12)`` would leave the account value at the anniversary unchanged, the
two monthly factors still compounding back — but the *garantie plancher* base is a
**sum** of charges rather than a product of factors, and twelve monthly charges do not
add to the annual one. Measured, that moves ``death_floor_pp`` at the anniversary by
up to 71.95 EUR on the anchor cell and 531.57 EUR on model point 12. Charge timing
differs across the sample — monthly on an end-of-month balance, quarterly on UC and
annually on the euro fund, daily accrual with an annual levy — and the finer grid can
now carry any of them; carrying a different one is a **new assumption** rather than a
finer grid, and the product spec's footnote 12 is where it would be recorded.
The price is stated rather than hidden: a mid-year exit is valued **gross** of the
plan year's charge, which on the anchor cell's last plan year is 0.387% above the
anniversary value at month ``T - 2``.
The same amount is deducted from the *garantie plancher* base, because the guarantee
is stated net of charges levied over the plan's life.
"""
if not is_anniv(t):
return 0.0
return (av_euro_pp_at(t, "BEF_CHARGE") * charge_euro # noqa: F821
+ av_uc_pp_at(t, "BEF_CHARGE") * charge_uc) # noqa: F821
[docs]
def death_floor_pp_at(t, timing):
"""The *garantie plancher* base at a point inside month t.
``"BEF_REBAL"``
the base ``g-(t)`` **carried into** the month:
:func:`death_floor_init` in the first projected month,
``death_floor_pp(t - 1)`` afterwards. This is where the frame's
opening base enters, so that nothing is indexed at ``t = -1``.
``"EOM"``
the base after the month's *versement* net of loading and the
month's charges; the same number as :func:`death_floor_pp`.
The formula is **unchanged in form** by the move to the monthly grid. Its three
moving terms — the *versement* net of loading, the arbitrage charge and the management
charge — are non-zero in only two months of the twelve, so the base is a step function
that moves at the plan-year start and at the anniversary and is flat in between.
"""
if timing == "BEF_REBAL":
if t <= 0:
return death_floor_init()
return death_floor_pp(t - 1)
if timing == "EOM":
return (death_floor_pp_at(t, "BEF_REBAL") + prem_to_av_pp(t)
- arbitrage_charge_pp(t) - mgmt_charge_pp(t))
raise ValueError("invalid timing")
[docs]
def death_floor_pp(t):
"""g(t): the *garantie plancher* base at the end of month t.
``g-(t) + V_net - arb(t) - mgmt_charge_pp(t)``: *versements* net of entry loading,
less the charges levied over the plan's life. **Not a floor at gross premiums** —
one contract says so expressly — and not a floor at the account value either. It is
the anchor contract's drafting; a second contract in the sample adds the euro fund's
interest net of charges to the base, and implementing that means adding a term here
and dropping :func:`check_floor_identity`, not moving a parameter.
It is a *base*, not a benefit: :func:`death_benefit_pp` applies the cessation at 70
and the €762 245 cap to it. The recursion itself keeps running past both, because
the cover can be reinstated by a model point that carries the flag while the base
cannot be reconstructed once it has stopped being accumulated.
On the monthly grid the base moves in exactly **two months of the twelve** — up by
``V_net`` less the arbitrage charge in the month that opens the plan year, down by the
management charge in the anniversary month — and is flat in between. A mid-year death
is therefore floored on the base as it stood at the last anniversary plus this year's
*versement*, while the account value beside it has grown every month; that is why the
month the floor stops biting can now be located exactly, and why it moves by up to two
months against the annual grid on a cell where the floor bites at all.
The base the month *opens* with is ``death_floor_pp_at(t, "BEF_REBAL")``, which at
``t = 0`` is :func:`death_floor_init`.
"""
return death_floor_pp_at(t, "EOM")
[docs]
def floor_in_force(t):
"""Whether the *garantie plancher* covers a death in month t.
The cell must carry the cover, and the member must still be under 70 at the end of the
**plan year** containing the month — the year opens at ``age(t)`` and closes at
``age(t) + 1``: the guarantee **ceases at the 70th birthday**.
The test stays on the plan year rather than on the month **[std]**, so the cover is
off for the whole plan year that ends on the 70th birthday, exactly as on the annual
grid. The notes' age basis is an integer attained age incremented once per plan year,
so there is no sub-annual age to test a month-exact rule against; the alternative
reading — cover in force until the birthday month — was considered and declined
because it would need one. On model point 9, the only shipped cell that retires at
70, the two rules give identical cash flows to the cent.
That is the same birthday
at which a PER death benefit stops being taxed as life insurance and enters the
inheritance-duty base in its entirety, and from 2026 the same one at which
contributions stop being deductible — one cliff edge, three consequences.
"""
return bool(death_floor_flag()
and age(t) + 1 < floor_cease_age) # noqa: F821
[docs]
def death_benefit_pp(t):
"""The per-policy death benefit for a death in month t.
The account value, floored by the *garantie plancher* while the cover is in force,
the floor itself capped at €762 245 across contracts. Death **closes the plan**, so
this is the whole benefit and not a sum at risk.
"""
if not floor_in_force(t):
return av_pp(t)
return max(av_pp(t), min(death_floor_pp(t), death_floor_cap)) # noqa: F821
[docs]
def mort_rate(t):
"""q(t): the **annual** mortality rate of the plan year containing month t.
The rate the technical notes tabulate, and it keeps the bare name for that reason —
:func:`mort_rate_mth` is the rate actually applied in the month. It is a property of
the plan year and not of the month: it is constant across the twelve months of a plan
year and steps at the anniversary, because :func:`age` does.
On a ``flat`` cell the model point's own placeholder; on a ``table`` cell the shipped
**[std]** proxy at ``age(t)`` — the attained age the plan year opens at — times
``mort_be_factor``.
Both are placeholders. TH 00-02 and TF 00-02 govern the death benefit during
accumulation and are cited rather than shipped, their *décalage d'âge* age shifts are
not reproduced, and no sampled contract publishes a mortality basis at all.
"""
if mort_basis() == "flat":
return mort_rate_flat()
x = min(age(t), omega_age) # noqa: F821
return min(1.0, float(data.mort_table().loc[ # noqa: F821
(sex(), x), "mort_rate"]) * mort_be_factor) # noqa: F821
[docs]
def mort_rate_mth(t):
"""q_m(t) = 1 - (1 - q)^(1/12): the monthly mortality rate applied in month t **[std]**.
Derived **geometrically** from the plan year's annual rate and not by dividing by
twelve, which is what makes twelve months compound back to exactly that rate — and so
what makes the in force at every anniversary reproduce the annual-step model's. On
the anchor cell's 0.00500, ``q_m = 0.00041762`` while ``q / 12 = 0.00041667``: twelve
of the latter would overstate the plan year's mortality by 0.23%.
No retrieved French source states a conversion convention for any decrement in this
product, so the constant-force reading is a standardization.
"""
return 1.0 - (1.0 - mort_rate(t)) ** (1.0 / 12.0)
[docs]
def early_release_rate(t):
"""w_e(t): the **annual** *déblocage anticipé* decrement of the plan year **[std]**.
The annual rate ``exit_table.csv`` tabulates, keeping the bare name for that reason;
:func:`early_release_rate_mth` is the rate actually applied in the month. It is a
property of the plan year, constant across its twelve months.
**Not a lapse rate.** A release requires one of the seven listed triggering events —
death of a spouse, invalidity, serious illness of a dependent child,
over-indebtedness, exhaustion of unemployment rights, business liquidation, purchase
of the main residence — bears **no charge**, and may be partial, leaving the plan in
force. The base model treats it as a full exit paying the whole account value.
Only the last of the seven cases is discretionary and none of them responds to
investment performance, so a dynamic moneyness multiplier would be a category error
on this decrement. It is read from ``exit_table.csv`` at (``compartment``,
``duration``) — a 1-based *ancienneté* label, so the key is :func:`plan_year` and not
the 0-based :func:`duration` — and compartment 3 carries a lower rate because the
main-residence limb is closed to it.
"""
tbl = data.exit_table().loc[compartment()] # noqa: F821
d = max(1, min(plan_year(t), int(tbl.index.max())))
return float(tbl.loc[d, "early_release_rate"])
[docs]
def early_release_rate_mth(t):
"""w_e,m(t) = 1 - (1 - w_e)^(1/12): the monthly release rate **[std]**.
The same constant-force conversion :func:`mort_rate_mth` takes, on the plan year's
annual rate: 0.00134321 a month on the anchor cell's 1.60% a year. A release requires
a listed triggering event and can fall in any month, which is what the finer grid
resolves — and twelve of these compound back to 1.60% exactly, so the year's total
decrement is unchanged.
"""
return 1.0 - (1.0 - early_release_rate(t)) ** (1.0 / 12.0)
[docs]
def transfer_out_rate(t):
"""w_r(t): the **annual** transfer-out decrement of the plan year **[std]**.
The annual rate ``exit_table.csv`` tabulates, keeping the bare name for that reason;
:func:`transfer_out_rate_mth` is the rate actually applied in the month.
**Not a lapse rate either.** The plan ends for this insurer but the savings do not
leave the regime: rights under accumulation are transferable to any other PER, and
the transfer does not alter the surrender or liquidation conditions. It pays a
transfer value, not a surrender value, and differs from the release amount by
:func:`transfer_indemnity_rate`.
Flat in the base run. The indemnity falls to nil at the fifth anniversary and a
rational holder waits, so the reference behavioural shape is a multiplier of 0.7 in
the years before the anniversary and 1.3 in the anniversary year **[std]**, the pair
chosen so that the two adjacent years average to 1.00 and the flat calibration is not
quietly raised by turning the shape on. It is switched off here so that the worked
example stays transparent. Note that the pair is mean-preserving over those two years
only: applied over a longer pre-anniversary run it is below 1 on average, and a
calibration that spans one would have to rescale.
"""
tbl = data.exit_table().loc[compartment()] # noqa: F821
d = max(1, min(plan_year(t), int(tbl.index.max())))
return float(tbl.loc[d, "transfer_out_rate"])
[docs]
def transfer_out_rate_mth(t):
"""w_r,m(t) = 1 - (1 - w_r)^(1/12): the monthly transfer-out rate **[std]**.
0.00083718 a month on the base run's 1.00% a year, and twelve of them compound back to
it exactly.
"""
return 1.0 - (1.0 - transfer_out_rate(t)) ** (1.0 / 12.0)
[docs]
def transfer_indemnity_rate(t):
"""iota(t): the transfer indemnity, 1% of acquired rights and then nil.
The fee "ne peuvent excéder 1 % des droits acquis" and is nil **five years after the
first *versement* in the plan** — not five years after the projection starts. That
is what :func:`plan_year` is for, and the anchor cell shows the difference: it carries
``duration_ifo = 2``, so it is already in its third plan year at ``t = 0`` and the
window covers only its first two projected plan years, months ``t = 0 … 23``.
The plan-year test is **exactly** a month test on the monthly grid, not an
approximation of one: because ``duration_ifo`` is a whole number of years,
``plan_year(t) < transfer_indemnity_years`` if and only if
``12 duration_ifo + t < 12 (transfer_indemnity_years - 1)`` — 48 months from the first
*versement* at the shipped setting, which on the anchor cell is months ``t = 0 … 23``.
The window is therefore the same on either grid, to the month, and there is nothing
for the conversion to decide here. It would stop being equivalent only for a model
point carrying a part-year *ancienneté*, which the column's integer type forbids.
The separate 15% reduction of euro-denominated transfer values, available where the
transfer value exceeds the asset share backing it, is **off** in the base run: it is
a management action conditional on a market state the base scenario does not produce,
and in a rising-rate scenario it dominates this 1% by an order of magnitude.
"""
if plan_year(t) < transfer_indemnity_years: # noqa: F821
return transfer_indemnity # noqa: F821
return 0.0
[docs]
def pols_if(t):
"""The in-force probability at the **start** of month t, before any decrement.
``pols_if(0) = pols_if_init()``, and this is the weight on month ``t``'s own cash
flows — the *versement*, the expense, and the exposure every decrement of the month is
taken against. It is the library's settled convention: ``pols_if(t)`` is the count
the row opens with, so a ``result_cf()`` flow divided by that row's ``pols_if``
returns a per-policy amount for the same period.
At the months that open a plan year it is exactly what the annual-step model carried
on the corresponding row: ``pols_if(12y)`` here is that model's ``pols_if(y)``,
because the monthly rates compound back to the annual ones.
:func:`result_cf_annual` publishes that column.
The technical notes' ``l(t)`` is the count the month **ends** with, which is one step
further on. It is :func:`pols_if_at` at ``"AFT_DECR"``; see the Space docstring on
why it no longer carries this name.
"""
if t <= 0:
return pols_if_init()
return pols_if_at(t - 1, "AFT_DECR")
[docs]
def pols_if_at(t, timing):
"""The in-force probability at a point inside month t.
``"BEF_DECR"``
the start of the month, before any decrement; :func:`pols_if`.
This is the weight on the month's *versement* and expense.
``"BEF_RELEASE"``
after mortality, before early releases.
``"BEF_TRANSFER"``
after early releases, before transfers out.
``"AFT_DECR"``
after transfers out: the end-of-month count, which is the technical
notes' own ``l(t)``.
The order — death, then early release, then transfer out — is an ordered
dependent-decrement convention **[std]**, matching the library's house treatment, and
the whole chain is re-applied **every month** at the converted monthly rates. That is
the only ordering under which the in force at every anniversary reproduces the
annual-step model, because ``[(1-q_m)(1-w_e,m)(1-w_r,m)]^12`` is exactly
``(1-q)(1-w_e)(1-w_r)``.
Its one visible consequence, which looks like an error and is not: the **split**
between the three decrements moves while their total does not. Walking the chain
twelve times with small steps dilutes the later decrements less, so over the anchor
cell's first plan year deaths fall 1.19%, releases 0.23%, and transfers rise 0.98%
against the annual grid — and the anniversary in force is identical to floating point.
Exposing the steps is what makes the ordering inspectable rather than implied by the
shape of three multiplications. The timing strings are the house ``BEF_*`` /
``AFT_DECR`` vocabulary of ``savings.CashValue_SE`` and of the other frlib models; the
account-value cells use ``"BEF_REBAL"`` / ``"BOM"`` / ``"BEF_CHARGE"`` / ``"EOM"``,
which mark points in the month's *investment* sequence and not in this chain.
"""
if timing == "BEF_DECR":
return pols_if(t)
if timing == "BEF_RELEASE":
return pols_if(t) * (1.0 - mort_rate_mth(t))
if timing == "BEF_TRANSFER":
return pols_if_at(t, "BEF_RELEASE") * (1.0 - early_release_rate_mth(t))
if timing == "AFT_DECR":
return pols_if_at(t, "BEF_TRANSFER") * (1.0 - transfer_out_rate_mth(t))
raise ValueError("invalid timing")
[docs]
def pols_death(t):
"""d_death(t): deaths in month t, against the start-of-month in force.
At the monthly rate :func:`mort_rate_mth`, and the claim is settled at the end of the
month of death on the balance the plan holds then — which is what the finer grid buys
over an annual step that could only settle it at the anniversary.
"""
return pols_if(t) * mort_rate_mth(t)
[docs]
def pols_release(t):
"""d_release(t): *déblocages anticipés* in month t, from that month's survivors."""
return pols_if_at(t, "BEF_RELEASE") * early_release_rate_mth(t)
[docs]
def pols_transfer(t):
"""d_transfer(t): transfers out in month t, after that month's deaths and releases."""
return pols_if_at(t, "BEF_TRANSFER") * transfer_out_rate_mth(t)
[docs]
def pols_maturity(t):
"""l(T-1): the survivors settling at the declared horizon; nil in every other month.
``pols_if_at(T - 1, "AFT_DECR")`` — the count the final projected month,
``t = proj_len() - 1``, **ends** with, after that month's own decrements, not the count
it opened with. A real contractual event rather than a modelling truncation: the plan
reaches its L. 224-1 maturity and the rights are liquidated, so the survivors are paid.
The settlement is **not spread** over the last plan year: the horizon is a contractual
date, so the whole of it lands in the horizon-anniversary month **[std]**.
"""
if t != proj_len() - 1:
return 0.0
return pols_if_at(t, "AFT_DECR")
[docs]
def annuity_factor():
"""a_x: the annuity conversion factor at the horizon **[std]**.
Read from ``annuity_factor.csv`` at (``sex``, ``retirement_age``), and anchored at
22.0000 for a male aged 64 — annual in arrears, no reversion.
**It is an undiscounted expected-instalment count.** A PER tariff is established
"d'après un taux d'intérêt technique au plus égal à 0 %", so the factor collapses to
the tariff table's expected number of instalments and nothing in this model discounts
it: :func:`rente_gross_pp` is a plain division. A 2% technical rate would shorten the
factor by a fifth, from 22.0000 to 17.66, and so inflate the annuity by roughly a
quarter on the anchor cell — the factor falls 19.7%, the annuity rises 24.6%.
No sampled insurer publishes an annuity rate card and TGH05 / TGF05 are not shipped,
so the ladder is a placeholder to be replaced before any quantitative use.
"""
return float(data.annuity_factor_table().loc[ # noqa: F821
(sex(), retirement_age()), "annuity_factor"])
[docs]
def capital_leg_pp():
"""The capital settled at the horizon, per surviving policy: ``(1 - theta) A(n-1)``.
**No exit charge.** Every sampled contract settles capital at 0%, so this is the
account value itself and not a surrender value. ``A(T-1)`` is the account value at
the end of the last projected **month**, the horizon anniversary — the same number the
annual-step model settled on, to floating point.
"""
return (1.0 - annuity_share()) * av_pp(proj_len() - 1)
[docs]
def capital_instalment_pp():
"""The size of one instalment of the capital leg under a *fractionné* settlement.
``capital_leg_pp() / capital_instalments()``. Published rather than paid: the model
records the whole capital leg at ``t = proj_len() - 1``, because the option changes
*when* the leg is paid and not how much, and the technical notes fix the frame at the
horizon. The monthly grid does **not** make the *fractionné* schedule expressible,
because the instalments fall **after** the horizon and the frame ends there — the
reason for the simplification is now the frame's end rather than the grid's
coarseness. See the Space docstring for what it costs.
"""
return capital_leg_pp() / capital_instalments()
[docs]
def annuity_cap_pp():
"""The capital converted to a *rente viagère*, per policy: ``theta A(n-1)``."""
return annuity_share() * av_pp(proj_len() - 1)
[docs]
def rente_gross_pp():
"""The gross annual annuity instalment: ``annuity_cap / a_x``.
A plain division, because :func:`annuity_factor` is undiscounted. Annual in arrears
is forced by ``annuity_factor.csv``, which holds an undiscounted **count of annual
instalments** — not by the projection grid, which is monthly: paying quarterly or
monthly means replacing that table, not changing a step. The sample does pay
quarterly or monthly, and the commutation test below is applied on the monthly
equivalent for exactly that reason.
"""
return annuity_cap_pp() / annuity_factor()
[docs]
def rente_net_pp():
"""The annual instalment net of the *frais d'arrérages*: ``rente_gross (1 - c_arr)``.
1.50% of each gross instalment **[std]**. Observed *arrérage* charges run from 0% to
3% across the sample; charged on every instalment for life, the level moves the
capital at which the commutation test bites.
"""
return rente_gross_pp() * (1.0 - arrear_charge_rate) # noqa: F821
[docs]
def is_commuted():
"""Whether the annuity is commuted to a single capital payment at the horizon.
The insurer may substitute a capital payment, with the annuitant's agreement, where
the monthly *quittance d'arrérages* does not exceed €110 including statutory
increases, **multiplied by the number of months in the payment period**. The
threshold is monthly, so an annual frequency tests against €1 320, and testing €110
against an annual instalment would commute almost nothing.
The base model commutes deterministically whenever the test passes; a
``commutation_agreement_rate`` is the natural refinement **[std]**. A cell electing
no annuity has nothing to commute and reads False.
"""
if annuity_cap_pp() <= 0.0:
return False
return bool(rente_net_pp() / payment_mths # noqa: F821
<= commute_threshold_mth) # noqa: F821
[docs]
def commuted_pp():
"""The commutation lump sum, per surviving policy; nil where the annuity is paid.
``rente_net a_x``, which is ``annuity_cap (1 - c_arr)`` exactly — commuting at the
conversion basis returns the converted capital less the *arrérage* charge, and
nothing else. :func:`check_commutation_identity` asserts it, because commuting at a
book value instead manufactures a gain out of nothing.
"""
if not is_commuted():
return 0.0
return rente_net_pp() * annuity_factor()
[docs]
def annuity_conversion_pp():
"""The capital handed to ``Rente_FR_S``, per surviving policy.
``annuity_cap`` where the annuity is paid as a *rente*, nil where it is commuted.
The annuity's own reserve, its 0.80% p.a. charge, reversion, *annuités garanties* and
revaluation are specified in ``products/rente_viagere/technical-notes.md``; this
model hands over an amount and records it.
"""
if is_commuted():
return 0.0
return annuity_cap_pp()
[docs]
def claim_pp(t, kind):
"""The benefit paid per exiting policy in month t, by kind.
Every kind is valued on ``av_pp(t)``, the balance at the **end of the month of exit**.
That is the one thing the finer grid changes rather than merely resolves: an annual
step could only value a mid-year exit at the year end.
``"DEATH"``
:func:`death_benefit_pp` — the account value floored by the
*garantie plancher* while the cover is in force. Death closes the
plan.
``"EARLY_RELEASE"``
the **whole** account value, with **no charge**. There is no
surrender charge and no market value adjustment on this product,
because there is no surrender.
``"TRANSFER"``
``A(t) (1 - iota(t))`` — the account value less the 1% indemnity
while the plan is under five years old, and the account value
itself afterwards.
``"MATURITY"``
the capital leg plus any commutation lump sum, at
``t = proj_len() - 1`` only. Where the annuity is *not* commuted its capital leaves as
:func:`annuity_conversion` instead, so the two never overlap.
"""
if kind == "DEATH":
return death_benefit_pp(t)
if kind == "EARLY_RELEASE":
return av_pp(t)
if kind == "TRANSFER":
return av_pp(t) * (1.0 - transfer_indemnity_rate(t))
if kind == "MATURITY":
if t != proj_len() - 1:
return 0.0
return capital_leg_pp() + commuted_pp()
raise ValueError("invalid kind")
[docs]
def claims(t, kind=None):
"""Benefit outgo in month t, by kind; the total when kind is omitted.
Each kind weights :func:`claim_pp` by its own decrement, so a benefit is never
multiplied by ``pols_if`` twice. ``annuity_conversion`` is **not** in here: it is
not a benefit paid to the policyholder but capital handed to the annuity model, and
it carries its own line in :func:`result_cf`.
"""
if kind is None:
return sum(claims(t, k) for k in
("DEATH", "EARLY_RELEASE", "TRANSFER", "MATURITY"))
if kind == "DEATH":
return claim_pp(t, kind) * pols_death(t)
if kind == "EARLY_RELEASE":
return claim_pp(t, kind) * pols_release(t)
if kind == "TRANSFER":
return claim_pp(t, kind) * pols_transfer(t)
if kind == "MATURITY":
return claim_pp(t, kind) * pols_maturity(t)
raise ValueError("invalid kind")
[docs]
def annuity_conversion(t):
"""The capital converted to a *rente* and handed to ``Rente_FR_S`` in month t.
Non-zero at ``t = proj_len() - 1`` only, and only on a cell whose annuity is not commuted. It
is an **outgo** of this model: the money leaves the accumulation projection, and a
reader who nets it out would be double-counting the annuity against itself.
"""
return annuity_conversion_pp() * pols_maturity(t)
[docs]
def inflation_factor(t):
"""The expense inflation factor in month t: ``(1 + pi)^(t/12)`` at 1.80% **[std]**.
Compounded on the monthly grid rather than stepped at anniversaries, which is the
``WholeLife_US_S`` convention and the one the notes' expense scale now states. Twelve
months of it compound to exactly one year of inflation, so the factor at the month
opening plan year ``y`` is the annual grid's ``1.018^y`` exactly; a full plan year of
expense accrues about 0.83% above the anniversary-stepped figure, because the average
of twelve monthly factors sits above the one at the year's start.
"""
return (1.0 + inflation_rate) ** (t / 12) # noqa: F821
[docs]
def premiums(t):
"""*Versement* income at the beginning of month t, an inflow.
Non-zero only in the twelve months that open a plan year, and weighted by the in force
at the **start** of the month, ``pols_if(t)``: a contribution is paid by a plan that is
still there to pay it. Because the weight at a plan-year start is exactly the annual
model's start-of-year count, the *versement* column reproduces the annual grid's.
"""
return premium_pp(t) * pols_if(t)
[docs]
def expenses(t):
"""E(t): the maintenance expense in month t **[std]**.
€30 a plan a year, accruing at **one twelfth a month** and inflating at 1.80% on the
in force at the start of the month. A plan that runs a full year therefore carries
very nearly the same charge as it did on the annual grid — but it is now borne by the
in force of *each month* rather than of the plan-year start, which is what makes a
decrementing block cost less: the aggregate falls 0.61% on the anchor cell while the
per-policy total rises 0.82% on the monthly-compounded inflation factor. Those two
signs are not in conflict; they are the two halves of the same change.
No insurer's unit cost is public; only the charge cap is, and the €20 association fee
the sample publishes is a one-off at adhesion and nil for an in-force cell.
"""
return (expense_maint / 12.0 # noqa: F821
* inflation_factor(t) * pols_if(t))
[docs]
def liability_cf(t):
"""CF(t): the net liability cash flow of month t, **outgo-positive**.
The technical notes' own orientation, published verbatim: claims, plus the capital
handed to the annuity model, plus expenses, less *versements*. :func:`net_cf` is its
negative.
"""
return (claims(t) + annuity_conversion(t) + expenses(t) - premiums(t))
[docs]
def net_cf(t):
"""The net cash flow of month t, **income positive**, per the house convention.
On the monthly frame only the twelve *versement* months are cash-positive; the claim
that a contributing plan is cash-positive in every plan year but the settlement one is
read off :func:`result_cf_annual`, which is what that frame is for.
Exactly ``-liability_cf(t)``, so that ``result_cf()["net_cf"]`` can be compared and
summed across the library without checking which product it came from.
"""
return -liability_cf(t)
[docs]
def check_pols_roll_fwd_resid(t):
"""The in-force roll-forward residual in month t; zero everywhere.
``l-(t) - d_death(t) - d_release(t) - d_transfer(t) - l(t)``, which in cells names is
``pols_if(t)`` less the three decrement flows less ``pols_if_at(t, "AFT_DECR")``.
Rebuilt from the three decrement flows rather than from the chain of survival factors
that defines the timings, so an exit counted twice — the classic error when two
decrements are both applied to the start-of-month in force instead of in sequence —
shows up here. It closes **month by month**, not merely at anniversaries.
"""
return (pols_if(t) - pols_death(t) - pols_release(t)
- pols_transfer(t) - pols_if_at(t, "AFT_DECR"))
[docs]
def check_pols_roll_fwd():
"""True when the in-force roll-forward closes in every projected month."""
return bool(all(
abs(check_pols_roll_fwd_resid(t)) <= roll_fwd_tol * max(pols_if_init(), 1.0) # noqa: F821
for t in range(proj_len())))
[docs]
def check_av_roll_fwd_resid(t):
"""The account value roll-forward residual in month t; zero everywhere.
``A(t) - [A-(t) + V_net - arb(t) + inv_income_pp(t) - mgmt_charge_pp(t)]``.
This is a **conservation** statement, not the recursion restated: the switch does not
appear in it, because a switch moves money between the two supports rather than into
or out of the plan, and the only things that cross the boundary are the *versement*
net of loading, the arbitrage charge, the return credited and the charge levied. A
switch that credits the destination without debiting the source, an arbitrage charge
taken from one support and not deducted anywhere, or a management charge computed on
a different balance from the one it is taken off, all break it — and all of them
leave every printed number looking plausible.
It is **unchanged in form** on the monthly grid and closes month by month: the
*versement*, the arbitrage charge and the management charge are simply zero in the
months they do not fall.
"""
built = (av_pp_at(t, "BEF_REBAL") + prem_to_av_pp(t)
- arbitrage_charge_pp(t)
+ inv_income_pp(t) - mgmt_charge_pp(t))
return av_pp(t) - built
[docs]
def check_av_roll_fwd():
"""True when the account value roll-forward closes in every projected month."""
return bool(all(abs(check_av_roll_fwd_resid(t)) <= 1e-8
for t in range(proj_len())))
[docs]
def check_floor_identity_resid(t):
"""The *garantie plancher* identity residual in month t; zero everywhere.
``[A(t) - g(t)] - [A-(t) - g-(t)] - inv_income_pp(t)``: the gap between the account
value and the guarantee base widens by the gross investment return credited and by
nothing else, because the *versement* net of loading, the arbitrage charge and the
management charge all enter both sides.
It is **zero by construction** given the recursion in :func:`death_floor_pp` — and
that is the point of writing it out. What it catches is the *wrong* recursion: a
base accumulated at gross ``V`` rather than ``V_net``, which is the single most
common misreading of this guarantee and which one sampled contract explicitly warns
against; a base that forgets the arbitrage charge; and a base charged something other
than what the account was actually charged. Each of those breaks the identity in the
first month in which it is wrong.
It closes month by month on the monthly grid, and it is the identity that forces the
management charge to stay whole at the anniversary: the base accumulates a **sum** of
charges while the account value accumulates a **product** of factors, so a monthly
charge would keep the account value at the anniversary and move the base.
"""
return ((av_pp(t) - death_floor_pp(t))
- (av_pp_at(t, "BEF_REBAL")
- death_floor_pp_at(t, "BEF_REBAL")) - inv_income_pp(t))
[docs]
def check_floor_identity():
"""True when the *garantie plancher* identity closes in every projected month."""
return bool(all(abs(check_floor_identity_resid(t)) <= 1e-8
for t in range(proj_len())))
[docs]
def check_euro_share_min_resid(t):
"""The regulatory de-risking minimum residual at the BOM of month t.
``av_euro_pp_at(t, "BOM") - a(t) av_pp_at(t, "BOM")``. In a **rebalancing month** it
must be at or above :func:`euro_share_min_bound`: the grid is a minimum, and taking
the arbitrage charge from the source — the UC bucket — is what keeps the
post-rebalancing share at or just above it. Taking the charge from the destination
instead would leave it under, by ``(1 - a) arb``, at every band crossing.
Note where it is measured, which the monthly grid makes sharper rather than softer.
The minimum binds at the **rebalancing date**, not continuously; between dates the mix
drifts with relative performance, and on this grid there are **eleven months** of that
drift rather than an instant. In those eleven months the residual is **negative by
construction** — the UC bucket outgrows the euro one — so this residual is a
compliance statement only in the months :func:`is_plan_boy` marks, and
:func:`check_euro_share_min` iterates over exactly those. The anchor cell is at
70.00% euro after the rebalancing that opens its last plan year, month 132, and 69.67%
at the anniversary, month 143.
"""
return av_euro_pp_at(t, "BOM") - alloc_euro(t) * av_pp_at(t, "BOM")
[docs]
def euro_share_min_bound(t):
"""The lowest residual the source-charging convention permits in month t.
Zero on a de-risking switch, where the UC bucket is the source and the euro
destination receives the switch in full. On a **reverse** switch the euro support is
itself the source, so the charge comes out of the balance being measured and the
share lands ``(1 - a) arb`` below the minimum — a gap the technical notes leave open,
because they ask both for a symmetric formula and for a share at or above the line
and the two cannot both hold in that direction.
Zero in every non-rebalancing month too, where ``switch_pp(t)`` is nil; those months
are not measured at all.
"""
if switch_pp(t) >= 0.0:
return 0.0
return -(1.0 - alloc_euro(t)) * arbitrage_charge_pp(t)
[docs]
def check_euro_share_min():
"""True when the post-rebalancing euro share meets the grid minimum at every date.
Measured in the **rebalancing months only**, ``is_plan_boy(t)``, because that is where
the minimum binds. On a monthly grid that restriction is load-bearing rather than
cosmetic: between rebalancing dates the mix drifts with relative performance and the
residual is negative by construction in eleven months of twelve — the anchor cell
drifts from 70.0006% to 69.67% over the twelve months of its last plan year — so a
check looping over the whole frame fails on every model point while nothing is wrong.
Re-imposing the target every month would invent a rebalancing frequency the contract
does not have.
Measured against :func:`euro_share_min_bound` rather than against zero, so that
the check states what the source-charging convention actually guarantees in each
direction. It still catches the pitfall it exists for: charging the **destination**
on a de-risking switch puts the residual at ``-(1 - a) arb`` where the bound is zero,
and fails here at the first band crossing.
"""
return bool(all(
check_euro_share_min_resid(t) >= euro_share_min_bound(t) - 1e-8
for t in range(proj_len()) if is_plan_boy(t)))
[docs]
def check_glide_path_closes():
"""True when the glide path's two shares add to one in every plan year read.
An input check rather than a model check. ``alloc_uc`` reads the file's own
``uc_share`` column instead of computing ``1 - euro_share``, so a grid whose rows do
not close would silently invest a fraction of each *versement* nowhere, or twice.
Read at the rebalancing months, which is where the shares are used and where every
distinct row of the grid a projection touches is read exactly once; the other eleven
months of each plan year return the same row.
"""
return bool(all(abs(alloc_euro(t) + alloc_uc(t) - 1.0) <= 1e-12
for t in range(proj_len()) if is_plan_boy(t)))
[docs]
def check_commutation_identity():
"""True when the commutation returns the converted capital less the charge.
``commuted = rente_net a_x = annuity_cap (1 - c_arr)``. Zero by construction while
the commutation and the conversion use the same ``a_x`` — which is exactly what it
is asserting. Commuting on a *different* basis from the conversion, or at a book
value, manufactures a gain or a loss out of nothing, and this is the line that would
catch it. On a cell that pays the annuity, both sides are nil.
"""
if not is_commuted():
return bool(commuted_pp() == 0.0)
target = annuity_cap_pp() * (1.0 - arrear_charge_rate) # noqa: F821
return bool(abs(commuted_pp() - target) <= 1e-8)
[docs]
def check_horizon():
"""True when the projection stops at the declared horizon.
No *versement* arrives after settlement, ``years_to_horizon`` does not go negative,
and the maturity claim falls in exactly one **month** — the last one,
``t = proj_len() - 1``, the horizon anniversary. Both probes are taken at
``t = proj_len()``, one month past the frame. A plan run past its own horizon keeps
compounding a balance that has already been paid out, and every number after the
horizon then looks like a projection rather than like the error it is.
"""
n = proj_len()
if premium_pp(n) != 0.0 or years_to_horizon(n) != 0:
return False
maturity_mths = [t for t in range(n) if pols_maturity(t) > 0.0]
return bool(maturity_mths == [n - 1])
[docs]
def result_cf():
"""Result table of cash flows, one row per **month** ``t = 0 … proj_len() - 1``.
``pols_if`` is the in force at the **start** of the month, which is the weight the
flows on that same row carry: dividing a flow by the row's ``pols_if`` returns a
per-policy amount for the same period. The end-of-month count the technical notes
call ``l(t)`` is ``pols_if_at(t, "AFT_DECR")``, published in :func:`result_state` as
``pols_if_eoy``.
``av_pp`` is published beside them because it is what every benefit is measured
against — but it is a **per policy** state variable, not a cash flow, and it is not
part of ``net_cf``. Multiplying it by ``pols_if`` and comparing that to a claims
column is the mistake this layout is arranged to make visible.
:func:`result_cf_annual` sums this frame into plan years so that it can be laid beside
the annual-step model it replaced.
"""
ts = list(range(proj_len())) # t = 0 .. T - 1
return pd.DataFrame( # noqa: F821
{
"pols_if": [pols_if(t) for t in ts],
"av_pp": [av_pp(t) for t in ts],
"premiums": [premiums(t) for t in ts],
"claims_death": [claims(t, "DEATH") for t in ts],
"claims_early_release": [claims(t, "EARLY_RELEASE") for t in ts],
"claims_transfer": [claims(t, "TRANSFER") for t in ts],
"claims_maturity": [claims(t, "MATURITY") for t in ts],
"annuity_conversion": [annuity_conversion(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_cf_annual():
""":func:`result_cf` summed into plan years, indexed by ``t_year``.
The frame **regrouped, never a second projection**, and the table a reader lays beside
the annual-step model this one replaced. ``t_year`` is the 0-based *projected* plan
year, ``duration_mth(t) // 12``, and deliberately not :func:`plan_year`: on an
in-force cell the plan's own *ancienneté* label is ahead by ``duration_ifo`` — the
anchor cell's first projected year is its third plan year — and the notes'
worked-example table is keyed 0 … n-1.
Each column is aggregated the way its own meaning requires:
* every **cash flow** column is the total of its twelve months;
* ``pols_if`` is ``.first()``, the count at the **start** of the plan year,
``pols_if(12 t_year)`` — which is the number the annual-step model carried on the
same row, exactly, because the monthly decrement rates compound back to the annual
ones;
* ``av_pp`` is ``.last()``, the balance at the plan-year **end**, ``av_pp(12 t_year +
11)`` — which is what ``av_pp(t)`` meant on the annual grid and what the annual
model carried on the same row, to floating point. Summing a state variable over
twelve months would be meaningless.
Of the eleven columns, four reproduce the annual grid exactly — ``pols_if``, ``av_pp``,
``premiums`` and ``annuity_conversion`` — because the *versement* is collected at the
plan-year start and weighted by the same count under either grid, the balance is the
anniversary balance, and the conversion is a horizon event. ``claims_maturity`` does
too. The other claims columns and ``expenses`` do **not**, and are not meant to:
claims now fall at the end of the month of exit and are valued on the balance held
then, and the maintenance charge is borne by the in force of each month rather than of
the plan-year start. ``liability_cf`` and ``net_cf`` follow them. That gap is the
point of the finer grid.
"""
df = result_cf()
years = pd.Index([duration_mth(t) // 12 for t in df.index], # noqa: F821
name="t_year")
out = df.drop(columns=["pols_if", "av_pp"]).groupby(years).sum()
out.insert(0, "av_pp", df["av_pp"].groupby(years).last())
out.insert(0, "pols_if", df["pols_if"].groupby(years).first())
return out
[docs]
def result_state():
"""The glide path and the two supports, one row per month ``t = 0 … proj_len() - 1``.
Years to horizon, the target euro share, the *versement* net of loading, the switch,
the arbitrage charge, the two support balances, the account value, the *garantie
plancher* base and the in force. Every column is **per policy** except
``pols_if_eoy``.
Read month by month it shows the shape of the product on the finer grid: five of its
ten columns move in the month that opens a plan year and nowhere else, the two
balances move every month, and the *garantie plancher* base moves in two months of
twelve. :func:`result_state_annual` is the plan-year view of the same frame, and it
is that one that reproduces the technical notes' worked-example table.
``pols_if_eoy`` is the notes' ``l(t)``, the count the month **ends** with, and it is
named so rather than ``pols_if`` so that it cannot be confused with the
start-of-period exposure :func:`result_cf` publishes under the house name. The two
are one period apart: ``result_state()["pols_if_eoy"].loc[t]`` equals
``result_cf()["pols_if"].loc[t + 1]``.
"""
ts = list(range(proj_len())) # t = 0 .. T - 1
return pd.DataFrame( # noqa: F821
{
"years_to_horizon": [years_to_horizon(t) for t in ts],
"alloc_euro": [alloc_euro(t) for t in ts],
"prem_to_av_pp": [prem_to_av_pp(t) for t in ts],
"switch_pp": [switch_pp(t) for t in ts],
"arbitrage_charge_pp": [arbitrage_charge_pp(t) for t in ts],
"av_euro_pp": [av_euro_pp(t) for t in ts],
"av_uc_pp": [av_uc_pp(t) for t in ts],
"av_pp": [av_pp(t) for t in ts],
"death_floor_pp": [death_floor_pp(t) for t in ts],
"pols_if_eoy": [pols_if_at(t, "AFT_DECR") for t in ts],
},
index=pd.Index(ts, name="t"), # noqa: F821
)
[docs]
def result_state_annual():
"""The technical notes' worked-example table: :func:`result_state` on the plan year.
One row per projected plan year ``t_year = 0 … proj_years() - 1``, with the same ten
columns. It is **not** a sum — a glide-path share and an account balance do not add —
it is the monthly frame read at the two months of the plan year where each of its
columns is determined:
* ``years_to_horizon``, ``alloc_euro``, ``prem_to_av_pp``, ``switch_pp`` and
``arbitrage_charge_pp`` at the **rebalancing month** ``12 t_year``, which is the only
month of the year in which the last three are non-zero;
* ``av_euro_pp``, ``av_uc_pp``, ``av_pp``, ``death_floor_pp`` and ``pols_if_eoy`` at
the **anniversary month** ``12 t_year + 11``, after the management charge and that
month's decrements.
Every figure in it reproduces the annual-step model's to floating point, which is what
lets the notes' worked example stay in the notes and still be asserted.
"""
years = list(range(proj_years()))
bom = [12 * y for y in years] # the rebalancing month
eom = [12 * y + 11 for y in years] # the anniversary month
return pd.DataFrame( # noqa: F821
{
"years_to_horizon": [years_to_horizon(t) for t in bom],
"alloc_euro": [alloc_euro(t) for t in bom],
"prem_to_av_pp": [prem_to_av_pp(t) for t in bom],
"switch_pp": [switch_pp(t) for t in bom],
"arbitrage_charge_pp": [arbitrage_charge_pp(t) for t in bom],
"av_euro_pp": [av_euro_pp(t) for t in eom],
"av_uc_pp": [av_uc_pp(t) for t in eom],
"av_pp": [av_pp(t) for t in eom],
"death_floor_pp": [death_floor_pp(t) for t in eom],
"pols_if_eoy": [pols_if_at(t, "AFT_DECR") for t in eom],
},
index=pd.Index(years, name="t_year"), # noqa: F821
)
[docs]
def result_settlement():
"""The settlement at the horizon, as a Series of per-policy amounts.
The notes' second worked-example table: the balance settled, the capital leg, the
capital converted, the gross and net *rente*, its monthly equivalent, the commutation
test and its outcome, and the two mutually exclusive ways the annuity leg leaves —
``commuted_pp`` if the test passes and ``annuity_conversion_pp`` if it does not.
Everything here is read at the **last projected month**, ``t_last = proj_len() - 1``
— the horizon anniversary. Every value in it is the annual-step model's, to floating
point: the settlement is a contractual event on a date the finer grid does not move.
"""
t_last = proj_len() - 1
return pd.Series( # noqa: F821
{
"av_pp": av_pp(t_last),
"annuity_factor": annuity_factor(),
"annuity_share": annuity_share(),
"capital_leg_pp": capital_leg_pp(),
"capital_instalment_pp": capital_instalment_pp(),
"annuity_cap_pp": annuity_cap_pp(),
"rente_gross_pp": rente_gross_pp(),
"rente_net_pp": rente_net_pp(),
"rente_net_mth": rente_net_pp() / payment_mths, # noqa: F821
"is_commuted": float(is_commuted()),
"commuted_pp": commuted_pp(),
"annuity_conversion_pp": annuity_conversion_pp(),
"death_floor_pp": death_floor_pp(t_last),
"pols_if_eoy": pols_if_at(t_last, "AFT_DECR"),
},
name="settlement",
)
# ---------------------------------------------------------------------------
# References
data = ("Interface", ("..", "Data"), "auto")
point_id = 1
omega_age = 120
mort_be_factor = 0.85
load_rate = 0.025
charge_euro = 0.007
charge_uc = 0.007
arb_rate = 0.003
arrear_charge_rate = 0.015
return_euro = 0.0338
return_uc = 0.05
transfer_indemnity = 0.01
transfer_indemnity_years = 5
floor_cease_age = 70
death_floor_cap = 762245.0
commute_threshold_mth = 110.0
payment_mths = 12
expense_maint = 30.0
inflation_rate = 0.018
roll_fwd_tol = 1e-10
pd = ("Module", "pandas")