# modelx: pseudo-python
# This file is part of a modelx model.
# It can be imported as a Python module, but functions defined herein
# are model formulas and may not be executable as standard Python.
"""The by-contract projection of :mod:`~.FIA_US_S`.
The Space is parameterized by ``point_id``, so ``Projection[1]`` is an ItemSpace
projecting model point 1::
>>> Projection[1].result_cf() # the worked-example anchor cell, by month
>>> Projection[1].result_cf_annual() # the same, summed into contract years
>>> Projection.point_id = 2 # or switch the default
.. rubric:: Input data
Inputs are **external files**: plain CSVs living in the model folder's parent directory,
``products/fixed_indexed_annuity/``, 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
``FIA_US_S`` folder without its parent's CSVs produces a model that reads
and then fails on first evaluation.
The readers and the filename References live on the sibling
:mod:`~.FIA_US_S.Data` Space, reached here through the ``data`` Reference, so
each file is read once per model rather than once per model point:
========================= ================================== ==========================
Reference (on Data) Cells File
========================= ================================== ==========================
model_point_file data.model_point_table() model_point_table.csv
mort_table_file data.mort_table() mort_table.csv
surr_charge_file data.surr_charge_table() surr_charge_table.csv
rollup_file data.rollup_table() rollup_table.csv
payout_rate_file data.payout_rate_table() payout_rate_table.csv
rate_scenario_file data.rate_scenario() rate_scenario.csv
withdrawal_file data.withdrawal_table() withdrawal_table.csv
========================= ================================== ==========================
.. rubric:: Projection basis
``t`` is the **month index and it is 0-based**: ``t = 0`` is the first month of a model
point projected from issue, ``duration(t) = t // 12`` is the completed contract years at
the start of month ``t``, and the contractual label is ``policy_year(t) = duration(t) + 1``.
The frame is ``t = entry_mth() … proj_len() - 1``, and ``proj_len()`` is ``12 *
policy_term()``. The grid matches :mod:`.MYGA_US_S`, :mod:`.Term_US_S` and the rest of the
library, and it is what the product assignment table records for this product.
**The monthly grid does not make the product monthly.** The technical notes make the
anniversary the single event date — "All transactions occur **at** the anniversary and are
processed as the last events of the contract year ending there" **[std]** — and that is
still true here: every contractual event is an annual event landing in the **anniversary
month**, ``is_anniv(t)``, the last month of each contract year. What the finer grid buys is
everything that is *not* a contractual event: mortality and surrender now fall in the month
they happen rather than all at the anniversary, the Model #805 floor and the fixed account
accrue month by month so a mid-year death or surrender is valued on the balance it actually
has, and maintenance expense accrues where it is incurred. Because the monthly decrement
rates compound to the annual ones and every contractual step still happens once a year,
**every state value at an anniversary is identical to the annual grid's** — account value,
benefit base, rollup base, lifetime withdrawal, Model #805 floor, phase and ``pols_if`` at
``t = 12k``. What changes is the timing and the weighting of the cash, which is the point.
Three vocabulary cells carry the split, and the whole model is written in terms of them:
:func:`duration` for anything keyed by contract year, :func:`is_anniv` for anything that
happens once a year, and :func:`year_open_mth` for the month whose closing value opens the
contract year — because on a monthly grid the previous *month* is no longer the previous
*year*, and reaching for ``t - 1`` where a year is meant is the mistake this conversion
exists to prevent.
``age(t) = age_at_entry() + duration(t)`` is the attained age **opening the contract year**
of month ``t``, so mortality over that year reads ``age(t)`` itself — the library
convention of :mod:`.Term_US_S` and :mod:`.MYGA_US_S`. Age changes on the anniversary, not
on the birthday and not monthly. The transactions at the closing anniversary are one year
older, and that age is :func:`exercise_age`, ``covered_age(t) + 1``: it is what reads the
lifetime-withdrawal percentage table and what clears the contractual minimum exercise age.
**There is no issue-instant row.** The single premium, the premium bonus and the
acquisition expense are beginning-of-month flows of month ``0`` on a new-issue model point,
whose first projected month is ``t = 0``. A model point may instead enter **in force** after
``entry_year()`` completed contract years, on balances stated in ``model_point_table.csv``
— which is what the worked example does, its anniversary-7 balances being explicitly
"illustrative ... broadly consistent with a seven-year deferral" **[std]** rather than
derived. ``entry_year()`` is an elapsed count of whole years, so ``entry_mth() = 12 *
entry_year()`` is the index of the model point's first projected month and the frame always
opens on an anniversary. Every recursion opens there on the ``*_init`` cells, read through
the opening timing (``av_pp_at(t, "BEF_INV")``, ``benefit_base_pp_at(t, "BEF_ROLLUP")``,
:func:`phase_open`, :func:`rider_in_force_open`), and ``result_cf()`` is indexed from
``entry_mth()``.
The anniversary's eight processing steps are the notes' own, all eight still annual and all
eight landing in the anniversary month, and each intermediate value is reachable through a
``timing`` argument rather than being buried inside one formula:
1. index credit and fixed interest --- :func:`index_credit_pp`,
:func:`fixed_interest_ann_pp`
2. rider charge on the **opening** benefit base --- :func:`rider_charge_pp`
3. benefit base: rollup, stack, step-up --- :func:`rollup_pp`, :func:`stack_pp`
4. lifetime and excess withdrawal --- :func:`lw_pp_at`, :func:`wd_pp`
5. charges on the excess and the proportional reduction --- :func:`wd_reduction_rate`
6. guaranteed minimum value roll --- :func:`mgsv_pp`
7. phase transition including the depletion test --- :func:`phase`
8. decrements --- :func:`pols_if_at` at ``"AFT_DECR"``
Steps 1--3 are skipped in ``DEPLETED``, steps 1--7 in ``TERMINATED``. Step 8 is the one
that is **not** annual: it runs every month, on :func:`mort_rate_mth` and
:func:`lapse_rate_mth`. Steps 1 and 6 keep a monthly component — the fixed account accrues
at ``(1 + i_F)^(1/12) - 1`` and the Model #805 floor at ``(1 + i_nf)^(1/12)`` — while their
annual figures, :func:`fixed_interest_ann_pp` and the anniversary deduction, stay whole.
Between anniversaries the contract is quiet: no withdrawal, no rider charge, no index
credit, no rollup and no step-up. The indexed account is therefore **flat between
anniversaries**, because the annual point-to-point segment locks only at maturity and "the
credit ... cannot be lost to later declines" [S1] is a statement about a credit that has
not yet been earned. That is the sourced rule "withdrawals are not credited with index
interest in the year they are taken" [S1] carried to mid-year deaths and surrenders
**[std]**; crediting a part year would be the *daily interim value* variant the notes
exclude.
.. rubric:: Naming
Cells names follow :mod:`.MYGA_US_S` — the deferred annuity chassis this
product sits on — and through it lifelib's ``basiclife.BasicTerm_S`` and
``savings.CashValue_SE``: ``pols_*`` for policy counts, ``av_*`` for account values,
plural nouns for cash flows, ``*_rate`` for rates, ``*_pp`` for per-contract amounts,
``*_at(t, timing)`` for a quantity read at a point inside the anniversary. The technical
notes use compact actuarial symbols instead. The mapping is:
================================================ ================================ ========================================
Notes symbol Cells Meaning
================================================ ================================ ========================================
t (the projection index) Month index, 0-based
(elapsed years) duration(t) Completed contract years at month t
(elapsed months) duration_mth(t) Completed contract months; = t
(the anniversary month) is_anniv(t) Last month of a contract year
(that month's index) anniv_mth(t) Anniversary closing month t's year
(the year's opening month) year_open_mth(t) Anniversary that opened it
(contract year) policy_year(t) Contract year containing t; duration + 1
x age_at_entry Issue age (ANB)
x + duration(t) age(t) Attained age opening month t's contract year
(younger covered life) covered_age(t) Younger covered age opening that year
x + duration(t) + 1 exercise_age(t) Covered age at the closing anniversary
(none) policy_term Contract years from issue to maturity_age
(none) proj_len Number of projected months; 12 x policy_term
(in-force cell entry) entry_year Completed years at entry
(none) entry_mth First projected month; 12 x entry_year
av_initial av_pp_init AV at entry, else P x (1 + b)
bb_initial benefit_base_pp_init BB at entry, else P
mgv_initial mgsv_pp_init MGV at entry, else 0.875 x P
(RB at entry) rollup_base_pp_init RB at entry, else P
(LW at entry) lw_pp_init LW at entry, 0 before exercise
(pi at entry) payout_rate_init Payout percentage locked at entry
(phase at entry) phase_init Phase at entry
P premium_pp Single premium
b bonus_rate Premium bonus rate (7% [S5])
b/(1+b) bonus_factor Clawback factor -- **not** b [S10]
v(t) vest_rate(t) Vested bonus percentage [S5]
alloc_indexed/alloc_fixed alloc_indexed / alloc_fixed Account allocation at issue
I(y) index_level(y) Index level at **anniversary number** y
R(t) index_return(t) I(y+1)/I(y) - 1 over month t's year
c cap_rate Declared annual cap (5.25% [S2])
c_min cap_rate_min Guaranteed minimum cap (0.25% [S4])
c(t) cap_rate_in_force() Cap actually applied
f floor_rate Index credit floor (0%)
p, s, d par_rate, spread_rate, Participation rate [R1]; spread
trigger_rate and trigger, levels **[std]**
cr(t) credit_rate(t) Credit rate for month t's contract year
(cr on a return) credit_rate_on(r, method) The crediting engine itself
IC(t) index_credit_pp(t) Index credit, in the anniversary month
FI(t) monthly fixed_interest_pp(t) Fixed account interest for month t
FI(t) annual fixed_interest_ann_pp(t) The contract year's, for the stack
i_F, i_F,min fixed_rate, fixed_rate_min Declared / guaranteed fixed rate
kappa av_int_factor Share of IC reaching the AV [S3]
(IC x kappa + FI) inv_income_pp(t) Interest credited to the AV
A(t) av_indexed_pp(t) Indexed account balance
F(t) av_fixed_pp(t) Fixed account balance
AV(t) av_pp(t) Account value closing month t
AV(0), AV(1), AV(2), AV(t) av_pp_at(t, timing) BEF_INV / BEF_FEE / BEF_WD / EOY in
order; BEF_PREM opens the month
AV(0) of the contract year av_pp_year_open(t) Balance opening month t's contract year
F(0), A(0) of that year av_fixed_pp_year_open(t), The fixed and indexed openings: the
av_indexed_pp_year_open(t) credit base and the fixed-interest base
l(t) x AV(t) av_at(t, timing) In-force weighted account value
(shortfall at exhaustion) av_depletion_pp(t) Withdrawal the AV could not fund
phi rider_charge_rate Rider charge rate (0.95% [S9])
Phi(t) rider_charge_pp(t) Rider charge amount
BB(t) benefit_base_pp(t) GLWB benefit base, closing
BB(3), BB(4) benefit_base_pp_at(t, timing) BEF_ROLLUP/BEF_STEP_UP/BEF_WD/EOY
RB(t) rollup_base_pp(t) Rollup base
g(t) rollup_rate(t) Guaranteed simple rollup rate [S2]
rollup(t) rollup_pp(t) Rollup dollar increment
m stack_factor Stacking factor (1.50 [S8][S9])
stack(t) stack_pp(t) Stacking credit
T_g in_growth_period(t) Benefit base still growing
(the step-up) step_up_applies(t) Whether the ratchet is tested
LW(t) lw_pp(t) Lifetime withdrawal amount, closing
LW before/after step 5 lw_pp_at(t, timing) BEF_WD / EOY
pi(a, basis) payout_rate(a, basis) Payout percentage by age band [S3]
(pi locked at exercise) payout_rate_locked(t) The percentage actually in force
h(a) activation_rate(a) Activation incidence, reported only
G(t) wd_pp(t) Gross withdrawal requested
min(G, LW) wd_guar_pp(t) Guaranteed portion
E(t) wd_excess_pp(t) Excess above the guaranteed amount
(unpayable withdrawal) wd_unfunded_pp(t) Requested but neither funded nor
guaranteed, on TERMINATED
(guaranteed portion paid) wd_guar_paid_pp(t) min(G, LW) less its share of it
(excess portion paid) wd_excess_paid_pp(t) E(t) less its share of it
FW(t) free_wd_allow(t) Free withdrawal amount
0.10 x AV(0)(t) free_wd_base(t) Base of the free amount
(FW consumed) wd_free_pp(t) Free-allowance portion of the
withdrawal
(FW remaining) free_wd_remain(t) Free amount left for the excess
X(t) on a withdrawal wd_charge_base_pp(t) Amount exposed to charge and MVA
X(t) on a surrender surr_charge_base_pp(t) Same, at a full surrender
sc(t) surr_charge_rate(t) Surrender charge percentage [S5]
SC(t) on a withdrawal wd_charge_pp(t) Surrender charge on the excess
SC(t) on a surrender surr_charge_pp(t) Surrender charge on a surrender
CB(t) on a withdrawal wd_clawback_pp(t) Non-vested bonus recovery
CB(t) on a surrender surr_clawback_pp(t) Same, at a full surrender
(the clawback formula) bonus_clawback_on(...) (1-A) x [B/(1+B)] x C [S10]
MVA(t) on a withdrawal wd_mva_pp(t) Collared MVA on the excess
MVA(t) on a surrender mva_pp(t) Collared MVA on a surrender
(the MVA collar) mva_pp_on(t, base, gross) The collared MVA on any base
(the MVA rate) mva_rate(t) [(1+i0)/(1+it)]^(n/12) - 1 [S10]
i0 mva_ref_yield_at_issue Reference index at issue
i_y mva_ref_yield(y) Reference index at **anniversary number** y
n/12 mva_term(t) Years left in the MVA period
(MVA applies at all) mva_in_force(t) Inside the MVA period
rho(t) wd_reduction_rate(t) Proportional reduction factor
(rho's formula) wd_reduction_rate_on(...) The [S9] worked construction
Wcum(t) wd_cum_pp(t) Cumulative gross withdrawals
G - SC - CB + MVA wd_payment_pp(t) Cash paid on the withdrawal
(pre-floor surrender value) surr_value_pp(t) AV - SC - CB + MVA
CSV(t) surr_benefit_pp(t) Surrender benefit paid
MGV(t) mgsv_pp(t) Model #805 guaranteed minimum
i_nf mgsv_rate Nonforfeiture accumulation rate
(the $50 charge) mgsv_charge_pp(t) Annual contract charge, 0 **[std]**
(statutory i_nf) mgsv_rate_statutory(...) Model #805 4B/4C indexed rate
phase(t) phase(t) Closing ACCUM/INCOME/DEPLETED/TERMINATED
(phase during the year) phase_open(t) Phase steps 1-7 are processed under
(first exercise) is_exercise(t) The first lifetime withdrawal
rider_in_force(t) rider_in_force(t) Rider still alive closing month t [S9]
rider_in_force(k-1) rider_in_force_open(t) Rider alive opening the contract year
depletion_cause(t) depletion_cause(t) Excess / charge / negative MVA flag
q(t) mort_rate(t) Annual mortality of month t's contract year
1 - (1-q)^(1/12) mort_rate_mth(t) The monthly rate actually applied
(A/E deviation) mort_ae_factor Mortality A/E factor, 100% **[std]**
w(t) lapse_rate(t) Annual surrender rate of that year
1 - (1-w)^(1/12) lapse_rate_mth(t) The monthly rate actually applied
w_base(t) lapse_rate_base(t) Base surrender table
w_shock shock_lapse_rate(t) 33% / 10% / 5% by rider state [R8]
M_money(t) lapse_moneyness_factor(t) Moneyness suppression **[std]**
l(k) at t = 12k pols_if(t) In-force opening month t
(closing the month) pols_if_at(t, "AFT_DECR") In-force at the end of month t
l(0) pols_if_init In-force at entry
(the four readings) pols_if_at(t, timing) BEF_DECR/BEF_MORT/BEF_LAPSE/AFT_DECR
l q_m pols_death(t) Deaths in the month
l (1-q_m) w_m pols_lapse(t) Full surrenders in the month
(none) pols_maturity(t) Survivors at the horizon
P at t = 0 premiums(t) Premium income
min(G, LW) x l(t) wd_guar(t) Guaranteed withdrawal outgo, paid
(E - SC - CB + MVA) l(t) wd_excess(t) Excess withdrawal outgo, paid
LW while DEPLETED income_payments(t) Post-depletion income outgo
(all three together) withdrawals(t) Total withdrawal outgo
(ledger benefit lines) claims(t, kind) Benefit outgo by kind
claim_pp(t, kind) Benefit per contract by kind
claims_from_av(t, kind) Account value released by a claim
claims_over_av(t, kind) Benefit paid above the account value
(no separate commission) commissions(t) Acquisition commission, 0 **[std]**
0.06 P; (80/12) x 1.025^(t/12) expenses(t) Acquisition and monthly maintenance
premium tax premium_taxes(t) Premium tax, 0% **[std]**
NetCF(t) net_cf(t) Net cash flow for the month
(the annual summary) result_cf_annual() result_cf() summed into contract years
================================================ ================================ ========================================
Six names needed care.
**pols_if(t) is the notes' l(k) at the twelfth months.** Across this library
``pols_if(t)`` is the number in force at the **start** of ``t`` and is the weight carried
by that same row's cash flows — :mod:`.Term_US_S` has ``pols_if(0) == pols_if_init()`` and
``savings.CashValue_SE`` has ``pols_if(t)`` equal to ``pols_if_at(t, "BEF_MAT")``. The
notes define ``l(k)`` as the in-force probability at the *end* of contract year ``k``,
which is anniversary ``k``, which is where contract year ``k + 1`` opens: on the monthly
index that is month ``12k``, so ``pols_if(12k) = l(k)`` with
``pols_if(entry_mth()) = l(0) = 1`` for a new issue. Because the monthly rates compound to
the annual ones this equality is exact, not approximate.
**The one place the finer grid changes an answer rather than its resolution** is the weight
on the anniversary's own cash. A withdrawal, a rider charge or a surrender charge falls in
month ``12k + 11`` and is weighted by ``pols_if(12k + 11)`` — the contracts that actually
*reach* the anniversary — where the annual grid weighted it by ``l(k)``, the contracts that
entered the year. The second is an approximation the annual grid could not avoid: it pays a
full year's guaranteed withdrawal to a contract that died in month three. The
reconciliation the convention buys is unchanged, and now holds row by row on the finer
grid: the ``pols_if`` column of :func:`result_cf` is the divisor of the cash flows on its
own row — ``result_cf()["wd_guar"][t] / wd_guar_paid_pp(t)`` is ``pols_if(t)``.
**MGV is MGSV.** The Model #805 floor is called ``MGV`` in these notes, after [S10], and
``MGSV`` in ``products/fixed_deferred_annuity/``, after its own specimen. Both source
files say in terms that this is **one quantity under two labels** and must not be modeled
as two. The chassis name :func:`mgsv_pp` is used here, so the two annuity models share it.
The *recursion*, however, is **not** the chassis's: this product accretes and then deducts,
``MGV(t) = max(0, MGV(t-1) x (1 + i_nf) - G(t))``, while the chassis deducts and then
accretes, ``MGSV(t) = [MGSV(t-1) - d(t) - c(t)] x g``. The worked example pins the FIA
ordering — ``93,811.84 x 1.01 - 10,144.16 = 84,605.80`` — and the chassis notes explicitly
warn against carrying their recursions across unexamined. Same name, same concept,
different arithmetic, on purpose.
**E(t) is not the chassis's E(t).** On the chassis ``E(t)`` is the amount exposed to the
surrender charge and the MVA, and is named ``wd_excess_pp`` / ``surr_excess_pp``. Here
``E(t)`` is the notes' **excess withdrawal** — the part of a gross withdrawal above the
guaranteed lifetime amount, the quantity that permanently reduces the guarantee and, at
exhaustion, destroys it. That is the product's headline concept and it keeps the name
:func:`wd_excess_pp`. The chargeable amount, the notes' ``X(t)``, becomes
:func:`wd_charge_base_pp` on the withdrawal path and :func:`surr_charge_base_pp` on the
surrender path. Reading a chassis formula across without renaming would silently charge a
surrender charge on the wrong base.
**age(t) opens the contract year; exercise_age(t) closes it.**
``age(t) = age_at_entry() + duration(t)`` is the attained age at the anniversary that opens
month ``t``'s contract year, exactly as in :mod:`.Term_US_S` and :mod:`.MYGA_US_S`, and
:func:`mort_rate` reads it directly — the annual rate it returns is then converted for the
month by :func:`mort_rate_mth`. Every transaction, however, falls at the **closing**
anniversary, one year older, and the payout percentage depends on that age: the anchor
cell's first lifetime withdrawal, in contract year 8 from an issue age of 62, is taken at
attained age 70 and reads ``pi(70, single) = 5.20%``. :func:`exercise_age` carries that age
— ``covered_age(t) + 1`` — so the ``+ 1`` is named once rather than spelled at each use.
**M_shock is absorbed into lapse_rate_base.** The notes write
``w(t) = min(0.35, w_base(t) x M_shock(t) x M_money(t))`` but state the shock as three
*absolute* rates (33% / 10% / 5%) rather than as multipliers on the 6% ultimate. Carrying
``M_shock`` as a separate factor would require inventing a denominator, so
:func:`lapse_rate_base` returns :func:`shock_lapse_rate` in the shock year and
:func:`lapse_rate` multiplies only by :func:`lapse_moneyness_factor`. The name
``shock_lapse_rate`` follows :mod:`.Term_US_S` — but the **timing does not**, and the
contrast is worth stating. There the notes put the shock "in full at the end of the final
level-period month", a contractual date, so :mod:`.Term_US_S` drops the whole shock in that
one month. Here the notes state it as the surrender rate *of contract year 11*, a year that
carries no surrender charge in any of its months, so there is no date inside the year for
the decision to cluster on and :func:`lapse_rate_mth` spreads it like any other annual
rate. The one surrender that is not spread is the deemed full surrender at termination,
which is a contractual event and lands whole on the anniversary.
**d and c collide across the library.** ``d`` is the performance-trigger rate in these
notes, deaths in :mod:`.Term_US_S` and the floor's withdrawal deduction on the chassis;
``c`` is the declared cap here and the floor's contract charge on the chassis. The names
``trigger_rate``, :func:`pols_death`, :func:`mgsv_charge_pp` and :func:`cap_rate`
keep the four apart.
One name is reused deliberately and reads across cleanly. :func:`credit_rate` is the
chassis's declared effective annual rate ``i_cr(t)`` and here the index credit rate
``cr(t) = max(f, min(c, R(t)))``. Different formulas, but the same concept — the rate at
which interest is credited for the contract year — so the name is kept rather than split.
.. rubric:: Timing arguments
Account value, following ``CashValue_SE``'s ``av_pp_at`` and named for the processing step
each precedes:
``"BEF_PREM"``
the balance carried into month ``t`` **before** the single premium: zero at ``t = 0``
on a new-issue model point, and the same as ``"BEF_INV"`` everywhere else. It exists so
that the account-value roll-forward closes on the first month too, the premium being a
beginning-of-month flow rather than a row of its own.
``"BEF_INV"``
the balance opening month ``t`` after the premium: ``av_pp_init()`` at the first
projected month and ``AV(t-1)`` after it. In an anniversary month this is the notes'
``AV(0)`` only when the fixed allocation is zero; in general the notes' ``AV(0)`` of the
contract year is :func:`av_pp_year_open`, eleven months earlier.
``"BEF_FEE"``
``AV(1)``, after the month's interest — the fixed accrual every month, plus the index
credit in an anniversary month — and before the rider charge.
``"BEF_WD"``
``AV(2)``, after the rider charge, before the withdrawal. **Not floored at zero** —
the 0% floor is on the index *credit*, not on the account value [S7].
``"EOY"``
``AV(t)``, after the withdrawal, floored at zero. Equal to :func:`av_pp`.
**Away from an anniversary the last three coincide**, at the opening balance plus the
month's fixed accrual, because no contractual transaction falls there.
Benefit base, named for the same steps: ``"BEF_ROLLUP"`` is the base opening the **contract
year**, ``"BEF_STEP_UP"`` is ``BB(3)`` after rollup and stack, ``"BEF_WD"`` is ``BB(4)``
after the annual step-up, and ``"EOY"`` is ``BB(t)`` after the proportional reduction. All
four steps are annual, so away from an anniversary every timing returns the opening base.
Lifetime withdrawal: ``"BEF_WD"`` is the amount payable at the contract year's anniversary,
``"EOY"`` the closing value after the same reduction; away from an anniversary both carry
the amount set at the last one, which is the income in force.
Policy counts, following the chassis: ``"BEF_DECR"`` is the count entering the month and
therefore equals :func:`pols_if` itself, ``"BEF_MORT"`` is the same (this product has no
annuitization decrement), ``"BEF_LAPSE"`` is after that month's deaths, and ``"AFT_DECR"``
is the closing count, which is also ``pols_if(t + 1)``. Both decrements are the **monthly**
rates.
Benefit ``kind`` arguments are ``"DEATH"``, ``"LAPSE"`` and ``"MATURITY"``. Any other
value of any of these raises ``ValueError``.
.. rubric:: The three phases, and why the cause of depletion matters
``ACCUM`` is deferral. ``INCOME`` starts at the first lifetime withdrawal and lasts while
an account value remains. ``DEPLETED`` is what the product is sold for: the account value
is gone, the insurer pays ``LW`` from its own funds for the rest of the covered life, no
rider charge is deducted, no index credit is computed, the surrender value and the death
benefit are zero, and **lapse is impossible**, so :func:`lapse_rate` returns zero and
``l(t+1) = l(t)(1 - q_m(t))``. ``TERMINATED`` is the same exhaustion with the guarantee
destroyed — reached when the account value is driven to zero by an excess withdrawal, a
surrender charge or a negative MVA rather than by guaranteed withdrawals and rider charges
[S1][S5][S9]. :func:`depletion_cause` carries the attribution and is evaluated *before*
the depletion test, exactly as the notes require. A model testing only ``AV <= 0`` would
either give the guarantee away after an excess withdrawal or destroy it after a legitimate
one. When the contract terminates, the survivors leave as a deemed full surrender —
:func:`lapse_rate` returns 1.0 for that contract year, and :func:`lapse_rate_mth` puts the
whole of it in the anniversary month rather than spreading it, because the contract "as
well as the rider will be considered Surrendered" [S5] on that date — so the in-force
roll-forward still closes.
The transition itself is annual and is tested only at the anniversary, which costs nothing:
the account value can reach zero only where a withdrawal or a charge is taken, and both
fall there. Between anniversaries :func:`phase` simply carries :func:`phase_open`.
**The asymmetry reaches the cash, not only the phase label.** On the exhaustion
anniversary itself the withdrawal requested exceeds what the account value can meet. In
``DEPLETED`` the insurer funds the whole shortfall, because that is the guarantee. On the
``TERMINATED`` branch it funds none of it: the balance is gone and the rider that would
have covered the rest is destroyed by the very withdrawal being paid, and [S5] treats the
contract as surrendered at that point. :func:`wd_unfunded_pp` is that unpayable part, and
:func:`wd_payment_pp`, :func:`wd_guar` and :func:`wd_excess` are all net of it. The cap is
on the *payment* only — :func:`wd_pp` still carries the amount requested, so the excess
still sets :func:`depletion_cause`, still drives ``rho`` to 1 and still takes the benefit
base to zero.
.. rubric:: pols_maturity and the projection horizon
The technical notes state no projection horizon; the ``DEPLETED`` liability is a life
annuity, so the model runs through the final month of the contract year *entered* at
attained age ``maturity_age`` = 120 **[std]**, the terminal age of the shipped mortality
table — ``proj_len() = 12 * policy_term()`` months. The survivors of the last month leave
through :func:`pols_maturity`, zero at every other ``t``, so that
pols_if(t) - pols_if(t+1) = pols_death(t) + pols_lapse(t) + pols_maturity(t)
holds for every ``t``, including the last, ``t = proj_len() - 1``, where
``pols_if(proj_len())`` is zero. Under the shipped table ``q`` reaches 1.000000 at age 120,
hence a monthly rate of 1.0 in the first month of that contract year, so the projection
closes itself and the term is numerically zero; it is kept because a substituted table with
no terminal age would make it bite, and because without it the last month would appear to
lose lives with no cause. The name follows ``BasicTerm_S.pols_maturity`` and the
construction follows :mod:`.Term_US_S`.
"""
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 contract identifier of the selected model point."""
return model_point()["policy_id"]
[docs]
def age_at_entry():
"""x: the issue age (ANB) of the selected model point."""
return int(model_point()["age_at_entry"])
[docs]
def sex():
"""The sex of the selected model point, M or F."""
return model_point()["sex"]
[docs]
def tax_status():
"""Tax status of the contract, NQ or Q.
Reported only. The base run is non-qualified **[std]**; no RMD module is
implemented, though the notes make the RMD age a behavioural input through
:func:`activation_rate` and the ``rmd_age`` Reference.
"""
return model_point()["tax_status"]
[docs]
def premium_pp():
"""P: the single premium per contract."""
return float(model_point()["premium"])
[docs]
def pols_if_init():
"""l(0): in-force probability at entry, 1 for a single-contract model point."""
return float(model_point()["pols_if_init"])
[docs]
def entry_year():
"""The completed contract years behind the model point when it enters the projection.
An elapsed count of whole years, which :func:`entry_mth` turns into the index of the
model point's **first projected month**, ``12 * entry_year()``. ``0`` for a new issue,
whose month ``0`` carries the premium, the acquisition expense and the notes'
Initialisation block as its opening state and beginning-of-month flows. A positive
value enters the contract **in force** on balances stated in the model point table,
which is what the worked example does: its anniversary-7 balances are described as
illustrative and broadly consistent with a seven-year deferral **[std]**, not derived
from one, so deriving them would mean retuning assumptions to force a match. Every
recursion opens here.
"""
return int(model_point()["entry_year"])
[docs]
def entry_mth():
"""The model point's first projected **month**, ``12 * entry_year()``.
The frame is ``t = entry_mth() … proj_len() - 1``. ``0`` for a new issue, whose month
``0`` carries the premium, the acquisition expense and the notes' Initialisation block
as its opening state and beginning-of-month flows. Elapsed time is stated in completed
contract years, so the frame always opens on an anniversary and every recursion opens
here.
"""
return 12 * entry_year()
[docs]
def bonus_rate():
"""b: the premium bonus rate, 7% of the single premium [S5]."""
return float(model_point()["bonus_rate"])
[docs]
def alloc_indexed():
"""The share of the account value allocated to the indexed account; 100% **[std]**."""
return float(model_point()["alloc_indexed"])
[docs]
def alloc_fixed():
"""The share allocated to the fixed account, ``1 - alloc_indexed()``."""
return 1.0 - alloc_indexed()
[docs]
def glwb_elected():
"""Whether the GLWB rider is elected at issue [S5][S9][S11]."""
return bool(model_point()["glwb_elected"])
[docs]
def glwb_basis():
"""``single`` or ``joint``.
The joint column of the payout table is the single column less 0.50%, read on the
younger covered person [S1][S3]. Joint-life *survivorship* is **not implemented**:
the notes specify the joint payout percentage but no second-life mortality basis.
"""
return model_point()["glwb_basis"]
[docs]
def joint_age():
"""The issue age (ANB) of the joint covered person; the annuitant's own if blank."""
given = model_point()["joint_age"]
return age_at_entry() if pd.isna(given) else int(given) # noqa: F821
[docs]
def income_start_age():
"""The attained age at which the base run takes its first lifetime withdrawal.
The deterministic run activates here rather than on the ``h(a)`` incidence table,
which the notes say clusters at the RMD age but which cannot be applied to a single
deterministic cell; see :func:`activation_rate`.
"""
return int(model_point()["income_start_age"])
[docs]
def utilization_intensity():
"""The fraction of ``LW`` actually withdrawn once income starts.
1.00 in the base run **[std]**; the majority of users withdraw 95%-105% of the
maximum [R1]. **The 1.05 case is an excess withdrawal** and routes through the
proportional reduction of step 5, which is why efficiency and excess-withdrawal
assumptions cannot be set independently.
"""
return float(model_point()["utilization_intensity"])
[docs]
def credit_method():
"""The crediting method: ``cap``, ``par``, ``par_cap``, ``spread`` or ``trigger``.
All five are implemented in :func:`credit_rate_on`. The notes' sixth variant,
monthly sum with a monthly cap, is **not implemented**: it needs a monthly grid,
which the notes exclude, and its floor convention is itself flagged as ambiguous
[S4][R1].
"""
return model_point()["credit_method"]
[docs]
def cap_rate():
"""c: the declared annual cap, 5.25% [S2].
A non-guaranteed element captured as of 07/01/2022, revisable under ASOP No. 2
[R6][REG-R26]. Holding it constant for forty years is a strong implicit assumption;
re-declaration against the option budget is **not implemented** because the notes
give the target but no option-pricing function.
"""
return float(model_point()["cap_rate"])
[docs]
def stack_factor():
"""m: the stacking factor on realised dollar credits, 150% [S8][S9].
Zero gives the pure-rollup design (a); with ``rollup_rate`` zero it gives the pure
stacking design (b), where one carrier's 250% election credits 250% to the benefit
base [S3][S4].
"""
return float(model_point()["stack_factor"])
[docs]
def av_int_factor():
"""kappa: the share of the index credit that reaches the account value [S3][S4].
1.00 on the blended baseline. 0.50 is the 250% election documented at one carrier,
which credits 250% of index interest to the benefit base but only 50% to the account
value, deliberately starving the account value. The stacking factor ``m`` applies to
the **gross** index credit, not to ``kappa x IC``.
"""
return float(model_point()["av_int_factor"])
[docs]
def rollup_id():
"""The guaranteed rollup schedule, a key into *rollup_table.csv*."""
return model_point()["rollup_id"]
[docs]
def mva_ref_yield_at_issue():
"""i0: the MVA reference index level locked at issue, 3.00% **[std]**."""
return float(model_point()["mva_ref_yield_at_issue"])
[docs]
def scenario_id():
"""The index and reference-yield scenario, a key into *rate_scenario.csv*."""
return model_point()["scenario_id"]
[docs]
def wd_schedule_id():
"""The ad hoc withdrawal programme, a key into *withdrawal_table.csv*."""
return model_point()["wd_schedule_id"]
[docs]
def av_pp_init():
"""AV at entry: the stated in-force balance, else ``P x (1 + b)`` [S5].
The bonus is credited to the **account value** at issue and earns index credits from
day one [S5]; it does **not** enter the benefit base [S9] or the nonforfeiture base
[S10].
"""
given = model_point()["av_initial"]
if pd.isna(given): # noqa: F821
return premium_pp() * (1.0 + bonus_rate())
return float(given)
[docs]
def av_fixed_pp_init():
"""F at entry: the fixed account share of :func:`av_pp_init`, allocated as premium."""
return alloc_fixed() * av_pp_init()
[docs]
def benefit_base_pp_init():
"""BB at entry: the stated in-force base, else ``P`` -- the bonus stays out [S9]."""
if not glwb_elected():
return 0.0
given = model_point()["bb_initial"]
if pd.isna(given): # noqa: F821
return premium_pp()
return float(given)
[docs]
def rollup_base_pp_init():
"""RB at entry: the stated in-force rollup base, else ``P`` [S2][S9]."""
if not glwb_elected():
return 0.0
given = model_point()["rb_initial"]
if pd.isna(given): # noqa: F821
return premium_pp()
return float(given)
[docs]
def mgsv_pp_init():
"""MGV at entry: the stated in-force floor, else 87.5% of premium [S10][R2].
87.5% of the single premium **excluding the bonus** [S10]; the worked example's
``93,811.84 = 87,500 x 1.01^7`` is that value rolled forward seven years.
"""
given = model_point()["mgv_initial"]
if pd.isna(given): # noqa: F821
return net_consideration_ratio * premium_pp() # noqa: F821
return float(given)
[docs]
def lw_pp_init():
"""LW at entry: zero before exercise, else the stated in-force income amount."""
given = model_point()["lw_initial"]
return 0.0 if pd.isna(given) else float(given) # noqa: F821
[docs]
def payout_rate_init():
"""The payout percentage already locked at entry; zero before exercise."""
given = model_point()["payout_rate_initial"]
return 0.0 if pd.isna(given) else float(given) # noqa: F821
[docs]
def phase_init():
"""The phase at entry: ``ACCUM`` unless the model point enters in force in income."""
value = model_point()["phase_initial"]
if value not in ("ACCUM", "INCOME", "DEPLETED", "TERMINATED"):
raise ValueError("invalid phase_initial")
return value
[docs]
def policy_term():
"""Contract term in years **[std]**: through the year entered at ``maturity_age``.
``maturity_age`` = 120 is the terminal age of the mortality basis, where the annual
rate is 1.000000, so the projection closes itself rather than being truncated: the
last contract year is the one *entered* at that age, hence the ``+ 1``. The notes
state no horizon, and the ``DEPLETED`` liability is a life annuity, so stopping
earlier would silently drop the tail the product is sold for.
"""
return maturity_age - age_at_entry() + 1 # noqa: F821
[docs]
def proj_len():
"""The number of contract **months** projected, ``12 * policy_term()``.
The **exclusive end** of the frame counted from ``t = 0``: ``result_cf()`` runs
``t = entry_mth() … proj_len() - 1``, so its last row is the final month of the
contract year entered at attained age ``maturity_age``.
"""
return 12 * policy_term()
[docs]
def policy_year(t):
"""The contract year month ``t`` falls in: ``duration(t) + 1``, the 1-based label.
Derived from ``t``, never indexed by. It is the key that reads the surrender charge,
the vesting vector and the rollup schedule, all of which the sources state by contract
year, and it is the year whose annual decrement rates :func:`mort_rate` and
:func:`lapse_rate` return.
"""
return duration(t) + 1
[docs]
def duration_mth(t):
"""Completed contract months at the start of month ``t``: ``t`` itself.
The cells exists so the monthly vocabulary reads the same here as in
:mod:`.Term_US_S` and ``savings.CashValue_SE``; an in-force model point opens the
frame at ``entry_mth()`` rather than re-basing ``t``.
"""
return t
[docs]
def duration(t):
"""Completed contract years at the start of month ``t``, ``duration_mth(t) // 12``.
Every contractual schedule in this product is annual, so this is what the attained
age, the surrender charge, the vesting vector, the rollup schedule and the scenario
tables are read at.
"""
return duration_mth(t) // 12
[docs]
def is_anniv(t):
"""True in the **last month of a contract year**, where every annual event lands.
``duration_mth(t) % 12 == 11``. All eight of the notes' processing steps are
contractually annual — the point-to-point index credit [S2][S4][S10], the rider charge
[S9], the benefit base rollup, stack and step-up [S9], the lifetime withdrawal, the
charges and the phase transition — so all eight happen here and nowhere else. What
the finer grid carries between anniversaries is the fixed-account accrual, the
Model #805 roll, the decrements and the maintenance expense.
"""
return duration_mth(t) % 12 == 11
[docs]
def anniv_mth(t):
"""The month that **closes** the contract year of month ``t``: ``12 * duration(t) + 11``.
Equal to ``t`` itself when :func:`is_anniv` is true. It is where the annual
quantities of that contract year are evaluated — which is what makes
:func:`lapse_rate` a property of the year rather than of the month.
"""
return 12 * duration(t) + 11
[docs]
def year_open_mth(t):
"""The month whose **closing** value opens the contract year of month ``t``.
``12 * duration(t) - 1``, the anniversary closing the previous contract year. It
falls below ``entry_mth()`` in the first projected contract year, where the notes'
Initialisation values stand in — which is the test every annual state cells makes.
"""
return 12 * duration(t) - 1
[docs]
def age(t):
"""x + duration(t): the attained age (ANB) **opening** the contract year of month t.
The library convention of :mod:`.Term_US_S` and :mod:`.MYGA_US_S`, so :func:`mort_rate`
reads this age directly for the contract year of mortality it covers. Age changes on
the anniversary, not on the birthday and not monthly. The transactions of that
contract year fall at its *closing* anniversary and are one year older: that age is
:func:`exercise_age`, which is what reads the lifetime withdrawal percentage table.
"""
return age_at_entry() + duration(t)
[docs]
def covered_age(t):
"""The attained age of the covered life opening the contract year of month t.
The annuitant's own age on a single-life basis; the **younger** covered person's on a
joint-life basis [S1][S3]. Timed like :func:`age`, at the opening anniversary; the
payout percentage and the minimum exercise age are read at :func:`exercise_age`.
"""
if glwb_basis() == "joint":
return min(age_at_entry(), joint_age()) + duration(t)
return age(t)
[docs]
def exercise_age(t):
"""``covered_age(t) + 1``: the covered age at the anniversary closing the contract year.
All transactions occur **at** the anniversary and are processed as the last events of
the contract year ending there **[std]**, so a lifetime withdrawal taken in the
contract year of month ``t`` is taken one year older than the age opening it. This is
the age that reads the payout percentage band and that clears the contractual minimum
exercise age of 50: the anchor cell's exercise in contract year 8, from issue age 62,
is at attained age 70 and reads the 70-79 band.
"""
return covered_age(t) + 1
[docs]
def scenario_value(y, name):
"""Step-function lookup of column ``name`` in the model point's scenario at anniversary ``y``.
**The argument is an anniversary number, not the frame's month index.** The ``t``
column of *rate_scenario.csv* is an anniversary number, ``0`` at issue, and the file's
keys are read unshifted; callers pass ``duration(t)`` for the anniversary opening the
contract year of month ``t`` and ``duration(t) + 1`` for the one closing it. Each row
states the level that holds from its own anniversary until the next row of the same
scenario, so a flat path is one row. Anniversaries before the first row take the
first row's level.
"""
sub = data.rate_scenario().loc[scenario_id()] # noqa: F821
keys = [i for i in sub.index if i <= y]
key = max(keys) if keys else min(sub.index)
return float(sub.loc[key, name])
[docs]
def index_level(y):
"""I(y): the index level at anniversary ``y``.
**Keyed by anniversary number, not by the frame's month index** — a time-point
reading, so the return over a contract year differences this level against the next
anniversary's. The S&P 500 **price** index in the composite; dividends are excluded
[S2][S6][R1].
"""
return scenario_value(y, "index_level")
[docs]
def index_return(t):
"""R(t) = I(y+1)/I(y) - 1 with ``y = duration(t)``: the return over the contract year.
The point-to-point segment runs from the anniversary opening the contract year of
month ``t`` to the one closing it, where it matures. The return is a property of the
contract year, not of the month, and is the same for every month inside it.
"""
y = duration(t)
prior = index_level(y)
if prior <= 0.0:
return 0.0
return index_level(y + 1) / prior - 1.0
[docs]
def cap_rate_in_force():
"""c(t): the annual cap actually applied.
The declared 5.25% snapshot held level **[std]**, never below the guaranteed minimum
cap of 0.25% [S4]. Set ``use_guaranteed_scale = True`` to run the contract on its
guaranteed minimums instead — the notes insist the guaranteed and current scales are
different assumption classes and "must not be mixed in the code".
"""
if use_guaranteed_scale: # noqa: F821
return cap_rate_min # noqa: F821
return max(cap_rate_min, cap_rate()) # noqa: F821
[docs]
def fixed_rate_in_force():
"""i_F: the fixed account rate applied, 2.30% declared over a 1.00% guarantee.
Declared [S2] and guaranteed minimum [S10]; ``use_guaranteed_scale`` switches to the
guarantee, as for the cap.
"""
if use_guaranteed_scale: # noqa: F821
return fixed_rate_min # noqa: F821
return max(fixed_rate_min, fixed_rate) # noqa: F821
[docs]
def credit_rate_on(r, method):
"""cr: the credit rate for an index return ``r`` under ``method``.
The engine the notes require, all branches floored at ``f`` = 0%:
``cap`` **[std]**
``max(f, min(c, R))`` [S2][S4][S10][R1] — the composite's baseline.
``par``
``max(f, p x R)`` [S4][S10][R1].
``par_cap``
``max(f, min(c, p x R))``, worked at [R1] as ``min(80% x 10%, 6%) = 6%`` — which
is where ``par_rate`` = 80% comes from, and the case
``test_crediting_engine_reproduces_the_R1_worked_case`` asserts.
``spread``
``max(f, p x R - s)``, the index-margin form [S8][R1].
``trigger``
``d x 1{R >= 0}``, a declared rate credited whenever the return is non-negative
[R1]; the floor applies when it is not.
**The notes declare the two levels these last branches need nowhere, so both are
[std].** They print the *forms* — ``s`` an index margin, ``d`` a performance-trigger
rate — and neither ``technical-notes.md`` nor ``product-spec.md`` states a value for
either, unlike the cap (5.25% [S2]) and the participation rate (80% [R1]). The
shipped ``spread_rate`` = 2.00% **[std]** and ``trigger_rate`` = 4.50% **[std]** are
illustrative levels chosen only to exercise the branch, and must not be read as
sourced parameters of the composite.
``index_cost_rate`` is deducted from ``R`` **before** any cap or participation rate,
which is where a volatility-controlled index's embedded servicing cost belongs
[S2][S10]; it is 0 in the base run **[std]**. The monthly-sum method is not
implemented — see :func:`credit_method`.
"""
net = r - index_cost_rate # noqa: F821
if method == "cap":
raw = min(cap_rate_in_force(), net)
elif method == "par":
raw = par_rate * net # noqa: F821
elif method == "par_cap":
raw = min(cap_rate_in_force(), par_rate * net) # noqa: F821
elif method == "spread":
raw = par_rate * net - spread_rate # noqa: F821
elif method == "trigger":
raw = trigger_rate if net >= 0.0 else floor_rate # noqa: F821
else:
raise ValueError("invalid credit_method")
return max(floor_rate, raw) # noqa: F821
[docs]
def credit_rate(t):
"""cr(t): the credit rate the segment of month t's contract year matures at.
A property of the contract year, like :func:`index_return`; it is applied once, at
the anniversary where the segment matures.
"""
return credit_rate_on(index_return(t), credit_method())
[docs]
def index_credit_pp(t):
"""IC(t) = A(0) x cr(t): the index credit on the segment maturing at month t.
One annual segment per indexed account, created at the anniversary opening the
contract year with the balance opening it and maturing at the anniversary closing it;
the credit locks at maturity and cannot be lost to later declines [S1], and **it is
not earned before then** — which is why this is zero in every month but the
anniversary, and why the indexed balance is flat between anniversaries. That is the
sourced rule "withdrawals are not credited with index interest in the year they are
taken" [S1], carried to mid-year deaths and surrenders **[std]**; crediting a part
year would be the *daily interim value* variant the notes exclude.
The credit base is the segment's opening balance less withdrawals from that account
during the segment — the "Interest Credit Basis" [S6] — which collapses to the opening
balance here because every transaction happens at an anniversary **[std]**. Zero in
``DEPLETED`` and ``TERMINATED``, where steps 1-3 are skipped.
"""
if t < entry_mth() or not is_anniv(t):
return 0.0
if phase_open(t) in ("DEPLETED", "TERMINATED"):
return 0.0
return av_indexed_pp_year_open(t) * credit_rate(t)
[docs]
def fixed_interest_pp(t):
"""FI(t): interest credited to the fixed account in **month** t.
The declared rate is an annual effective one, so the month accrues at
``(1 + i_F)^(1/12) - 1`` and twelve of them compound back to exactly ``i_F`` — nothing
else touches the fixed account between anniversaries, because every contractual
transaction falls at one. :func:`fixed_interest_ann_pp` is the annual figure the
benefit-base stack is computed on.
"""
if t < entry_mth():
return 0.0
if phase_open(t) in ("DEPLETED", "TERMINATED"):
return 0.0
rate = (1.0 + fixed_rate_in_force()) ** (1.0 / 12.0) - 1.0
return av_fixed_pp_at(t, "BEF_INV") * rate
[docs]
def fixed_interest_ann_pp(t):
"""FI over the contract year closing at the anniversary in month ``t``: ``F(0) x i_F``.
On the balance **opening** that contract year. The twelve monthly accruals of
:func:`fixed_interest_pp` compound to exactly this, so the annual figure is computed
directly rather than accumulated — which is what keeps the benefit-base stack, and
through it the whole guarantee, identical to the annual grid's.
"""
if t < entry_mth() or not is_anniv(t):
return 0.0
if phase_open(t) in ("DEPLETED", "TERMINATED"):
return 0.0
return av_fixed_pp_year_open(t) * fixed_rate_in_force()
[docs]
def inv_income_pp(t):
"""Interest credited to one contract's account value in month t.
``kappa x IC(t) + FI(t)``: the fixed accrual every month, and the index credit only in
the anniversary month where the segment matures. ``kappa`` starves the account value
in the pure-stacking design [S3]; the benefit-base stack in :func:`stack_pp` uses the
**gross** index credit, which is the whole point of that design.
"""
return av_int_factor() * index_credit_pp(t) + fixed_interest_pp(t)
[docs]
def rider_charge_pp(t):
"""Phi(t) = phi x BB(0): the GLWB rider charge, deducted once a year [S9].
On the **benefit base**, not the account value, and taken **after** index credits are
added — the notes' ordering, which is why the base in the formula is the *opening*
base. In the worked example the base closes at 1.59 times the account value, so
charging on the account value would understate the deduction by a growing margin.
Deducted from the fixed account first and then proportionately across indexed
accounts [S9]. ``phi`` is fixed for fifteen contract years and never exceeds 1.50%
[S9]; re-declaration after that is not implemented. **Once the account value is
exhausted the charge stops** — there is nothing to deduct it from and income
continues [S9].
The charge is annual by contract [S9], so on the monthly grid it lands whole on the
anniversary rather than being spread over the year; the monthly deduction documented
at one carrier is a variant this composite does not adopt **[std]**.
"""
if t < entry_mth() or not is_anniv(t):
return 0.0
if not rider_in_force_open(t):
return 0.0
if phase_open(t) in ("DEPLETED", "TERMINATED"):
return 0.0
return (min(rider_charge_rate, rider_charge_rate_max) # noqa: F821
* benefit_base_pp_at(t, "BEF_ROLLUP"))
[docs]
def av_pp_at(t, timing):
"""AV per contract in **month** t, read at the point given by ``timing``.
``"BEF_PREM"`` is the balance carried into the month before the single premium, zero
at ``t = 0`` on a new issue; ``"BEF_INV"`` is the balance opening the month after it,
which is ``av_pp_init()`` at the first projected month and ``AV(t-1)`` after that;
``"BEF_FEE"`` is after the month's interest — the fixed accrual every month, plus the
index credit in an anniversary month; ``"BEF_WD"`` is after the rider charge; ``"EOY"``
is after the withdrawal. **In a month that is not an anniversary the last three
coincide**, at the opening balance plus the fixed accrual, because every contractual
transaction falls at an anniversary.
Only ``"EOY"`` is floored at zero: **the 0% floor is on the index credit, not on the
account value** [S7], so ``"BEF_WD"`` may legitimately sit below the opening balance or
below zero when the rider charge exceeds the credit. Flooring it would silently remove
the charge drag that produces depletion.
"""
if t < entry_mth():
return av_pp_init()
if timing == "BEF_PREM":
return 0.0 if t == entry_mth() == 0 else av_pp_at(t, "BEF_INV")
opening = av_pp_init() if t == entry_mth() else av_pp(t - 1)
if timing == "BEF_INV":
return opening
val = opening + inv_income_pp(t)
if timing == "BEF_FEE":
return val
val = val - rider_charge_pp(t)
if timing == "BEF_WD":
return val
if timing == "EOY":
return max(0.0, val - wd_pp(t))
raise ValueError("invalid timing")
[docs]
def av_pp(t):
"""AV(t): the account value per contract closing **month** t, after all processing."""
return av_pp_at(t, "EOY")
[docs]
def av_pp_year_open(t):
"""AV(0): the account value **opening** the contract year containing month ``t``.
The balance the annual index credit and the free withdrawal allowance are computed on:
the closing value of the anniversary before, or the Initialisation value in the first
projected contract year. On an annual grid this would simply be the previous row's
close; on the monthly grid the previous *month* is not the previous *year*, which is
what this cells exists to keep straight.
"""
prev = year_open_mth(t)
return av_pp_init() if prev < entry_mth() else av_pp(prev)
[docs]
def av_fixed_pp_year_open(t):
"""F(0): the fixed account balance opening the contract year containing month ``t``."""
prev = year_open_mth(t)
return av_fixed_pp_init() if prev < entry_mth() else av_fixed_pp(prev)
[docs]
def av_indexed_pp_year_open(t):
"""A(0): the indexed balance opening the contract year — the segment's credit base."""
return av_pp_year_open(t) - av_fixed_pp_year_open(t)
[docs]
def av_fixed_pp_at(t, timing):
"""F per contract in month t; see :func:`av_pp_at` for ``timing``.
The fixed account is the one balance that moves between anniversaries: it accrues at
``(1 + i_F)^(1/12) - 1`` every month. The rider charge comes out of it **first** and
only then proportionately across the indexed accounts [S9]. The notes give no
allocation rule for withdrawals, so they are taken **pro rata** across the two accounts
**[std]**; with the baseline 100% indexed allocation neither rule is visible.
"""
if t < entry_mth():
return av_fixed_pp_init()
if timing == "BEF_PREM":
return 0.0 if t == entry_mth() == 0 else av_fixed_pp_at(t, "BEF_INV")
opening = av_fixed_pp_init() if t == entry_mth() else av_fixed_pp(t - 1)
if timing == "BEF_INV":
return opening
val = opening + fixed_interest_pp(t)
if timing == "BEF_FEE":
return val
val = max(0.0, val - rider_charge_pp(t))
if timing == "BEF_WD":
return val
if timing == "EOY":
total = av_pp_at(t, "BEF_WD")
if total <= 0.0:
return 0.0
share = min(1.0, max(0.0, wd_pp(t) / total))
return max(0.0, val * (1.0 - share))
raise ValueError("invalid timing")
[docs]
def av_fixed_pp(t):
"""F(t): the fixed account balance per contract closing month t."""
return av_fixed_pp_at(t, "EOY")
[docs]
def av_indexed_pp_at(t, timing):
"""A per contract in month t: ``AV - F`` at the same timing, by construction."""
return av_pp_at(t, timing) - av_fixed_pp_at(t, timing)
[docs]
def av_indexed_pp(t):
"""A(t): the indexed account balance per contract closing month t.
Flat between anniversaries — the segment credits only at maturity — and stepped at
each anniversary by that year's index credit net of its share of the charge and the
withdrawal.
"""
return av_indexed_pp_at(t, "EOY")
[docs]
def av_depletion_pp(t):
"""The part of month t's withdrawal the account value could not fund.
Zero until the account value runs out, then the shortfall between the withdrawal
requested and the balance available to meet it. It is the term that makes the
account value roll-forward close across the depletion anniversary, where ``AV(t)`` is
floored at zero.
**It is not the same thing as an amount paid.** Whether the shortfall is a payment
depends on which exhaustion branch the anniversary closes on: in ``DEPLETED`` the
guarantee is alive and the insurer funds it from its own funds [S1][S3][S9][R1], on
the ``TERMINATED`` branch there is neither balance nor promise behind it and it is
not paid at all. :func:`wd_unfunded_pp` carries that half, and only that half is
kept out of the ledger.
"""
if t < entry_mth():
return 0.0
return max(0.0, wd_pp(t) - av_pp_at(t, "BEF_WD"))
[docs]
def surr_charge_rate(t):
"""sc(t): the surrender charge percentage of month t's contract year [S5].
9.1, 9, 8, 7, 6, 5, 4, 3, 2, 1% over the ten-year charge period and zero thereafter;
the last row of the table holds for every later year. The table is keyed by the
contractual 1-based contract year, ``policy_year(t)``, so the rate is the same in every
month of a year.
"""
table = data.surr_charge_table() # noqa: F821
key = min(policy_year(t), int(table.index.max()))
return float(table.loc[key, "surr_charge_rate"])
[docs]
def vest_rate(t):
"""v(t): the vested percentage of the premium bonus in month t's contract year [S5].
0, 10, ..., 100% over eleven contract years, read at ``policy_year(t)``. On
**death** 100% vests immediately [S5], which is why the death benefit carries no
clawback.
"""
table = data.surr_charge_table() # noqa: F821
key = min(policy_year(t), int(table.index.max()))
return float(table.loc[key, "vest_rate"])
[docs]
def rollup_rate(t):
"""g(t): the guaranteed simple rollup rate of month t's contract year [S2][S9].
Read as a step function of the contract year ``policy_year(t)`` from the model
point's schedule: the blended baseline is 5.00% in years 1-10, 2.00% in years 11-20 and
zero after [S2]; another documented design is a flat 3.00% over fifteen anniversaries
[S9].
"""
sub = data.rollup_table().loc[rollup_id()] # noqa: F821
keys = [y for y in sub.index if y <= policy_year(t)]
if not keys:
return 0.0
return float(sub.loc[max(keys), "rollup_rate"])
[docs]
def payout_rate(a, basis):
"""pi(a, basis): the lifetime withdrawal percentage at attained age ``a`` [S3].
Five inclusive attained-age bands; the joint column is the single column less 0.50%
[S1][S3]. Zero below the first band, because no lifetime withdrawal is permitted
there; the last band holds above its upper age.
"""
if basis == "single":
column = "payout_rate_single"
elif basis == "joint":
column = "payout_rate_joint"
else:
raise ValueError("invalid glwb_basis")
table = data.payout_rate_table() # noqa: F821
for _, row in table.iterrows():
if int(row["age_lo"]) <= a <= int(row["age_hi"]):
return float(row[column])
last = table.iloc[-1]
return float(last[column]) if a > int(last["age_hi"]) else 0.0
[docs]
def payout_rate_locked(t):
"""The payout percentage in force in the contract year of month t.
Locked at the attained age of the **first** lifetime withdrawal, :func:`exercise_age`,
and not re-read afterwards **[std]**. :func:`is_exercise` is a property of the
contract year, so the lock holds for every month of the year it is set in and is then
carried from anniversary to anniversary. Documented alternatives: one carrier reads
the band from the age at the most recent anniversary, letting it step up [S3]; another
makes it depend on both issue age and the youngest covered person's age at exercise
[S9]; a third uses sex-distinct factors [S5].
"""
if t < entry_mth():
return payout_rate_init()
if is_exercise(t):
return payout_rate(exercise_age(t), glwb_basis())
prev = year_open_mth(t)
if prev < entry_mth():
return payout_rate_init()
return payout_rate_locked(prev)
[docs]
def activation_rate(a):
"""h(a): the GLWB activation incidence at attained age ``a`` **[std]**.
``0`` below 60, 5% from 60 to the RMD age, 40% at it and 15% above [R1][REG-R64].
``rmd_age`` is a configurable Reference and **must not be hard-coded**: the statutory
age is set by IRC 401(a)(9) as amended by SECURE 2.0 and finalized in T.D. 10001
[REG-R57][REG-R58] and is not printed in the retrieved research material.
**Reported only.** A single deterministic cell cannot activate a fraction of itself,
so the base run exercises at the model point's ``income_start_age`` instead; this
cells exists so the incidence assumption is visible and testable rather than implied.
"""
if a < activation_age_low: # noqa: F821
return 0.0
if a < rmd_age: # noqa: F821
return activation_rate_early # noqa: F821
if a == rmd_age: # noqa: F821
return activation_rate_rmd # noqa: F821
return activation_rate_late # noqa: F821
[docs]
def is_exercise(t):
"""True through the contract year whose closing anniversary carries the **first** LW.
Requires an in-force rider, the ``ACCUM`` phase opening the year and an
:func:`exercise_age` at or above both the contractual minimum of 50 [S2][S3][S9] and
the model point's ``income_start_age``. It depends only on the state **opening** the
contract year, which is what lets the phase, the benefit base and the withdrawal be
evaluated without a circular reference — and what makes it constant across the year,
so every month of the exercise year reads the same phase and the same locked payout
percentage.
"""
if t < entry_mth():
return False
if not glwb_elected():
return False
prev = year_open_mth(t)
prior = phase_init() if prev < entry_mth() else phase(prev)
if prior != "ACCUM":
return False
if not rider_in_force_open(t):
return False
return exercise_age(t) >= max(min_income_age, income_start_age()) # noqa: F821
[docs]
def phase_open(t):
"""The phase the annual steps of month t's contract year are processed under.
The phase **opening the contract year** — ``phase_init()`` in the first projected year
and the closing phase of the previous anniversary after that — promoted to ``INCOME``
when the first lifetime withdrawal falls in this year. Constant across the twelve
months, because every one of the eight steps belongs to the year rather than to the
month. Steps 1-3 are skipped when this is ``DEPLETED`` and steps 1-7 when it is
``TERMINATED``.
"""
if t < entry_mth():
return phase_init()
prev = year_open_mth(t)
opening = phase_init() if prev < entry_mth() else phase(prev)
if opening == "ACCUM" and is_exercise(t):
return "INCOME"
return opening
[docs]
def depletion_cause(t):
"""True when a charge, an excess withdrawal or a negative MVA touched the AV in month t.
The attribution test of step 5, evaluated **before** the depletion test: an account
value run to zero by an excess withdrawal, a surrender charge or a market value
adjustment loses the guarantee entirely, while one run to zero by guaranteed
withdrawals and rider charges keeps it [S1][S5][S9]. One carrier's confinement and
terminal illness waivers are themselves excess withdrawals that terminate the income
rider [S1] — a trap if waivers are added.
"""
if t < entry_mth():
return False
return (wd_excess_pp(t) > 0.0 or wd_charge_pp(t) > 0.0
or wd_mva_pp(t) < 0.0)
[docs]
def phase(t):
"""The phase closing month t, after step 7's transition at the anniversary.
Step 7 is annual and the account value can only reach zero at an anniversary — nothing
between them takes a withdrawal or a charge — so away from one this simply carries
:func:`phase_open`, and the transition is tested at the anniversary itself.
``ACCUM -> INCOME`` at the first lifetime withdrawal;
``INCOME -> DEPLETED`` when the account value reaches zero with no
:func:`depletion_cause`, which is where the economic value of the guarantee sits;
``INCOME -> TERMINATED`` when it reaches zero *with* one; and
``any -> TERMINATED`` when the benefit base reaches zero [S9]. An account value
exhausted in ``ACCUM`` terminates rather than depletes **[std]**: the notes define
the depleted state only out of ``INCOME``, and before exercise there is no ``LW`` to
pay.
"""
if t < entry_mth():
return phase_init()
opening = phase_open(t)
if opening == "TERMINATED":
return "TERMINATED"
if opening == "DEPLETED":
return "DEPLETED"
if not is_anniv(t):
return opening
if glwb_elected() and benefit_base_pp(t) <= 0.0:
return "TERMINATED"
if av_pp(t) <= 0.0:
if opening == "INCOME" and not depletion_cause(t):
return "DEPLETED"
return "TERMINATED"
return opening
[docs]
def rider_in_force_open(t):
"""Whether the GLWB rider is alive **opening** the contract year of month t.
``glwb_elected()`` in the first projected contract year — an in-force model point
enters on the rider state its row states — and :func:`rider_in_force` at the previous
anniversary after that. Kept as a cells of its own, like :func:`phase_open`, because
four formulas need the opening rider state and none of them may reach for a closing
one.
"""
if t < entry_mth():
return bool(glwb_elected())
prev = year_open_mth(t)
if prev < entry_mth():
return bool(glwb_elected())
return rider_in_force(prev)
[docs]
def rider_in_force(t):
"""Whether the GLWB rider is still alive at the end of month t [S9].
It terminates on the earliest of death of the covered person, the benefit base
reduced to zero, termination of the base contract, assignment, owner cancellation on
or after the earliest cancellation date, or a change in a covered person — with no
refund of past charges [S9]. Death is a decrement here, and cancellation and
assignment are not modelled, so what survives is the base-reaching-zero and
contract-termination legs.
"""
if t < entry_mth():
return bool(glwb_elected())
if not glwb_elected():
return False
if not rider_in_force_open(t):
return False
if phase(t) == "TERMINATED":
return False
return benefit_base_pp(t) > 0.0
[docs]
def in_growth_period(t):
"""T_g: whether the benefit base still rolls up and stacks in month t's contract year.
To the earlier of the first lifetime withdrawal and contract year 20 [S1][S2]. The
year of exercise itself is still inside the window — the worked example credits both
the rollup and the stack in contract year 8, the year income starts — which is why the
test is on the phase *opening* the year.
"""
if t < entry_mth():
return False
if not glwb_elected():
return False
if policy_year(t) > growth_period_max: # noqa: F821
return False
prev = year_open_mth(t)
return (phase_init() if prev < entry_mth() else phase(prev)) == "ACCUM"
[docs]
def step_up_applies(t):
"""Whether the annual step-up ``BB <- max(BB, AV)`` is tested at month t.
Annual by construction, so it is tested only at the anniversary.
**A [std] generalisation.** No retrieved document describes an automatic annual
ratchet during deferral; documented instead are an at-exercise step-up to the
contract value [S5], an annual benefit amount computed on the greater of base and
account value at exercise [S9], and a never-decreasing income amount once withdrawals
begin [S3]. ``step_up_mode = "at_exercise"`` reduces the model to those designs;
``"annual"`` is the superset and the baseline. Under the blended baseline it rarely
binds — a dollar of credit adds $1 to the account value and $1.50 to the base — but
*rarely is not never*: on a new issue the bonus opens contract year 1 with an account
value of ``107,000`` against a benefit base of ``100,000``, so a first contract year
with a zero index credit gives an account value of ``106,050`` against a base of
``105,000`` and the step-up binds.
"""
if not is_anniv(t):
return False
if not glwb_elected():
return False
if phase_open(t) in ("DEPLETED", "TERMINATED"):
return False
if step_up_mode == "annual": # noqa: F821
return True
elif step_up_mode == "at_exercise": # noqa: F821
return is_exercise(t)
else:
raise ValueError("invalid step_up_mode")
[docs]
def rollup_pp(t):
"""rollup(t) = g(t) x RB(0): the guaranteed rollup increment, once a year.
**A flat dollar increment, not simple interest on the grown base.** One carrier
computes it on premium less withdrawals [S2] and another on the adjusted *initial*
base [S9]; the latter's fifteen-year table confirms a constant $3,000 a year on a
$100,000 adjusted initial base [S9]. Compounding it inflates the base and every
downstream charge and payment.
"""
if not is_anniv(t) or not in_growth_period(t):
return 0.0
prev = year_open_mth(t)
opening = (rollup_base_pp_init() if prev < entry_mth()
else rollup_base_pp(prev))
return rollup_rate(t) * opening
[docs]
def stack_pp(t):
"""stack(t) = m x max(0, IC(t) + FI(t)): the stacking credit at the anniversary.
On **realised dollar credits**, net of any strategy fee (zero here) and floored at
zero [S8][S9] — one specimen's separately named stacking credit. The gross index
credit is used, so the pure stacking design can credit 250% to the base while
``kappa`` sends only 50% to the account value [S3][S4]. ``FI`` here is the **annual**
fixed interest of the contract year, :func:`fixed_interest_ann_pp`, not the
anniversary month's own accrual.
"""
if not is_anniv(t) or not in_growth_period(t):
return 0.0
return stack_factor() * max(0.0, index_credit_pp(t) + fixed_interest_ann_pp(t))
[docs]
def benefit_base_pp_at(t, timing):
"""BB per contract in month t, read at the point given by ``timing``.
``"BEF_ROLLUP"`` is the base **opening the contract year** — ``benefit_base_pp_init()``
in the first projected year and the base at the previous anniversary after it;
``"BEF_STEP_UP"`` is after the rollup and the stack; ``"BEF_WD"`` is after the annual
step-up and is the base the lifetime withdrawal is computed on; ``"EOY"`` is after
step 5's proportional reduction. All four steps are annual, so away from an
anniversary every timing returns the opening base and the benefit base is flat.
The base is **notional**: it has no cash value, cannot be withdrawn and cannot be taken
as a lump sum [S1][S9].
"""
if t < entry_mth():
return benefit_base_pp_init()
if not glwb_elected() or not rider_in_force_open(t):
return 0.0
prev = year_open_mth(t)
opening = (benefit_base_pp_init() if prev < entry_mth()
else benefit_base_pp(prev))
if timing == "BEF_ROLLUP":
return opening
val = opening + rollup_pp(t) + stack_pp(t)
if timing == "BEF_STEP_UP":
return val
if step_up_applies(t):
val = max(val, av_pp_at(t, "BEF_WD"))
if timing == "BEF_WD":
return val
if timing == "EOY":
return val * (1.0 - wd_reduction_rate(t))
raise ValueError("invalid timing")
[docs]
def benefit_base_pp(t):
"""BB(t): the GLWB benefit base per contract closing month t."""
return benefit_base_pp_at(t, "EOY")
[docs]
def rollup_base_pp(t):
"""RB(t): the rollup base closing month t.
Reduced in the same proportion as the benefit base under the baseline **[std]**
convention [S9], and only at an anniversary, where the reduction happens. Set
``rb_wd_convention = "dollar"`` for the alternative documented at another carrier,
``RB(t) = max(0, RB(0) - G(t))`` — "Premium minus Withdrawals" [S2].
"""
if t < entry_mth():
return rollup_base_pp_init()
prev = year_open_mth(t)
opening = (rollup_base_pp_init() if prev < entry_mth()
else rollup_base_pp(prev))
if rb_wd_convention == "pro_rata": # noqa: F821
return opening * (1.0 - wd_reduction_rate(t))
elif rb_wd_convention == "dollar": # noqa: F821
return max(0.0, opening - wd_pp(t))
else:
raise ValueError("invalid rb_wd_convention")
[docs]
def lw_pp_at(t, timing):
"""LW per contract in month t, read at the point given by ``timing``.
``"BEF_WD"`` is the amount payable at this contract year's anniversary, after step 4's
ratchet; ``"EOY"`` is the closing value, after step 5's proportional reduction. The
ratchet is annual, so away from an anniversary this carries the amount set at the last
one — the income in force, which is what a mid-year reading of the rider state means.
At exercise ``LW = pi x BB(4)``; afterwards the ratchet ``LW = max(LW(0), pi x BB(4))``
applies so income never decreases [S3]. Unused ``LW`` does **not** carry forward [S9];
one carrier is the exception, accumulating the shortfall without interest [S3]. In
``DEPLETED`` the amount is simply carried: there is no base to recompute it from.
"""
if t < entry_mth():
return lw_pp_init()
if timing not in ("BEF_WD", "EOY"):
raise ValueError("invalid timing")
prev = year_open_mth(t)
prior = lw_pp_init() if prev < entry_mth() else lw_pp(prev)
opening = phase_open(t)
if not is_anniv(t):
return 0.0 if opening == "TERMINATED" else prior
if opening == "TERMINATED":
val = 0.0
elif opening == "DEPLETED":
val = prior
elif opening == "INCOME":
candidate = payout_rate_locked(t) * benefit_base_pp_at(t, "BEF_WD")
val = candidate if is_exercise(t) else max(prior, candidate)
else:
val = 0.0
if timing == "BEF_WD":
return val
return val * (1.0 - wd_reduction_rate(t))
[docs]
def lw_pp(t):
"""LW(t): the lifetime withdrawal amount per contract closing month t."""
return lw_pp_at(t, "EOY")
[docs]
def wd_scheduled_pp(t):
"""The ad hoc gross withdrawal scheduled at the anniversary of month t's year, else zero.
Read from *withdrawal_table.csv*, which is **not** a step function: a contract year
with no row takes nothing. **Its key column is an elapsed contract year count, not the
frame's month index** — a row keyed ``y`` is a withdrawal at the anniversary closing
contract year ``y + 1`` — so it is read at ``duration(t)`` and the file is unchanged by
the move to a monthly grid. Lifetime withdrawals are generated by the rider, not
scheduled here.
"""
if not is_anniv(t):
return 0.0
table = data.withdrawal_table() # noqa: F821
key = (wd_schedule_id(), duration(t))
return float(table.loc[key, "wd_amount"]) if key in table.index else 0.0
[docs]
def wd_pp(t):
"""G(t): the gross withdrawal taken at the anniversary in month t.
Zero in every other month: both the lifetime withdrawal and the ad hoc schedule are
contractually annual, so the finer grid moves no cash between anniversaries. In
``INCOME``, ``utilization_intensity() x LW`` plus any scheduled ad hoc amount; in
``DEPLETED``, exactly ``LW``, paid by the insurer from its own funds; in ``ACCUM``,
only the scheduled amount. Gross by construction: a contract promising a stated net
check would need a gross-up solve, which is not implemented.
"""
if t < entry_mth() or not is_anniv(t):
return 0.0
opening = phase_open(t)
if opening == "TERMINATED":
return 0.0
if opening == "DEPLETED":
return lw_pp_at(t, "BEF_WD")
if opening == "INCOME":
return utilization_intensity() * lw_pp_at(t, "BEF_WD") + wd_scheduled_pp(t)
return wd_scheduled_pp(t)
[docs]
def wd_guar_pp(t):
"""min(G(t), LW(t)): the guaranteed portion of month t's withdrawal.
A withdrawal is applied **first** against the guaranteed annual amount; that portion
reduces the account value dollar for dollar and leaves the benefit base and the
income amount unchanged [S9].
"""
return min(wd_pp(t), lw_pp_at(t, "BEF_WD"))
[docs]
def wd_excess_pp(t):
"""E(t) = max(0, G(t) - LW(t)): the **excess withdrawal** in month t.
The quantity that permanently reduces the guarantee, and that destroys it entirely if
it exhausts the account value [S1][S5][S9]. Note this is *not* the chassis's
``wd_excess_pp``, which is the amount exposed to the surrender charge; that is
:func:`wd_charge_base_pp` here. Before exercise ``LW`` is zero, so the whole
withdrawal is an excess.
"""
return max(0.0, wd_pp(t) - lw_pp_at(t, "BEF_WD"))
[docs]
def wd_unfunded_pp(t):
"""The part of G(t) that neither the account value nor the guarantee pays.
``G(t) - min(G(t), max(0, AV(2)(t)))`` in a month whose closing phase is
``TERMINATED``; zero everywhere else. **The two exhaustion branches are not
symmetric, and this is where they part.** In ``DEPLETED`` the account value is gone
but the guarantee is alive, so the insurer pays ``LW`` from its own funds for the
rest of the covered life [S1][S3][S9][R1] and nothing is unfunded. On the
``TERMINATED`` branch the account value is driven to zero by an excess withdrawal, a
surrender charge or a negative MVA, and "an account value run to zero by an excess
withdrawal loses the guarantee entirely" [S1][S5][S9] — [S5] is explicit that the
contract "as well as the rider will be considered Surrendered". Beyond the account
value there is then nothing to pay from: no balance and no promise. Paying the
requested amount in full would honour the guarantee in the very year the withdrawal
destroys it, which is the trap the notes' attribution test exists to avoid.
The notes write step 4 as ``AV(5)(t) = AV(2)(t) - G(t)`` with no cap because they
write it for the funded case. The cap here is on the **payment**, not on the
withdrawal: :func:`wd_pp` still carries the amount requested, so the excess still
sets :func:`depletion_cause` and still drives ``rho`` to 1 and the benefit base to
zero. Only :func:`wd_payment_pp` and the three ledger lines are reduced.
"""
if t < entry_mth():
return 0.0
if phase(t) != "TERMINATED":
return 0.0
return wd_pp(t) - min(wd_pp(t), max(0.0, av_pp_at(t, "BEF_WD")))
[docs]
def wd_guar_paid_pp(t):
"""The guaranteed portion of month t's withdrawal that is actually paid.
``min(G, LW)`` less whatever of :func:`wd_unfunded_pp` the excess could not absorb.
The account value funds the withdrawal in the order the notes compose it — the first
``LW`` dollars are the guaranteed portion and the remainder is the excess [S9] — so
an under-funded withdrawal leaves the **excess** unpaid first and eats into the
guaranteed portion only after that **[std]**, the notes being silent on a withdrawal
the account value cannot fund. Equal to :func:`wd_guar_pp` in every month except a
terminating anniversary.
"""
return max(0.0, wd_guar_pp(t) - max(0.0, wd_unfunded_pp(t) - wd_excess_pp(t)))
[docs]
def wd_excess_paid_pp(t):
"""The excess portion of month t's withdrawal that is actually paid.
``max(0, E(t) - wd_unfunded_pp(t))``: the excess is the first thing an under-funded
withdrawal loses, being the part with no promise behind it. Equal to
:func:`wd_excess_pp` in every month except a terminating anniversary.
"""
return max(0.0, wd_excess_pp(t) - wd_unfunded_pp(t))
[docs]
def wd_cum_pp(t):
"""Wcum(t): cumulative gross withdrawals from entry to the end of month t.
A running total over months, though only the anniversary months add to it.
"""
if t < entry_mth():
return 0.0
prior = 0.0 if t == entry_mth() else wd_cum_pp(t - 1)
return prior + wd_pp(t)
[docs]
def free_wd_base(t):
"""The base of the free withdrawal amount: the AV opening the contract year of month t.
10% of the account value at the anniversary that **opens** the contract year, available
from contract year 1 and with no carry-forward [S1][S3][S5][S6][S9][S10]; the
combination is **[std]**. Fixing the base at a *known* prior value is what lets the
chargeable amount be evaluated without a fixed point, and it is what makes the
allowance a property of the contract year rather than of the month.
"""
return av_pp_year_open(t)
[docs]
def free_wd_allow(t):
"""FW(t) = 0.10 x AV(0): the free withdrawal amount for month t's contract year."""
return free_wd_rate * free_wd_base(t) # noqa: F821
[docs]
def wd_free_pp(t):
"""The free-allowance portion of the anniversary's withdrawal: the part of FW(t) it uses.
The chassis name for this quantity (``MYGA_US_S``, ``UL_US_S``,
``RILA_US_S``), so the library spells the free-allowance portion
of a withdrawal one way everywhere. Zero away from the anniversary, where no
withdrawal is taken and the whole allowance therefore remains against a mid-year
surrender.
**A [std] choice the notes flag as open.** [S9] says only that withdrawals up to the
annual benefit amount carry no charge "even if greater than the Free Withdrawal
Amount"; it does not say whether they *exhaust* it. The convention here — the
guaranteed amount consumes the free amount — is the insurer-favourable reading and is
what the worked example uses, leaving ``12,800.00 - 10,144.16 = 2,655.84`` against
the surrender. The alternative leaves the whole ``FW(t)`` available against the
excess.
"""
if t < entry_mth() or not is_anniv(t):
return 0.0
if phase_open(t) == "INCOME":
return min(free_wd_allow(t), lw_pp_at(t, "BEF_WD"))
return min(free_wd_allow(t), wd_pp(t))
[docs]
def free_wd_remain(t):
"""The free withdrawal amount left after the contract year's guaranteed withdrawal.
The whole allowance away from an anniversary, where no withdrawal has been taken yet —
which is what a mid-year surrender is charged against.
"""
return free_wd_allow(t) - wd_free_pp(t)
[docs]
def wd_charge_base_pp(t):
"""X(t): the part of month t's withdrawal exposed to charge, clawback and MVA.
``max(0, G(t) - FW(t))`` before exercise and ``max(0, E(t) - remaining free)`` after
it **[std]** — withdrawals up to ``LW`` carry no surrender charge, no MVA and no
bonus clawback **even when ``LW`` exceeds the free withdrawal amount** [S9]. Zero
once the account value is gone: post-depletion income is not a chargeable withdrawal.
"""
if t < entry_mth():
return 0.0
opening = phase_open(t)
if opening in ("DEPLETED", "TERMINATED"):
return 0.0
if opening == "INCOME":
return max(0.0, wd_excess_pp(t) - free_wd_remain(t))
return max(0.0, wd_pp(t) - free_wd_allow(t))
[docs]
def surr_charge_base_pp(t):
"""X(t) at a **full surrender** in month t: ``AV(t)`` less the free amount left.
The chassis calls this ``surr_excess_pp``; renamed here only for symmetry with
:func:`wd_charge_base_pp`, because ``E(t)`` already names a different quantity in
these notes. The construction is the chassis's and the source is the same [S10].
"""
if t < entry_mth():
return 0.0
if phase(t) == "DEPLETED":
return 0.0
return max(0.0, av_pp(t) - free_wd_remain(t))
[docs]
def bonus_factor():
"""b/(1+b): the factor that strips the bonus out of a bonus-inclusive account value.
**The clawback factor is b/(1+b), not b** [S10] — the account value already contains
the bonus, so using ``b`` over-recovers by ``(1+b)``.
"""
return bonus_rate() / (1.0 + bonus_rate())
[docs]
def bonus_clawback_on(base, vested, bonus):
"""CB = (1 - A) x [B/(1+B)] x C: the non-vested bonus recovery [S10].
``A`` is the vested percentage for the contract year, ``B`` the bonus percentage and
``C`` the gross withdrawal less the free withdrawal amount. Worked verbatim at
[S10]: contract year 5, bonus 16%, gross $100,000, free $7,000 gives
``0.70 x 0.1379 x 93,000 = 8,979``. Written as a function of its three arguments so
that arithmetic is testable directly.
"""
return (1.0 - vested) * (bonus / (1.0 + bonus)) * base
[docs]
def mva_ref_yield(y):
"""i_y: the MVA reference index level at anniversary ``y``, from the scenario table.
**Keyed by anniversary number**, like :func:`index_level`, so the level in force at a
transaction closing the contract year of month ``t`` is
``mva_ref_yield(duration(t) + 1)``. A declared investment-grade corporate bond yield
index; generic here **[std]**, and named as Barclay's US Credit Index in the
linear-form products [S6][S7].
"""
return scenario_value(y, "mva_ref_yield")
[docs]
def mva_term(t):
"""n/12: the years remaining in the MVA period at the end of month t.
The MVA period is the ten-year surrender charge period [S7][S10], and the notes state
the term in **months** — which is what the monthly grid can now say exactly:
``surr_charge_period - (t + 1)/12``, a transaction in month ``t`` being settled at its
end. At the worked example's anniversary 8 — month 95, the close of contract year 8 —
that is ``10 - 96/12 = 2`` years, the ``n = 24`` months the notes quote, so every
anniversary value is the annual grid's; a surrender six months earlier now carries the
30 months it actually has instead of being rounded to the anniversary. The remaining
term reaches zero at anniversary 10, so a transaction carries a live adjustment through
contract year 9 and none in contract year 10.
"""
return max(0.0, surr_charge_period - (t + 1) / 12.0) # noqa: F821
[docs]
def mva_in_force(t):
"""Whether an MVA applies to a transaction in month t.
``MVA = 0`` outside the MVA period and on the death benefit [S5][S6][S7][S10]; the
second is handled where the death benefit is valued.
"""
return mva_term(t) > 0.0
[docs]
def mva_rate(t):
"""The market value adjustment rate, ``[(1+i0)/(1+it)]^(n/12) - 1`` [S10].
Signed, and **negative when the reference yield has risen** [S10]. This is the
ratio-of-yield-factors family, which is naturally bounded; the linear family
``(i0 - it) x T`` adopted by the fixed-deferred chassis [S6][S7] is unbounded and
must be collared separately. The FIA composite **restates** the MVA rather than
inheriting it, which is why the chassis's ``mva_family`` switch is absent here.
The reference yield is read at the anniversary **closing** the contract year of month
``t``, ``duration(t) + 1``, which is the transaction date the adjustment applies at.
"""
if not mva_in_force(t):
return 0.0
return ((1.0 + mva_ref_yield_at_issue())
/ (1.0 + mva_ref_yield(duration(t) + 1))) ** mva_term(t) - 1.0
[docs]
def mva_pp_on(t, base, gross):
"""The collared MVA on a currency ``base`` withdrawn as ``gross`` in month t.
``|MVA| <= max(0, G - SC - CB - MGV)`` [S10], so a negative MVA combined with the
surrender charge and the clawback can never push the proceeds below the guaranteed
minimum value, and the maximum positive adjustment cannot exceed the maximum negative
one. In the worked example's surrender trace the limit is
``max(0, 122,865.84 - 5,965.56 - 84,605.80) = 32,294.48`` and the raw
``-1,158.64`` sits well inside it.
**The notes are silent on how a surrender-value collar applies to a partial
withdrawal, and both readings are shipped.** The limit is stated on the *gross
withdrawal*, and its stated purpose is that the adjustment "never reduces the
surrender value below the guaranteed minimum value" — a full-surrender concept, and
the only case the worked example demonstrates. Read literally, a partial withdrawal
smaller than the floor gives a non-positive limit and therefore **no adjustment at
all**. ``mva_collar_basis`` selects:
``"gross"`` **[std] default**
the notes' literal text, ``reference = G(t)``.
``"surrender_value"``
the test applied to the contract rather than to the payment,
``reference = AV(t)``, so a partial withdrawal carries the adjustment its rate
produces up to what the remaining floor allows.
**The two are identical on the surrender path**, where ``G(t) = AV(t)``, so the
worked example reproduces under either; they differ only where the notes say nothing.
"""
raw = mva_rate(t) * base
if mva_collar_basis == "gross": # noqa: F821
reference = gross
elif mva_collar_basis == "surrender_value": # noqa: F821
reference = av_pp(t)
else:
raise ValueError("invalid mva_collar_basis")
limit = max(0.0, reference - surr_charge_rate(t) * base
- bonus_clawback_on(base, vest_rate(t), bonus_rate())
- mgsv_pp(t))
return max(-limit, min(limit, raw))
[docs]
def wd_charge_pp(t):
"""SC(t) on month t's withdrawal: ``sc(t) x X(t)`` [S5][S10]."""
return surr_charge_rate(t) * wd_charge_base_pp(t)
[docs]
def wd_clawback_pp(t):
"""CB(t) on month t's withdrawal [S10]."""
return bonus_clawback_on(wd_charge_base_pp(t), vest_rate(t), bonus_rate())
[docs]
def wd_mva_pp(t):
"""MVA(t) on month t's withdrawal, collared [S10]."""
return mva_pp_on(t, wd_charge_base_pp(t), wd_pp(t))
[docs]
def wd_payment_pp(t):
"""The cash paid on month t's withdrawal, ``G - unfunded - SC - CB + MVA``.
The surrender charge and the clawback are **internal transfers** within the account
value, not fee income: the account value falls by the *gross* ``G(t)`` while the
holder receives this. Reporting them as income while also projecting the account
value net of them double-counts.
:func:`wd_unfunded_pp` is zero in every month except a terminating anniversary, where
it strips out the part of the request the account value could not fund and the
destroyed guarantee does not cover. On the ``DEPLETED`` branch it stays zero and the
whole of ``LW`` is paid, which is the guarantee.
"""
return (wd_pp(t) - wd_unfunded_pp(t) - wd_charge_pp(t)
- wd_clawback_pp(t) + wd_mva_pp(t))
[docs]
def wd_reduction_rate_on(av, lw, gross):
"""rho for a gross withdrawal of ``gross`` against ``av`` with guaranteed amount ``lw``.
``E / (AV - LW)``, the post-exercise construction stated verbatim at [S9]: account
value $100,000, base $200,000, annual benefit amount $10,000, withdrawal $28,000
gives denominator $90,000, excess $18,000, reduction 20%, base to $160,000 and
benefit amount to $8,000. Passing ``lw = 0`` gives the pre-exercise form
``G / AV`` [S1][S3][S5][S9] — **the two denominators differ by exactly LW**, which is
the pitfall the notes name. ``rho = 1`` when the denominator is non-positive: the
base goes to zero and the rider terminates [S9].
"""
excess = max(0.0, gross - lw)
if excess <= 0.0:
return 0.0
denom = av - lw
if denom <= 0.0:
return 1.0
return min(1.0, excess / denom)
[docs]
def wd_reduction_rate(t):
"""rho(t): the proportional reduction applied to BB, LW and RB in month t.
Pre-exercise the denominator is the gross account value after the rider charge;
post-exercise it is that value **net of the guaranteed amount** [S9]. Zero when
there is no excess: a withdrawal inside ``LW`` leaves the guarantee untouched.
"""
if t < entry_mth():
return 0.0
opening = phase_open(t)
if opening in ("DEPLETED", "TERMINATED"):
return 0.0
if opening == "INCOME":
return wd_reduction_rate_on(av_pp_at(t, "BEF_WD"),
lw_pp_at(t, "BEF_WD"), wd_pp(t))
return wd_reduction_rate_on(av_pp_at(t, "BEF_WD"), 0.0, wd_pp(t))
[docs]
def surr_charge_pp(t):
"""SC(t) on a full surrender in month t."""
return surr_charge_rate(t) * surr_charge_base_pp(t)
[docs]
def surr_clawback_pp(t):
"""CB(t) on a full surrender closing month t [S10]."""
return bonus_clawback_on(surr_charge_base_pp(t), vest_rate(t), bonus_rate())
[docs]
def mva_pp(t):
"""MVA(t) on a full surrender closing month t, collared [S10]."""
return mva_pp_on(t, surr_charge_base_pp(t), av_pp(t))
[docs]
def surr_value_pp(t):
"""The surrender value before the nonforfeiture floor, ``AV - SC - CB + MVA``.
The composition order is **account value, then charge and clawback and MVA computed
on the pre-deduction excess, then the floor** — the chassis's order, and the one
contractual element the FIA notes inherit rather than restate.
"""
if phase(t) == "DEPLETED":
return 0.0
return (av_pp(t) - surr_charge_pp(t) - surr_clawback_pp(t) + mva_pp(t))
[docs]
def surr_benefit_pp(t):
"""CSV(t) = max(AV - SC - CB + MVA, MGV): the surrender benefit paid [S1][S6][S10].
Zero in ``DEPLETED``: there is no account value and no surrender value [S1][S9]. A
binding floor is **not** a separate cash flow — it raises the benefit, and
``MGV - SV`` is a reconciliation quantity only.
"""
if phase(t) == "DEPLETED":
return 0.0
return max(surr_value_pp(t), mgsv_pp(t))
[docs]
def mgsv_charge_pp(t):
"""The annual contract charge deducted from the Model #805 floor, at the anniversary.
Model #805 4.A permits $50 a year, accumulated at the nonforfeiture rate, together
with premium tax actually paid and indebtedness [R2]. All are zero here **[std]**
because no retrieved product declares an actual annual policy fee, which makes the
modeled floor slightly conservative.
"""
if t < entry_mth() or not is_anniv(t):
return 0.0
return mgsv_annual_charge # noqa: F821
[docs]
def mgsv_pp(t):
"""MGV(t): the Model #805 guaranteed minimum value closing **month** t.
The floor accretes every month at ``(1 + i_nf)^(1/12) - 1``, so twelve months compound
to exactly the annual nonforfeiture rate and the anniversary values are the annual
grid's; the withdrawal is deducted at the anniversary, where it is taken. **Accrete,
then deduct** — the worked example pins the ordering at
``93,811.84 x 1.01 - 10,144.16 = 84,605.80``. The fixed-deferred chassis deducts and
*then* accretes; the two are different arithmetic on the same concept, which is exactly
what that file warns against carrying across.
Accruing monthly rather than once at the anniversary is what makes the floor mean
something for a mid-year death or surrender, which is where it most often binds. The
opening value is ``0.875 x P`` excluding the bonus at issue [S10][R2].
``rider_charge_from_mgsv`` switches on the treatment documented at two carriers, one
deducting the rider charge from the floor and the other its allocation charge
[S1][S2][S3][S4]; the composite does not **[std]**.
"""
if t < entry_mth():
return mgsv_pp_init()
opening = mgsv_pp_init() if t == entry_mth() else mgsv_pp(t - 1)
accrued = opening * (1.0 + mgsv_rate) ** (1.0 / 12.0) # noqa: F821
if not is_anniv(t):
return max(0.0, accrued)
charge = rider_charge_pp(t) if rider_charge_from_mgsv else 0.0 # noqa: F821
return max(0.0, accrued - wd_pp(t) - mgsv_charge_pp(t) - charge)
[docs]
def mgsv_rate_statutory(cmt5, option_cost):
"""The Model #805 4.B/4.C indexed nonforfeiture rate [R2][R3].
``max(0.0015, min(0.03, round(CMT5 to 1/20 of 1%) - 0.0125 - delta))`` where
``delta`` is the equity-index reduction of 4.C: available only when the annualized
option cost of the **guaranteed** index features is at least 25 basis points, and
then equal to ``min(100 bp, option cost)``, certified annually [R3].
Two traps the notes name. **The 4.B floor is 15 basis points, not 1%** — the
composite's 1.00% is a **[std]** pick inside the 0.15%-3% corridor, not the statutory
floor. And whether the 15 bp floor survives the 4.C reduction is not stated in the
retrieved text [unverified]; the specimen contract language, "the interest rates will
range between 0.15% and 3%", suggests it does [S10], which is the reading
implemented. The statute *defines the minimum*: the contract rate must satisfy
``mgsv_rate >= mgsv_rate_statutory(...)``, which is what
:func:`mgsv_rate_is_compliant` tests, not the reverse inequality.
"""
delta = (min(mgsv_stat_delta_max, option_cost) # noqa: F821
if option_cost >= mgsv_stat_delta_gate else 0.0) # noqa: F821
rounded = math.floor(cmt5 / mgsv_stat_step + 0.5) * mgsv_stat_step # noqa: F821
return max(mgsv_stat_floor, # noqa: F821
min(mgsv_stat_cap, # noqa: F821
rounded - mgsv_stat_spread - delta)) # noqa: F821
[docs]
def mgsv_rate_is_compliant(cmt5, option_cost):
"""True when the contract nonforfeiture rate meets the statutory minimum [R2][R3]."""
return mgsv_rate >= mgsv_rate_statutory(cmt5, option_cost) # noqa: F821
[docs]
def mort_rate(t):
"""q(t): the **annual** mortality rate of the contract year containing month t.
Read at ``age(t)``, the attained age opening that contract year — the library
convention of :mod:`.Term_US_S` and :mod:`.MYGA_US_S`. The rate actually applied to a
month is :func:`mort_rate_mth`; this cells stays annual so the table is read the way
it is published and so the assumption is visible at the frequency it is set at.
The shipped table is the same illustrative Makeham annuitant curve as
``products/fixed_deferred_annuity`` **[std]**, *not* a published basis. The prescribed
basis is the 2012 IAM Basic / 2012 IAR generational family with Projection Scale G2,
``q_x^(2012+n) = q_x^(2012) x (1 - G2_x)^n``, rounding applied from the 2012 period
rate each time and never by compounding an already rounded rate [REG-R59][REG-R60]; it
may not be redistributed here, so swap it in by repointing ``Data.mort_table_file``.
Generational projection is **not implemented** for the same reason. ``mort_ae_factor``
carries the A/E deviation, 100% **[std]** against the 2020-2024 payout experience study
[REG-R61]; the spelling is the library's shared one, so the A/E factor reads the same
in every model that has one.
"""
base = float(data.mort_table().loc[(age(t), sex()), "mort_rate"]) # noqa: F821
return min(1.0, mort_ae_factor * base) # noqa: F821
[docs]
def mort_rate_mth(t):
"""The monthly mortality rate, ``1 - (1 - q)^(1/12)``.
The uniform-force conversion the library uses everywhere: twelve of these compound to
exactly the annual rate of the contract year, so the anniversary in-force numbers are
the annual grid's and only their distribution through the year changes. Dividing by
twelve instead would overstate the decrement, and the error grows with the rate.
"""
return 1.0 - (1.0 - mort_rate(t)) ** (1.0 / 12.0)
[docs]
def shock_lapse_rate(t):
"""w_shock: the surrender rate in the year the surrender charge expires [R8].
**The single most important behavioral fact in the product.** 33% without a GLWB
rider against 10% with one in force but not activated, and 5% **[std]** once it is
activated, extrapolated from the finding that contracts with GLWBs lapse less than
those without and that activated GLWBs lapse least [R1][R8]. Applying a plain
fixed-deferred shock lapse — roughly 52%-56% for fixed-rate deferred annuities
[REG-R63, unverified] — to an FIA with an in-force rider materially understates the
tail the product is sold for.
"""
if not glwb_elected() or not rider_in_force_open(t):
return shock_lapse_no_rider # noqa: F821
if phase_open(t) == "ACCUM":
return shock_lapse_rider_idle # noqa: F821
return shock_lapse_rider_active # noqa: F821
[docs]
def lapse_rate_base(t):
"""w_base(t): the base annual surrender rate of month t's contract year **[std]**.
2% in years 1-3, 3% in 4-6, 4% in 7-9, 5% in year 10, the shock in the year the
surrender charge expires, and 6% thereafter — the shape [R1] describes: low early,
rising through the charge period, spiking at expiry, then falling back but staying
above pre-shock levels. The notes' separate ``M_shock`` multiplier is absorbed here,
because the shock is stated as an absolute rate rather than as a factor on the
ultimate; see the Space docstring.
"""
year = policy_year(t)
if year == surr_charge_period + 1: # noqa: F821
return shock_lapse_rate(t)
if year <= 3:
return lapse_base_early # noqa: F821
if year <= 6:
return lapse_base_mid # noqa: F821
if year <= 9:
return lapse_base_late # noqa: F821
if year <= surr_charge_period: # noqa: F821
return lapse_base_final # noqa: F821
return lapse_base_ult # noqa: F821
[docs]
def lapse_moneyness_factor(t):
"""M_money(t) = clamp(1 - 0.6 max(0, BB/AV - 1), 0.2, 1.0) **[std]**.
Surrender is suppressed when the guarantee is in the money, because a rational
surrender destroys a guarantee worth ``BB - AV`` in benefit-base terms. The observed
direction is documented [R1][R8]; the functional form is not.
Evaluated on the state **closing the contract year**, at :func:`anniv_mth`, so the
factor is a property of the year like the base rate it multiplies. Reading it month
by month would let the fixed-account accrual drift the ratio within the year and make
the annual surrender assumption depend on where in the year it is sampled.
"""
a = anniv_mth(t)
av = av_pp(a)
if av <= 0.0:
return lapse_money_floor # noqa: F821
ratio = benefit_base_pp(a) / av
return min(1.0, max(lapse_money_floor, # noqa: F821
1.0 - lapse_money_slope * max(0.0, ratio - 1.0))) # noqa: F821
[docs]
def lapse_rate(t):
"""w(t): the **annual** surrender rate of the contract year containing month t.
``min(0.35, w_base(t) x M_money(t))``, and **zero in DEPLETED** — with no account
value there is nothing to surrender, so lapse is impossible [S1][S9] and leaving the
decrement on would silently truncate the most expensive part of the liability. In
the contract year the contract terminates, the survivors leave as a deemed full
surrender [S5][S9], so this returns 1.0 there and the in-force roll-forward closes.
Evaluated on the phase **closing** the contract year, at :func:`anniv_mth`, so it is
one rate for the whole year; :func:`lapse_rate_mth` is what a month actually applies.
"""
if t < entry_mth():
return 0.0
a = anniv_mth(t)
if phase_open(a) == "TERMINATED":
return 0.0
closing = phase(a)
if closing == "TERMINATED":
return 1.0
if closing == "DEPLETED":
return 0.0
return min(lapse_rate_max, # noqa: F821
lapse_rate_base(t) * lapse_moneyness_factor(t))
[docs]
def lapse_rate_mth(t):
"""The monthly surrender rate, ``1 - (1 - w)^(1/12)``.
The same uniform-force conversion as :func:`mort_rate_mth`, so twelve months compound
to exactly the contract year's annual rate — including the shock in the year the
surrender charge expires, which **is** spread. That is deliberate and is the opposite
of :mod:`.Term_US_S`, where the notes state the shock as a rate "applied in full at
the end of the final level-period month": here the notes state it as the surrender
rate *of contract year 11*, a year that carries no surrender charge in any month, so
there is no contractual date inside it for the decision to cluster on.
The one rate that is **not** spread is the deemed full surrender at termination. That
is a contractual event, not a behavioral assumption: the contract "as well as the
rider will be considered Surrendered" [S5] at the anniversary the account value is
destroyed, so the survivors leave in that month and not before.
"""
w = lapse_rate(t)
if w >= 1.0:
return 1.0 if is_anniv(t) else 0.0
return 1.0 - (1.0 - w) ** (1.0 / 12.0)
[docs]
def pols_if(t):
"""The in-force probability at the **start** of month t.
The library convention, set by :mod:`.Term_US_S` and ``savings.CashValue_SE``: this is
the count entering the month, before its decrements, and it is the weight carried by
every cash flow reported on the same row of :func:`result_cf`. At an anniversary month
``12k + 11`` it is the count that reaches the anniversary and therefore the weight the
year's withdrawal and charges carry — on the annual grid that weight was the count
entering the year, which is the one place the finer grid changes the answer rather than
only the resolution.
``pols_if(entry_mth()) = pols_if_init()``, the recursion runs
``pols_if(t + 1) = pols_if(t)(1 - q_m(t))(1 - w_m(t))`` with death before surrender
**[std]**, and it is zero from ``proj_len()`` on, where the survivors of the last
projected month have left through :func:`pols_maturity`. Because the monthly rates
compound to the annual ones, ``pols_if(12k)`` is exactly the annual-grid model's
``pols_if(k)``: the notes' ``l(k)``.
"""
if t <= entry_mth():
return pols_if_init()
if t >= proj_len():
return 0.0
return pols_if_at(t - 1, "AFT_DECR")
[docs]
def pols_if_at(t, timing):
"""In-force in month t read at the point given by ``timing``.
``"BEF_DECR"`` and ``"BEF_MORT"`` are both the count entering the month, which is
:func:`pols_if` itself — this product has no annuitization decrement, so the chassis's
two timings coincide. ``"BEF_LAPSE"`` is after deaths, and ``"AFT_DECR"`` is the count
at the *end* of month t and therefore ``pols_if(t + 1)``. Both decrements are the
**monthly** rates.
"""
if t < entry_mth():
return pols_if_init()
pols = pols_if(t)
if timing in ("BEF_DECR", "BEF_MORT"):
return pols
pols = pols * (1.0 - mort_rate_mth(t))
if timing == "BEF_LAPSE":
return pols
pols = pols * (1.0 - lapse_rate_mth(t))
if timing == "AFT_DECR":
return pols
raise ValueError("invalid timing")
[docs]
def pols_death(t):
"""Deaths in month t, on the monthly mortality rate."""
return (0.0 if t < entry_mth()
else pols_if_at(t, "BEF_MORT") * mort_rate_mth(t))
[docs]
def pols_lapse(t):
"""Full surrenders in month t, on the survivors of that month's mortality."""
return (0.0 if t < entry_mth()
else pols_if_at(t, "BEF_LAPSE") * lapse_rate_mth(t))
[docs]
def pols_maturity(t):
"""Survivors leaving at the projection horizon, non-zero only at ``proj_len() - 1``.
Not a decrement — the horizon runs out — but needed for the in-force roll-forward to
close. Numerically zero under the shipped mortality table, whose annual rate reaches
1.000000 at age 120, hence a monthly rate of 1.0, so the projection closes itself; see
the Space docstring.
"""
return pols_if_at(t, "AFT_DECR") if t == proj_len() - 1 else 0.0
[docs]
def pols_decr(t, kind):
"""The number of contracts leaving in month t by benefit ``kind``."""
if kind == "DEATH":
return pols_death(t)
elif kind == "LAPSE":
return pols_lapse(t)
elif kind == "MATURITY":
return pols_maturity(t)
else:
raise ValueError("invalid kind")
[docs]
def claim_pp(t, kind):
"""The benefit paid per contract at the end of month t by ``kind``.
``"DEATH"`` is ``max(AV(t), MGV(t))`` with 100% bonus vesting, no surrender charge
and no MVA [S1][S2][S5][S10], and is never below the cash surrender benefit [R2 6].
Note the FIA composite **restates** this rather than inheriting the chassis's full
account value floored at the surrender benefit. ``"LAPSE"`` is the surrender benefit.
``"MATURITY"`` is the account value floored at the guaranteed minimum at the horizon.
All three are **zero in DEPLETED**: there is no account value, no surrender value and
no death benefit, and the only exit is death [S1][S9].
"""
if phase(t) == "DEPLETED":
return 0.0
if kind == "DEATH":
return max(av_pp(t), mgsv_pp(t))
elif kind == "LAPSE":
return surr_benefit_pp(t)
elif kind == "MATURITY":
return max(av_pp(t), mgsv_pp(t))
else:
raise ValueError("invalid kind")
[docs]
def claim_from_av_pp(t, kind):
"""The account value released per contract by a claim of ``kind``.
``AV(t)`` for all three kinds; the difference from :func:`claim_pp` is
:func:`claims_over_av`, positive when the Model #805 floor binds and negative when
the surrender charge and a negative MVA bite.
"""
if kind in ("DEATH", "LAPSE", "MATURITY"):
return av_pp(t)
else:
raise ValueError("invalid kind")
[docs]
def premiums(t):
"""Premium income: the single premium, at the **start** of month 0 on a new issue.
There is no issue-instant row, so the premium is a beginning-of-month flow of the
first projected month rather than a row of its own. An in-force model point paid its
premium before the projection starts, so this is zero throughout for it — as are the
acquisition expense and the premium tax that key off it.
"""
if t == 0 and entry_mth() == 0:
return premium_pp() * pols_if_init()
return 0.0
[docs]
def prem_to_av_pp(t):
"""Premium and bonus credited to the account value per contract, at issue only."""
return av_pp_init() if (t == 0 and entry_mth() == 0) else 0.0
[docs]
def prem_to_av(t):
"""Premium and bonus credited to the block's account value, at issue only."""
return prem_to_av_pp(t) * pols_if_init() if t == 0 else 0.0
[docs]
def wd_guar(t):
"""Guaranteed withdrawal outgo in month t, weighted by ``pols_if(t)``.
The count entering the month, which is the in-force number reported on the same row of
:func:`result_cf`; at an anniversary that is the count which **reaches** it.
On :func:`wd_guar_paid_pp`, not on the amount requested, so a terminating anniversary
cannot pay a guaranteed withdrawal the account value could not fund and the destroyed
rider no longer covers. Zero in ``DEPLETED``, where the same payment is reported as
:func:`income_payments` so the two lines partition the total rather than overlap.
"""
if t < entry_mth() or phase_open(t) == "DEPLETED":
return 0.0
return wd_guar_paid_pp(t) * pols_if(t)
[docs]
def wd_excess(t):
"""Excess withdrawal outgo in month t: ``E - unfunded - SC - CB + MVA``.
Weighted by ``pols_if(t)``, the count entering the month, and on
:func:`wd_excess_paid_pp`: the excess is the first part of an under-funded withdrawal to
go unpaid.
"""
if t < entry_mth():
return 0.0
net = (wd_excess_paid_pp(t) - wd_charge_pp(t) - wd_clawback_pp(t) + wd_mva_pp(t))
return net * pols_if(t)
[docs]
def income_payments(t):
"""Post-depletion income outgo: ``LW`` at the anniversary while ``phase = DEPLETED``.
**This is the guarantee.** There is no account value behind it, no rider charge is
deducted, and it runs for the rest of the covered life [S1][S3][S9][R1].
Annual like every other withdrawal, so it falls in the anniversary month and is
weighted by the contracts that **reach** it. Paying it in each of the twelve months
would multiply the guarantee twelvefold and break the partition
``withdrawals = wd_guar + wd_excess + income_payments`` that :func:`result_cf`
documents.
"""
if t < entry_mth() or not is_anniv(t):
return 0.0
if phase_open(t) != "DEPLETED":
return 0.0
return lw_pp_at(t, "BEF_WD") * pols_if(t)
[docs]
def withdrawals(t):
"""Total withdrawal outgo in month t, ``wd_payment_pp(t) x pols_if(t)``.
Equal to :func:`wd_guar` + :func:`wd_excess` + :func:`income_payments` in every
month, the three lines partitioning it by the notes' ledger categories. All
four are published by :func:`result_cf`, so **summing every column of that frame
double-counts the withdrawal**; the ledger identity is on the total, and the split is
there because the notes report the three categories separately.
"""
return 0.0 if t < entry_mth() else wd_payment_pp(t) * pols_if(t)
[docs]
def claims(t, kind=None):
"""Benefit outgo in month t, for one ``kind`` or, with ``kind=None``, all three."""
if kind is None:
return (claims(t, "DEATH") + claims(t, "LAPSE") + claims(t, "MATURITY"))
return claim_pp(t, kind) * pols_decr(t, kind)
[docs]
def claims_from_av(t, kind):
"""The account value released by a claim of ``kind``, in-force weighted."""
return claim_from_av_pp(t, kind) * pols_decr(t, kind)
[docs]
def claims_over_av(t, kind=None):
"""Benefit paid less the account value released, ``claims - claims_from_av``.
Signed and a reconciliation quantity only: positive exactly when the Model #805 floor
binds, negative when the surrender charge, the clawback and a negative MVA bite. A
binding floor does not add a top-up cash flow — it raises the benefit.
"""
if kind is None:
return (claims_over_av(t, "DEATH") + claims_over_av(t, "LAPSE")
+ claims_over_av(t, "MATURITY"))
return claims(t, kind) - claims_from_av(t, kind)
[docs]
def commissions(t):
"""Acquisition commission, **zero [std]**.
The technical notes fold the whole acquisition cost into a single 6.0%-of-premium
acquisition *expense* and give no separate commission, so ``comm_rate_acq`` is 0 and
this line exists for shape only. It is kept rather than dropped because every model
in the library carries the same cash flow vocabulary.
"""
return comm_rate_acq * premiums(t) # noqa: F821
[docs]
def premium_taxes(t):
"""Premium tax on the single premium, 0% on the composite state basis **[std]**."""
return premium_tax_rate * premiums(t) # noqa: F821
[docs]
def inflation_factor(t):
"""The expense inflation factor in month t, ``1.025^(t/12)``.
A fractional power of the annual rate, so twelve months of it compound to exactly one
year of inflation and the factor at each anniversary is the annual grid's.
"""
return (1.0 + inflation_rate) ** (t / 12.0) # noqa: F821
[docs]
def expenses(t):
"""Insurer expenses: acquisition at issue and inflating maintenance monthly **[std]**.
6.0% of the single premium in month 0 on a new issue, and $80 per contract per year
inflating at 2.5% — **one twelfth of it a month**, weighted by the in-force at the
start of the month. Spreading the maintenance charge is the point of the finer grid
for this line: it is an insurer cost accruing continuously, not a contractual event
tied to the anniversary, so it now falls where it is incurred and is weighted by
survivors who have not yet left. ``premiums(t)`` is zero everywhere but the first
month, so the acquisition term simply drops out elsewhere. Both are standardizations:
no retrieved document discloses FIA acquisition cost or per-contract maintenance.
"""
if t < entry_mth() or t >= proj_len():
return 0.0
return (expense_acq_rate * premiums(t) # noqa: F821
+ expense_maint / 12.0 * inflation_factor(t) * pols_if(t)) # noqa: F821
[docs]
def net_cf(t):
"""Net cash flow in month t.
The index credit, the rider charge, the surrender charge, the bonus clawback and the
movement of the Model #805 floor are **internal accounting entries**: they drive the
account value, the benefit base and the benefit *amount* but are never ledger lines
of their own. Only amounts paid to or received from the contract holder, and the
insurer's own expenses, appear here.
"""
return (premiums(t) - withdrawals(t) - claims(t)
- commissions(t) - expenses(t) - premium_taxes(t))
[docs]
def av_at(t, timing):
"""The in-force weighted account value in month t; see :func:`av_pp_at`.
Every timing inside the month carries ``pols_if(t)`` — the count entering it — because
all processing happens before the decrements. ``"EOY"`` carries the count *leaving*
the month, ``pols_if(t + 1)``: zero at the horizon, where :func:`pols_maturity` has
taken the survivors out.
"""
if t < entry_mth():
return 0.0
if timing in ("BEF_PREM", "BEF_INV", "BEF_FEE", "BEF_WD"):
return av_pp_at(t, timing) * pols_if(t)
if timing == "EOY":
return av_pp(t) * pols_if(t + 1)
raise ValueError("invalid timing")
[docs]
def inv_income(t):
"""Interest credited to the whole block in month t."""
return 0.0 if t < entry_mth() else inv_income_pp(t) * pols_if(t)
[docs]
def rider_charges(t):
"""Rider charges deducted from the whole block's account value in month t."""
return 0.0 if t < entry_mth() else rider_charge_pp(t) * pols_if(t)
[docs]
def wd_from_av(t):
"""The account value released by month t's withdrawals, gross of charge and MVA."""
return 0.0 if t < entry_mth() else wd_pp(t) * pols_if(t)
[docs]
def av_depletion(t):
"""The block's withdrawal outgo the account value could not fund in month t."""
return 0.0 if t < entry_mth() else av_depletion_pp(t) * pols_if(t)
[docs]
def av_change(t):
"""The change in the block's account value over month t.
Closing less **opening**, the opening being read at ``"BEF_PREM"`` so that the single
premium of a new issue's month 0 is a movement rather than part of the balance brought
in. Away from the first month ``av_at(t, "BEF_PREM")`` is exactly the previous
month's closing value.
"""
return av_at(t, "EOY") - av_at(t, "BEF_PREM")
[docs]
def check_av_roll_fwd_resid(t):
"""Account value roll-forward residual for month t; the signed float.
``AV(t) - AV(0)(t) = premium in + interest credited - rider charges - withdrawals
+ the part of a withdrawal the account value could not fund - the account value
released by each claim kind``. The cash *paid* on a surrender may differ from the
account value released — by the charge, the clawback, the MVA and any binding
Model #805 floor — and that difference is :func:`claims_over_av`, deliberately not
part of this identity. It closes month by month on the finer grid, where the interest
term is the month's accrual and the claim terms are that month's exits.
The per-``t`` residual is kept because it is what a debugging session wants when
:func:`check_av_roll_fwd` returns ``False``; the boolean is defined in terms of it.
"""
if t < entry_mth():
return 0.0
expected = (prem_to_av(t) + inv_income(t) - rider_charges(t) - wd_from_av(t)
+ av_depletion(t)
- claims_from_av(t, "DEATH") - claims_from_av(t, "LAPSE")
- claims_from_av(t, "MATURITY"))
return av_change(t) - expected
[docs]
def check_av_roll_fwd():
"""Check the account value roll-forward: ``True`` when it closes at every projected t.
No argument and a bool, following ``savings.CashValue_SE`` and the rest of this
library, so one test can call the same check across every model. The signed residual
for a single month is :func:`check_av_roll_fwd_resid`.
The tolerance is absolute and loose enough for an account value of order $100,000
accumulated over a century of months; it is not a modelling assumption.
"""
res = []
for t in range(entry_mth(), proj_len()):
res.append(math.isclose(check_av_roll_fwd_resid(t), 0.0, # noqa: F821
abs_tol=1e-6))
return all(res)
[docs]
def check_pols_roll_fwd_resid(t):
"""In-force roll-forward residual for month t; the signed float.
``pols_if(t) - pols_if(t+1) = deaths + surrenders + horizon exits``. Zero to floating
point for every ``t``, including the last, ``t = proj_len() - 1``, where
``pols_if(proj_len())`` is zero and :func:`pols_maturity` carries the survivors out.
"""
if t < entry_mth():
return 0.0
return (pols_if(t) - pols_if(t + 1) - pols_death(t)
- pols_lapse(t) - pols_maturity(t))
[docs]
def check_pols_roll_fwd():
"""Check the in-force roll-forward: ``True`` when it closes at every projected t.
No argument and a bool, as for :func:`check_av_roll_fwd`; the signed residual for a
single month is :func:`check_pols_roll_fwd_resid`.
"""
res = []
for t in range(entry_mth(), proj_len()):
res.append(math.isclose(check_pols_roll_fwd_resid(t), 0.0, # noqa: F821
abs_tol=1e-12))
return all(res)
[docs]
def result_cf():
"""Result table of cashflows, indexed by the **month** ``t`` from ``entry_mth()``.
The frame runs ``t = entry_mth() … proj_len() - 1``, one row per projected month and
**no issue-instant row**: the first row of a new-issue model point carries the premium
and the acquisition expense as beginning-of-month flows alongside that month's own
activity. :func:`result_cf_annual` sums it into contract years.
``pols_if`` is the in-force count at the **start** of month ``t``, which is the weight
every cash flow on the same row carries — dividing a row's cash flow by its
per-contract amount returns this column exactly. Every withdrawal and benefit driven
by the contract falls in an anniversary month, so those columns are zero in eleven
rows out of twelve; deaths, surrenders and maintenance expense fall in every row.
``withdrawals`` is the library-wide column for partial withdrawal payments and is the
total; the three columns after it — ``wd_guar``, ``wd_excess`` and
``income_payments`` — partition that total into the notes' own ledger categories.
**They are published alongside it, not instead of it, so summing every column of the
frame double-counts the withdrawal.** ``net_cf`` is income-positive, as everywhere
in this library.
"""
ts = list(range(entry_mth(), proj_len()))
return pd.DataFrame( # noqa: F821
{
"pols_if": [pols_if(t) for t in ts],
"premiums": [premiums(t) for t in ts],
"withdrawals": [withdrawals(t) for t in ts],
"wd_guar": [wd_guar(t) for t in ts],
"wd_excess": [wd_excess(t) for t in ts],
"income_payments": [income_payments(t) for t in ts],
"claims_death": [claims(t, "DEATH") for t in ts],
"claims_lapse": [claims(t, "LAPSE") for t in ts],
"claims_maturity": [claims(t, "MATURITY") for t in ts],
"commissions": [commissions(t) for t in ts],
"expenses": [expenses(t) for t in ts],
"premium_taxes": [premium_taxes(t) for t in ts],
"net_cf": [net_cf(t) for t in ts],
},
index=pd.Index(ts, name="t"), # noqa: F821
)
[docs]
def result_pols():
"""Result table of in-force movements, indexed by the month t.
``pols_if`` opens the row and ``pols_if_aft_decr`` closes it, and the two decrement
columns are the **monthly** rates actually applied; the annual rates they are converted
from are :func:`mort_rate` and :func:`lapse_rate`, constant across each contract year.
The closing column is the opening figure of the next row in every month but the last,
where :func:`pols_maturity` takes the survivors out and the projection stops. The
identity ``check_pols_roll_fwd_resid`` asserts is therefore
``pols_if(t) - pols_if(t+1) = pols_death + pols_lapse + pols_maturity``.
"""
ts = list(range(entry_mth(), proj_len()))
return pd.DataFrame( # noqa: F821
{
"pols_if": [pols_if(t) for t in ts],
"mort_rate": [mort_rate(t) for t in ts],
"mort_rate_mth": [mort_rate_mth(t) for t in ts],
"lapse_rate": [lapse_rate(t) for t in ts],
"lapse_rate_mth": [lapse_rate_mth(t) for t in ts],
"pols_death": [pols_death(t) for t in ts],
"pols_lapse": [pols_lapse(t) for t in ts],
"pols_maturity": [pols_maturity(t) for t in ts],
"pols_if_aft_decr": [pols_if_at(t, "AFT_DECR") for t in ts],
},
index=pd.Index(ts, name="t"), # noqa: F821
)
[docs]
def result_av():
"""Result table of the per-contract account value and surrender trace, indexed by month.
The first eight columns walk the worked example's account value rows, now month by
month: ``fixed_interest_pp`` accrues in every row, ``index_credit_pp``,
``rider_charge_pp`` and ``wd_pp`` only in anniversary rows. The rest are the surrender
trace, available in **every month** rather than only at the anniversaries the notes
tabulate — which is the reading a mid-year surrender needs.
"""
ts = list(range(entry_mth(), proj_len()))
return pd.DataFrame( # noqa: F821
{
"av_pp_bef_inv": [av_pp_at(t, "BEF_INV") for t in ts],
"credit_rate": [credit_rate(t) for t in ts],
"index_credit_pp": [index_credit_pp(t) for t in ts],
"fixed_interest_pp": [fixed_interest_pp(t) for t in ts],
"rider_charge_pp": [rider_charge_pp(t) for t in ts],
"av_pp_bef_wd": [av_pp_at(t, "BEF_WD") for t in ts],
"wd_pp": [wd_pp(t) for t in ts],
"av_pp": [av_pp(t) for t in ts],
"mgsv_pp": [mgsv_pp(t) for t in ts],
"surr_charge_pp": [surr_charge_pp(t) for t in ts],
"surr_clawback_pp": [surr_clawback_pp(t) for t in ts],
"mva_pp": [mva_pp(t) for t in ts],
"surr_benefit_pp": [surr_benefit_pp(t) for t in ts],
},
index=pd.Index(ts, name="t"), # noqa: F821
)
[docs]
def result_glwb():
"""Result table of the GLWB rider state per contract, indexed by the month t.
The benefit base is **notional** — no cash value, not withdrawable, not payable as a
lump sum [S1][S9] — so none of these columns is a cash flow. They are the drivers of
the rider charge, the lifetime withdrawal and, in the end, the post-depletion income.
Every one of them is annual: the rider state is flat between anniversaries and steps at
each one, so the rows of interest are the anniversary rows ``t = 12k + 11``.
"""
ts = list(range(entry_mth(), proj_len()))
return pd.DataFrame( # noqa: F821
{
"phase": [phase(t) for t in ts],
"rollup_pp": [rollup_pp(t) for t in ts],
"stack_pp": [stack_pp(t) for t in ts],
"bb_pp_bef_step_up": [benefit_base_pp_at(t, "BEF_STEP_UP") for t in ts],
"benefit_base_pp": [benefit_base_pp(t) for t in ts],
"rollup_base_pp": [rollup_base_pp(t) for t in ts],
"lw_pp": [lw_pp(t) for t in ts],
"free_wd_allow": [free_wd_allow(t) for t in ts],
"wd_guar_pp": [wd_guar_pp(t) for t in ts],
"wd_excess_pp": [wd_excess_pp(t) for t in ts],
"wd_unfunded_pp": [wd_unfunded_pp(t) for t in ts],
"wd_reduction_rate": [wd_reduction_rate(t) for t in ts],
},
index=pd.Index(ts, name="t"), # noqa: F821
)
[docs]
def result_cf_annual():
""":func:`result_cf` summed into contract years, indexed by ``policy_year``.
Every cash flow column is the total of its twelve months; ``pols_if`` is the count at
the **start** of the contract year, ``pols_if(12 * duration)``, which is the number the
annual-step model carried on the same row.
Against the annual grid this agrees exactly on ``pols_if``, ``premiums``,
``commissions`` and ``premium_taxes``, the last three being the single premium and two
rates on it. It does **not** agree on the withdrawal columns, the claims or the
expenses, and so not on ``net_cf``: a withdrawal taken at the anniversary is now
weighted by the contracts that **reach** it rather than by those that entered the year,
claims fall in the month of exit, and maintenance expense accrues monthly. That gap is
the point of the finer grid — see the Space docstring.
"""
df = result_cf()
years = pd.Index([duration(t) + 1 for t in df.index], # noqa: F821
name="policy_year")
out = df.drop(columns="pols_if").groupby(years).sum()
out.insert(0, "pols_if", df["pols_if"].groupby(years).first())
return out
# ---------------------------------------------------------------------------
# References
data = ("Interface", ("..", "Data"), "auto")
point_id = 1
maturity_age = 120
net_consideration_ratio = 0.875
floor_rate = 0.0
cap_rate_min = 0.0025
fixed_rate = 0.023
fixed_rate_min = 0.01
par_rate = 0.8
spread_rate = 0.02
trigger_rate = 0.045
index_cost_rate = 0.0
use_guaranteed_scale = False
rider_charge_rate = 0.0095
rider_charge_rate_max = 0.015
rider_charge_from_mgsv = False
growth_period_max = 20
step_up_mode = "annual"
rb_wd_convention = "pro_rata"
mva_collar_basis = "gross"
min_income_age = 50
free_wd_rate = 0.1
surr_charge_period = 10
mgsv_rate = 0.01
mgsv_annual_charge = 0.0
mgsv_stat_step = 0.0005
mgsv_stat_spread = 0.0125
mgsv_stat_cap = 0.03
mgsv_stat_floor = 0.0015
mgsv_stat_delta_max = 0.01
mgsv_stat_delta_gate = 0.0025
mort_ae_factor = 1.0
lapse_base_early = 0.02
lapse_base_mid = 0.03
lapse_base_late = 0.04
lapse_base_final = 0.05
lapse_base_ult = 0.06
shock_lapse_no_rider = 0.33
shock_lapse_rider_idle = 0.1
shock_lapse_rider_active = 0.05
lapse_money_slope = 0.6
lapse_money_floor = 0.2
lapse_rate_max = 0.35
rmd_age = 73
activation_age_low = 60
activation_rate_early = 0.05
activation_rate_rmd = 0.4
activation_rate_late = 0.15
comm_rate_acq = 0.0
expense_acq_rate = 0.06
expense_maint = 80.0
inflation_rate = 0.025
premium_tax_rate = 0.0
math = ("Module", "math")
pd = ("Module", "pandas")