Implementation Notes#
Status: Draft, 2026-08-26. Built from
products/per_assurance/technical-notes.md;
the product it implements is specified in
product-spec.md.
This is a mechanics demonstration, not a pricing or reserving result. The mechanics are sourced — the blocage and the seven
L. 224-4early-release cases R3, release paid as a single payment of all or part of the eligible rights R5 D. 224-4, the 1 % transfer indemnity nil after five years from the first versement R3 L. 224-6, the 0 % maximum technical rate for a PER tariff R9 A. 142-1, the regulatory de-risking grid and its four qualified profiles R6 art. 1, the euro capital floor stated net of loading and net of charges levied [S1] [S3] [S7], death closing the plan R3 L. 224-4 II, the compartment-3 annuity-only rule R3 L. 224-5 [S2], and the €110 monthly quittance commutation threshold R10 A. 160-2. Every rate is a std standardization: the encadré requires charge maxima to be disclosed and caps no level REG-R30, no sampled insurer publishes an annuity rate card [S1] [S2] [S4] [S7], TH 00-02 / TF 00-02 and TGH05 / TGF05 are cited and not shipped REG-R21 REG-R22 REG-R23, and no public French experience exists for PER early-release, transfer or annuitisation behaviour.
Run it#
python products/per_assurance/run.py # the notes' worked example
python products/per_assurance/run.py 6 # the annuity that is not commuted
python products/per_assurance/run.py 10 # the cell whose death floor bites
Three lines to the same thing:
import modelx as mx
model = mx.read_model("products/per_assurance/PER_FR_S")
model.Projection[1].result_cf()
Four frames come out of it, two on the month and two on the plan year.
result_cf() gives the cash flows, one row per month, and its pols_if column is the
count each row opens with, which is the weight that row’s flows carry;
result_state() gives the glide path and the two supports, also monthly, where five of
its ten columns move in the month that opens a plan year and nowhere else.
result_cf_annual() sums the cash flows into plan years, and result_state_annual()
reads the state frame at the two months that determine each of its columns — that last one
is the technical notes’ worked-example table, whose pols_if_eoy column is the notes’
end-of-year l(t). result_settlement() gives the settlement at the horizon, down to the
commutation test.
The time index#
t is 0-based and counts plan months, as in every monthly model in this library:
t = 0 is the first projected month, month t runs from time t to time t + 1, and
proj_len() = 12 × proj_years() is the number of projected months rather than the last
index. Both monthly frames are range(proj_len()) — t = 0 … proj_len() − 1,
proj_len() rows — so the anchor cell’s twelve plan years are 144 months labelled
t = 0 … 143, and its settlement falls on row 143. Nothing is indexed at t = −1: the
state carried into the first month is the "BEF_REBAL" timing of av_euro_pp_at,
av_uc_pp_at, av_pp_at and death_floor_pp_at, which returns the model point’s *_init
columns at t = 0 and last month’s closing balance afterwards.
Three clocks run beside t and none of them is it:
duration_mth(t) = tis the months elapsed. It exists so that the monthly models in this library share one vocabulary, and so that the other two read as// 12of it rather than as a baret // 12.duration(t) = duration_ifo + t // 12is the completed years since the first versement, 0-based in lifelib’s sense — nil through the plan’s first year. An in-force cell opens the frame att = 0like any other; its history is carried induration_ifo, not in a frame offset, which on the finer grid is worth more rather than less: there is noproj_start()here and no month of the frame that is not projected.plan_year(t) = duration(t) + 1is the plan’s own 1-based ancienneté year, the contractual label the ancienneté schedules are written in. It keysexit_table.csvand the transfer indemnity window (plan_year(t) < 5), and it is the only place a 1-based year appears in a formula.
All three step at the plan anniversary and are constant through the twelve months in
between, and so are years_to_horizon(t) = proj_years() − t // 12 and the attained age
age(t) = age_init() + t // 12.
Two months of each plan year carry its annual events, and they are not the same month.
is_plan_boy(t) — t % 12 == 0 — is the month that opens a plan year, where the
versement arrives and the balance is rebalanced onto the glide path. is_anniv(t) —
t % 12 == 11 — is the month that closes it, where the management charge is levied and,
at t = proj_len() − 1, where the plan is liquidated. The garantie plancher base moves
at those two months and nowhere else. Everything that is not contractually annual — the
crediting of both supports, the three decrements, the maintenance expense — happens every
month, which is what the finer grid is for.
The input CSVs. No column in any of the five is the frame’s t under another name,
and none of them shifts with it:
File |
Column |
Decision |
|---|---|---|
|
|
Unchanged, and unchanged by the monthly grid too. It is a whole-years-remaining key, read at |
|
|
Unchanged. Its first row is the plan’s first year, so it is a 1-based ancienneté label read at |
|
|
Unchanged. Attained age, read at |
|
|
Unchanged. Ages, not points on the frame |
|
|
Unchanged. An elapsed count in whole years, already 0-based: two completed years is |
|
|
Unchanged. An amount per year, collected whole in the month that opens each plan year. No sourced modal factor exists for this product, so a |
Every key in that table is a whole year or an age, which is why the finer grid needed no
input change: each is read at the plan year containing the month. The transfer-indemnity
window is the one that looks like it might have moved and provably has not — because
duration_ifo is a whole number of years, plan_year(t) < 5 holds exactly when
12 × duration_ifo + t < 48, so the window is the same to the month on either grid.
Accumulation with a two-way exit#
That sentence is the product, and it is the reason this model looks unlike the other savings models in the library.
The plan is blocked until the L. 224-1 maturity R3. There is no surrender right,
no surrender charge and no market value adjustment, and the contracts say so in terms:
the accumulation phase carries “no surrender right except in the statutory cases”
[S2] [S3] [S4] [S7]. So there is no lapse_rate and no claims_lapse anywhere in
PER_FR_S, and test_there_is_no_lapse_machinery_anywhere asserts their absence
rather than leaving it to inspection.
What leaves the book instead are two decrements that are not the same event and do not pay the same amount:
|
|
|
|---|---|---|
What it is |
Déblocage anticipé on one of the seven |
Transfer of acquired rights to another PER R3 L. 224-6 |
Trigger |
A listed event — death of a spouse, invalidity, serious illness of a dependent child, over-indebtedness, exhaustion of unemployment rights, business liquidation, purchase of the main residence |
The holder’s election, at any time |
Charge |
None [S2] [S3] [S7] |
1 % of acquired rights while |
Pays |
The whole account value |
|
Compartment 3 |
Reduced — the main-residence limb is closed to it R3 L. 224-4 I 6° |
Unchanged |
Where the money goes |
Out of the regime |
Stays in the regime: the blocage, the compartments and the exit conditions travel with it |
Using one decrement for both, or naming either a lapse, silently attaches the wrong
payment formula to half the exits — and they dominate the run-off. At 2.60 % a year
combined they remove about a quarter of the book over the anchor cell’s twelve years, far
more than mortality; the aggregate anchor is 2.62 % of accumulation-phase provisions
leaving every year R22. The two rates stay annual — they are what exit_table.csv
tabulates — and are applied month by month at the constant-force conversion, so the total
they remove over a plan year, and the in force at every anniversary, is exactly what the
annual grid produced. What the finer grid moves is the split between the three
decrements, because the ordered chain is walked twelve times with small steps instead of
once with large ones: over the anchor cell’s first plan year deaths fall 1.19 %, releases
0.23 %, and transfers rise 0.98 %, summing to the same total.
Only the main-residence case of the seven is discretionary, and none of them responds to
investment performance, so a dynamic moneyness multiplier would be a category error on
early_release_rate. The behavioural lever that does exist on this product — the holder
moving the declared retirement date, which re-cuts the whole allocation immediately
R5 D. 224-3 [S3] [S4] — has no public calibration and is not modelled.
The glide path is an input table#
allocation_grid.csv is keyed by (allocation_profile, years_to_horizon) and gives
euro_share and uc_share. alloc_euro(t) is a lookup, not a formula.
k(t) = n − t // 12
a(t) = allocation_grid[allocation_profile, k(t)].euro_share
The 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 deliberate, and it is the single most important design decision in this model. Gestion pilotée par horizon is the default management by law R3 L. 224-3, the arrêté fixes four qualified profiles and a minimum low-risk share by distance to the declared liquidation date R6 art. 1, and in this market the regulatory grid is not a floor insurers beat; it is the product — Suravenir reproduces it verbatim [S7] and Generali’s profiles hit exactly those percentages over exactly those bands [S2]. But the anchor contract sits above it on twenty one-year bands rather than four [S1], and an insurer may restate a profile’s allocation unilaterally [S3] [S4]. A published grid is a snapshot. Putting it in a file makes substituting one a table edit.
Profile |
|
|
|
|
|---|---|---|---|---|
|
30 % |
60 % |
80 % |
90 % |
|
— |
20 % |
50 % |
70 % |
|
— |
— |
30 % |
50 % |
|
— |
— |
30 % |
50 % |
dynamique and offensif are shipped identical, which is what the arrêté says
R6 art. 1 rather than an oversight.
The boundary belongs to the tighter band std: k = 10 reads 20 %, k = 5 reads
50 %, k = 2 reads 70 %. R6’s part (a) grid was extracted — percentages and band
headings both [research §5] — but the headings as rendered read “≥ 10 years out” and
“from 10 years out”, which overlap at k = 10, and the same at 5 and at 2. The text
therefore does not say which band a boundary year falls in, the conservative rule is the
model’s own, and the looser reading understates the euro share for a full year at each of
three transitions.
“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 R5 D. 224-3, and the two contract definitions found disagree — SRRI ≤ 3 [S7] against ≤ 2 including the euro fund [S3]. Realising the bucket as the euro support is the most conservative reading of both and keeps the model to two supports. The unlisted minimum that bites since 24 October 2024 R6 part (b) R7 is not carved out of the UC bucket.
The rebalancing, and which support pays for it#
m(t) = a(t)·A⁻(t) − E_eu⁻(t)
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
in the month that opens a plan year, and with the roles of the two supports exchanged
when m(t) < 0. In the other eleven months m(t) = 0, arb(t) = 0 and V_net = 0, so
the two supports simply carry forward: the rebalancing is a contractual annual event and is
not spread. A ⁻ is the balance carried into the month —
av_pp_at(t, "BEF_REBAL") and its two support halves, which are the model point’s opening
state at t = 0. Two conventions, both std and both load-bearing.
The arbitrage charge comes off the source support. The destination receives the
switch in full, so 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;
taking the charge from the destination instead would put it under, at every crossing, by
(1 − a)·arb. check_euro_share_min() asserts it.
The versement is not a switch. New money is allocated directly at the target mix and
bears no arbitrage charge, which is what “allocation of both contributions and existing
balance” means in the one contract publishing its ladder [S1].
arbitrage_charge_pp(t) = 0 in the anchor cell’s first two plan years, months t = 0 and
t = 12, where the équilibré grid asks for no euro support at all, even though €3 000
was paid in each — and it is 0 in every month that is not a rebalancing month, on every
cell, because there is no switch in those months 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 falls
monotonically to 69.67 % at the anniversary, month 143. The consequence is a real change to
the check: check_euro_share_min() iterates over the rebalancing months alone, because
in the other eleven the residual is negative by construction, and a check looping over the
whole frame would fail on every model point while nothing was wrong. Nothing in the model
reads the intra-year share, because re-imposing the target there would invent a rebalancing
frequency the contract does not have — real contracts rebalance quarterly to semi-annually
[S1] [S3] [S7], the finer grid could now express that, and doing it would be an assumption
change rather than a grid change. Measured, quarterly rebalancing costs the anchor cell
0.0142 % of its final balance and €0.46 of extra arbitrage over twelve years.
The one case the convention cannot cover#
The two halves of “charge the source” and “land at or above the minimum” come apart on a reverse switch, and the notes do not say which half wins. Model point 2 arrives holding 40 % euro against a 20 % minimum nine years out — which is what an incoming transfer can do — so its first rebalancing sells euro down to the grid, the euro support is itself the source, and charging the source takes €12.00 out of the very balance the minimum is measured on. The share lands €9.60 below the line on €19 988, or 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. check_euro_share_min() therefore
measures against euro_share_min_bound(t) — zero on a de-risking switch and
−(1 − a)·arb on a reverse one — so it states the property that is actually true in each
direction and still fails if the charge is taken from the destination on a de-risking
switch. A firm resolving the gap the other way changes one branch of av_euro_pp_at and
nothing else.
Settlement: a capital leg, and a rente that usually is not one#
In the last projected month, t = proj_len() − 1 — 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, so every figure in result_settlement() is the
annual-step model’s to floating point. The capital leg bears no exit charge [S1] [S2] [S3] [S7] [S8]. The annuity leg is converted at
annuity_factor(), charged the frais d’arrérages at 1.50 % std [S8], and tested
against the commutation threshold.
Two things follow from the 0 % maximum technical rate R9 A. 142-1.
a_x is an undiscounted expected-instalment count, not a discounted annuity factor.
Nothing in the model discounts it and rente_gross_pp() is a plain division; 22.0000
asserts 22 further annual payments to a male aged 64 std. A 2 % rate would shorten
the factor to about 17.66 and inflate the annuity by roughly a quarter. Note what fixes the
annuity’s frequency: that table, which counts annual instalments — not the projection
grid, which is monthly. Paying quarterly or monthly means replacing annuity_factor.csv.
Commuting at the conversion basis is nearly value-neutral:
commuted = rente_net · a_x = annuity_cap · (1 − c_arr)
check_commutation_identity() asserts it. Commuting at a book value instead
manufactures a gain out of nothing. The mechanism is not marginal — €272 m of 2024
individual-PER benefits at an average €16 200 R22 — and the €110 threshold is a
monthly quittance scaled by the months in the payment period R10 A. 160-2, so an
annual frequency tests against €1 320. Testing €110 against an annual instalment would
commute almost nothing.
The anchor cell’s annuity is €78.45 a month and duly commutes; the cliff sits at
annuity_share = 42.06 %, and model point 6 is the same cell at 50 %, whose €130.75 a
month is paid as a rente. That is a live instance of the market pattern: the average PER
annuity in payment is €1 300 a year, about €108 a month, just under the threshold
R22 R10.
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 [S7], reversion, annuités garanties
and revaluation through the profit-sharing account are specified in
../rente_viagere/technical-notes.md. Duplicating
the payout chassis here would give the library two of them to keep in step.
Staged capital is settled at the horizon std. The capital fractionné option
R3 L. 224-5 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 model records
the whole capital leg at t = proj_len() − 1, publishes the instalment as
capital_instalment_pp(), and credits nothing to the unpaid balance. The finer grid does
not make the fractionné schedule expressible — its instalments fall after the
horizon and the frame ends there — so the reason for the simplification is now the frame’s
end rather than the grid’s coarseness.
Exact on an undiscounted gross-cash-flow basis;
not exact for anything that discounts, and a discounting layer needs the schedule.
No PPB stock std. The euro support is credited at the asset return [S9] and
charged on the post-crediting balance [S3]. The effective euro rate net of charge is
1.0338 × 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 provision pour participation
aux bénéfices release [S9]. A PER’s PPB release horizon is fifteen years rather than
eight, because the commitments sit in a comptabilité auxiliaire d’affectation
REG-R16 R8 L. 142-4; modelling that stock is a fund-level scenario extension, and four
of the seven sampled contracts have no contractual profit-sharing clause at all
[S4] [S5] [S6] [S7]. The machinery this stands in for is implemented next door in
Euro_FR_S and specified in
../assurance_vie_euro/technical-notes.md —
the compte de participation aux résultats, the PPB dotation-and-release lever and its
vintage clock. Two cautions before lifting it: the release deadline there is eight years
and here it is fifteen, and a PER euro fund is not an assurance vie euro fund. See The
euro leg is cross-referenced, not re-implemented in this product’s technical notes.
Tax is outside the projection. deduction_elected is carried on the model point and
enters no recursion. The election is the pivot of the whole exit tax treatment
R19 R20 R21, but it changes what the holder keeps, not what the insurer pays.
test_the_deduction_election_is_carried_and_inert projects the anchor cell with the flag
flipped and asserts an identical cash flow table.
Also out of scope, per the notes: partial early release leaving the plan in force
R5 D. 224-4; the 15 % transfer-value reduction on euro-denominated rights
R5 R. 224-6 [S8]; profile and horizon changes during the projection; and the provision
de diversification supports [S4] [S6], which are the eurocroissance product.
What the monthly grid changes, and what it does not#
The rule the conversion follows is one sentence: annual assumptions stay annual, annual contract terms stay on their own month of the plan year, and only the grid underneath them gets finer. A monthly grid is not a monthly product. The versement and the rebalancing land whole in the month that opens a plan year, the management charge lands whole in the month that closes it, and the liquidation lands whole at the horizon; the decrements and the two credited returns are converted at the constant force, so twelve months compound back to exactly the published annual figure.
The consequence is worth stating precisely, because it is what makes a monthly run
comparable to the annual-step model it replaced. Every anniversary state and the whole
settlement reproduce that model to floating point — worst relative deviation 1.8e-14
across all twelve shipped model points on the two supports, the account value, the death
benefit, the management charge and the garantie plancher base, and 1.4e-14 absolute on
the in force. So do the plan-year totals of the credited return and of premiums, and
every quantity that is one value per plan year.
The cash flows do not, and that is the point. Claims now fall at the end of the month
of exit and are valued on the balance held then, so claims_death falls 2.49 %,
claims_early_release 1.54 % and claims_transfer 0.35 % on the anchor cell; 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 first plan year — while their total, and so the in force at every anniversary, is
unchanged. result_cf_annual() and result_state_annual() exist so the two grids can be
laid side by side.
Inputs are external files#
The five input CSVs live in this directory, beside run.py — not inside the model
folder. PER_FR_S/ holds nothing but formulas:
products/per_assurance/
model_point_table.csv <- inputs live here
allocation_grid.csv
mort_table.csv
exit_table.csv
annuity_factor.csv
run.py
model.md
product-spec.md <- the documents this model implements
technical-notes.md
sources.md
PER_FR_S/ <- formulas only
__init__.py (model docstring)
_system.json
Data/__init__.py (reads the CSVs, once per model)
Projection/__init__.py (the by-policy projection)
This follows lifelib’s annuallife/TradLife_A. Projection is parameterized by
point_id, so the CSV readers live in an unparameterized Data Space and each file is
read once per model rather than once per model point; a test counts the reads.
Reference |
Cells |
File |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
File |
Contents |
Provenance |
|---|---|---|
|
Twelve model points. Point 1 is the technical notes’ worked example — M 52, horizon 64, |
anchor cell std, technical notes’ worked example; €16 600 is the published average accumulation-phase balance R22 |
|
Target |
|
|
Annual mortality by sex and age 18–120, capped at 1 |
std proxy shaped on French population mortality REG-R24; level anchored so that |
|
|
std, split of the 2.62 % aggregate of R22; c3 loses the main-residence case R3 L. 224-4 I 6° |
|
Undiscounted conversion factors by sex and age 55–80, anchored at 22.0000 for a male 64 |
std placeholder; TGH05 / TGF05 cited and not shipped R12 REG-R21 |
What is deliberately not in a file. The charge levels — the entry loading, the two
management charges, the arbitrage rate, the frais d’arrérages — are Projection
References. The encadré requires maxima to be disclosed and caps nothing REG-R30, and
the sampled range is wide: entry loadings 0 % to 4.80 %, euro management charges 0.50 % to
2.30 % [S1]–[S8]. Every adopted level is a standardization, and putting them where a reader
trips over them is better than filing them in a table that looks like data.
Sign convention#
liability_cf(t) is the technical notes’ orientation, outgo-positive: claims, plus the
capital handed to Rente_FR_S, plus expenses, less versements. net_cf(t) is exactly
its negative, income-positive, which is the house sign across the library, so
result_cf()["net_cf"] can be summed alongside any other model’s without checking which
product it came from. test_both_signs_of_the_net_flow_are_published asserts the two are
negatives to 1e-9 and that the outgo columns rebuild liability_cf.
A contributing plan is cash-positive in every plan year but the settlement one, where
the whole account value leaves at once. On the monthly frame that reads differently: only
the twelve versement months are cash-positive, and the eleven ordinary months of each
plan year carry claims and expenses against no income. The plan-year statement is read off
result_cf_annual(), which is what that frame is for — years 0–10 positive, year 11 at
−47 395.16.
annuity_conversion is an outgo, not a memo item: where the annuity is not commuted
the converted capital leaves this projection. It and commuted_pp are mutually exclusive
by construction, and only one of them is ever non-zero on a cell.
Naming#
Cells names follow basiclife.BasicTerm_S and savings.CashValue_SE wherever those
models have an analogue. Six needed care, and the in-force count needed two entries
because the notes and the library index it at opposite ends of the year.
Notes symbol |
Cells |
Why it needed care |
|---|---|---|
|
does not exist |
There is no surrender right R3 L. 224-4 [S2] [S3] [S4] [S7]. |
|
|
The count the year opens with, |
|
|
The count the year ends with — the notes’ own indexing, and the column their worked-example table prints. It is published as the |
|
|
Signed, because the sign decides which support bears the arbitrage charge. Positive on the ordinary de-risking switch; negative where a cell arrives above the grid’s minimum |
|
|
An undiscounted expected-instalment count, not a discounted annuity factor R9 A. 142-1. Named for what it is so nothing discounts it twice. It counts annual instalments, and it — not the projection step — is what fixes the annuity’s frequency |
|
|
Per policy, and published as a |
|
|
The annual rates the notes tabulate and the CSVs hold, and they keep the bare names for that reason — the library register forbids the opposite spelling. They are properties of the plan year, constant across its twelve months |
|
|
The monthly rates actually applied, |
|
|
The same constant-force treatment in interest form, |
(the month) |
|
|
(the timings) |
|
Points inside a month. |
claims(t, kind) takes "DEATH", "EARLY_RELEASE", "TRANSFER" and "MATURITY", and
the result_cf() columns are named for the kind that produces them. There is no
claims subtotal column beside them.
Standardizations used#
Everything in this list is std: the monthly projection frequency; the annual
versement and rebalancing at the plan-year start and the annual management charge at its
end; the geometric monthly conversion of both credited returns and of all three decrements;
the monthly accrual of maintenance expense and its monthly-compounded inflation; the
decrement and benefit ordering; the glide-path band edges and realising “low risk” wholly as the euro
support; taking the arbitrage charge from the source support and allocating the
versement at the target mix; the entry loading of 2.50 %, the euro and UC management
charges of 0.70 %, the arbitrage rate of 0.30 % and the frais d’arrérages of 1.50 %; the
UC gross return of 5.00 % and the euro-fund gross asset return of 3.38 %, which has a
source for its 2025 level [S9] but none for carrying it flat over twelve years; the whole
mortality table, its anchored level and the
mort_be_factor of 0.85, and the flat 0.00500 placeholder the worked example runs on; the
early-release rate of 1.60 %, the transfer-out rate of 1.00 % and the reduced c3 release
rate; the ordered dependent-decrement convention; folding the garantie plancher charge
into the management charge; the annuity factor ladder and its 22.0000 anchor; the annuity
election of 30 % and deterministic commutation whenever the test passes; maintenance
expense of €30 a plan a year inflating at 1.80 %; keeping the garantie plancher cessation
on the plan year rather than on the birthday month; settling staged capital at the horizon;
and carrying no PPB stock, no partial early release, no 15 % transfer-value reduction and
no profile or horizon change.
Tests#
tests/test_per_assurance_fr.py asserts every row of the notes’ worked example to the
cent and l(t) to six decimals — on the 0-based monthly frame, t = 0 … 143, reading
each row at the rebalancing month 12y and the anniversary month 12y + 11, with
result_cf_annual() summing the 144 rows of result_cf() into the notes’ twelve and
result_state_annual() publishing the notes’ own table — the month-by-month table of the
first plan year, the constant-force rate conversions in the only direction that is true
(1 − (1 − q_m)^12 = q, never 12·q_m = q), that the management charge falls in the
anniversary month alone, the settlement table and the commutation
identity, and
then one test per listed modelling pitfall — the glide-path band edge, the versement
that is not a switch, the source-charging convention and the reverse-switch bound, the
minimum binding at the rebalancing date, the floor identity and the two cells where the
floor bites and where it ceases, the absence of lapse machinery, the transfer indemnity
window measured from the first versement, the three decrements not double-counting, the
undiscounted conversion factor, the monthly commutation threshold and its cliff, the
per-policy against aggregate distinction, and the projection stopping at the horizon with
tax outside it. It asserts the exposure convention separately and over every model point,
because breaking it is silent: result_cf() has to open at pols_if_init() on row
t = 0, each row’s pols_if has to be the count that row opens with, and the notes’
l(t) has to sit one period ahead of it at pols_if_at(t, "AFT_DECR"). It also asserts that a compartment-3
cell electing capital raises, that a contradictory annuity_share raises, that the glide
path can be swapped without touching a formula, and that the model round-trips.
python -m pytest lifelib/libraries/frlib/tests/test_per_assurance_fr.py -q