# modelx: pseudo-python
# This file is part of a modelx model.
# It can be imported as a Python module, but functions defined herein
# are model formulas and may not be executable as standard Python.
"""The by-policy monthly projection of the :mod:`~.UL_US_S` model.
The Space is parameterized by ``point_id``, so ``Projection[1]`` is an ItemSpace
projecting model point 1::
>>> Projection[1].result_av() # the worked-example anchor cell
>>> 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/universal_life/``, 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
``UL_US_S`` folder without its parent's CSVs produces a model that reads and
then fails on first evaluation.
The readers live in the unparameterized :mod:`~.UL_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
coi_rates_file data.coi_rates() coi_rates.csv
corridor_file data.corridor_factors() corridor_factors.csv
mort_table_file data.mort_table() mort_table.csv
class_factor_file data.class_factor_table() class_factor_table.csv
lapse_table_file data.lapse_table() lapse_table.csv
prem_persistency_file data.prem_persistency_table() prem_persistency.csv
surr_charge_file data.surr_charge_table() surr_charge_table.csv
====================== ================================ ==========================
.. rubric:: Projection basis
``t`` counts **policy months**, 1-based: ``t = 1`` is the issue month of a new-business
model point, and for an in-force point it is the first projected month, sitting
``duration_mth_init()`` completed months after issue. State variables the notes define
at ``t = 0`` -- ``AV(0)``, ``L(0)``, ``F(0)``, ``l(0) = 1`` -- are the ``t == 0``
branch of the corresponding recursion.
Within each month the notes' monthiversary order is followed exactly:
1. amortize the surrender charge (:func:`surr_charge_rate`);
2. gross premium and its load, net premium to the account value
(:func:`premium_pp`, :func:`prem_to_av_pp`);
3. withdrawal and withdrawal fee, and under Option A the face reduction they force
(:func:`wd_pp`, :func:`wd_fee_pp`, :func:`sum_assured_at`) -- after which the
account value is the notes' ``AV'(t)``, :func:`av_pp_at(t, "BEF_FEE")<av_pp_at>`;
4. death benefit and the GPT corridor test (:func:`db_pp`);
5. net amount at risk, the death benefit discounted one month at the **guaranteed**
rate less the account value measured **before** the deduction
(:func:`net_amt_at_risk`);
6. the monthly deduction (:func:`mth_deduction_pp`);
7. the shortfall test (:func:`is_shortfall`);
8. end of month: one month's interest on the post-deduction balance
(:func:`inv_income_pp`) and loan interest accrual (:func:`loan_bal_pp`);
9. end of month: decrements, death before lapse (:func:`pols_death`,
:func:`pols_lapse`).
Cash flows are **undiscounted**. Premiums, expenses and premium taxes fall at BOM and
are weighted by ``pols_if(t)``; death claims by ``pols_if(t) * mort_rate_mth(t)``;
surrender payments by ``pols_if(t) * (1 - mort_rate_mth(t)) * lapse_rate_mth(t)``.
Partial withdrawals also fall at BOM and are weighted by ``pols_if(t)``, but they are
:func:`withdrawals`, a cash flow line of their own rather than a ``kind`` of
:func:`claims`.
.. rubric:: Naming
Cells names follow lifelib's ``basiclife.BasicTerm_S`` and ``savings.CashValue_SE``
wherever those models have an analogue -- ``pols_*`` for policy counts, ``av_*`` for
account values, plural nouns for cash flows, ``*_rate`` for rates, ``*_pp`` for
per-policy amounts, ``timing`` and ``kind`` string arguments. The technical notes use
compact actuarial symbols instead. The mapping is:
========================= ============================== ==========================
Notes symbol Cells Meaning
========================= ============================== ==========================
t (the ``t`` argument) Policy month, 1-based
y policy_year(t) Policy year, ceil(t/12)
(t - 1 in months) duration_mth(t) Completed policy months
(y - 1) duration(t) Completed policy years
x / issue_age age_at_entry Issue age (ANB)
x + y - 1 age(t) Attained age (ANB)
(none) proj_len Last projected month
sex sex Sex, M or F
risk_class rate_class Underwriting class, one of six
F / face_amount sum_assured Initial total face amount
F(t) sum_assured_at(t) Total face after reductions
U units(t) Face in $1,000 units
db_option db_option Death benefit option, A or B
qual_test qual_test 7702 test elected; GPT only
premium_pattern premium_type LEVEL / SINGLE / TARGET
premium_mode (not modelled) Monthly premiums only **[std]**
planned_premium_annual premium_pp_ann Planned annual premium
av_initial / AV(0) av_pp_init Opening account value
loan_balance_initial loan_bal_init Opening loan balance, L(0)
policy_month_offset duration_mth_init Completed months at t = 1
sc_layer_table surr_charge_id Surrender charge schedule ID
(schedule applies) has_surr_charge Whether a schedule applies
guideline_single_premium gsp GSP in the GPT limit
guideline_level_premium glp GLP in the GPT limit
seven_pay_premium seven_pay_prem 7-pay premium, an input
GP(t) premium_pp(t) Gross premium per policy
pp(y) prem_persistency(t) Paid/planned factor
pl load_prem_rate Current premium load rate
NP(t) prem_to_av_pp(t) Net premium to account value
W(t) wd_pp(t) Partial withdrawal
wf wd_fee_pp(t) Withdrawal fee ($25)
(free amount) wd_free_pp(t) Free allowance still available
wd_used_year wd_used_year(t) Free allowance used this year
(face cut) face_reduction_pp(t) Option A face reduction
AV(t) av_pp(t) Account value, end of month
AV(t-1) av_pp_at(t, "BEF_PREM") Account value, start of month
(after premium) av_pp_at(t, "BEF_WD") Before the withdrawal
AV'(t) av_pp_at(t, "BEF_FEE") After premium and withdrawal
(AV'(t) floored at 0) av_pp_db_basis(t) The AV' that DB and NAAR use
AV'(t) - MD(t) av_pp_at(t, "BEF_INV") After the monthly deduction
(aggregate AV) av_at(t, timing) Account value in force
(none) av_change(t) Change in account value
e_pol expense_pol_mth Per-policy charge
e_unit(y) expense_unit_mth_1/_2 Per-unit charge
rc(t) rider_charge_pp(t) Rider charges (0)
(e_pol + e_unit U + rc) maint_fee_pp(t) Non-COI monthly charges
MD(t) mth_deduction_pp(t) Monthly deduction
q_coi_guar(s) coi_rate_guar(t) Guaranteed maximum COI rate
q_coi(s) coi_rate(t) Current COI rate
(COI charge) coi_pp(t) Cost of insurance charge
NAAR(t) net_amt_at_risk(t) Net amount at risk
DB(t) db_pp(t) Death benefit after corridor
cf(a) corridor_factor(t) GPT corridor factor
(NAAR factor) naar_factor 1 + i_gm
i_guar guar_rate_ann Guaranteed annual rate
i_cr crediting_rate_ann(t) Current credited annual rate
i_m inv_return_mth(t) Monthly credited rate
i_gm guar_rate_mth Monthly guaranteed rate
(interest credited) inv_income_pp(t) Interest credited to AV
earned_rate(t) (not modelled) NGE revision input, no source
L(t) loan_bal_pp(t) Policy loan balance
r_L loan_rate_ann Charged loan rate
SC(t) surr_charge_pp(t) Surrender charge
(SC per $1,000) surr_charge_rate(t) Surrender charge rate
CSV(t) csv_pp(t) Cash surrender value
NCSV(t) ncsv_pp(t) Net cash surrender value
(SC retained) surr_charge(t) Surrender charge collected
CumPrem(t) cum_prem_pp(t) Cumulative premiums
(GPT limit) gpt_limit(t) max(GSP, GLP x years)
(GPT test) gpt_ok(t) GPT compliance flag
(7-pay limit) seven_pay_limit(t) 7-pay cumulative limit
(MEC flag) is_mec(t) 7-pay failure flag, latched
(grace trigger) is_shortfall(t) AV' - L < MD
grace_flag(t) (not modelled) In-grace state; see below
(cure payment) cure_premium_pp(t) 3 x MD plus load
q_m(t) mort_rate_mth(t) Monthly mortality rate
(annual q) mort_rate(t) Annual mortality rate
w_base(y) lapse_rate_base(t) Base annual lapse rate
M_sc lapse_rate_sc_mult(t) SC-expiry lapse shock
M_rate(t) lapse_rate_dyn_mult(t) Dynamic lapse multiplier
r_comp(t) comp_rate_ann(t) Competitor new-money rate
w_annual(y,t) lapse_rate(t) Total annual lapse rate
w_m(t) lapse_rate_mth(t) Monthly lapse rate
l(t-1) pols_if(t) In force at start of month t
(l(0)) pols_if_init In force at outset
(deaths) pols_death(t) Deaths in month t
(lapses) pols_lapse(t) Lapses in month t
(none) pols_maturity(t) Maturities: always zero
(premium income) premiums(t) Premium income
(death claims) claims(t, "DEATH") Death claims
(surrender outgo) claims(t, "LAPSE") Surrender payments
(withdrawal outgo) withdrawals(t) Withdrawal payments
(maintenance) expenses(t) Maintenance expenses
(percent of premium) premium_taxes(t) Premium tax / %-of-premium
NetCF(t) net_cf(t) Net liability cash flow
========================= ============================== ==========================
The table covers every symbol the notes define: the Model point attributes table, the
State variables table and the notation list, in that order, followed by the cash flow
rows. Three symbols have **no cells at all**, and are carried in the table as ``(not
modelled)`` so that the absence is recorded rather than silent: ``grace_flag(t)``, the
one of the notes' ten state variables with no counterpart here, because the grace
cascade is a diagnostic and no in-grace state is held (:func:`is_shortfall`);
``premium_mode``, because every premium in this model is monthly **[std]**; and
``earned_rate(t)``, the input the notes' optional NGE revision rule would need
(:func:`crediting_rate_ann`).
Eight names needed care.
The notes' ``risk_class`` is :func:`rate_class` here, and the model point table column
is ``rate_class`` too. The name is taken from ``Term_US_A``/``BasicTerm_S``, which
this library follows ahead of the notes wherever the two collide; ``rate_class`` also
avoids reading as Python's ``class``. The six classes themselves are the product
spec's, unchanged.
``l(t)`` in the notes is the in-force probability at the **end** of month ``t``, while
``pols_if(t)`` follows ``BasicTerm_S`` and is the number in force at the **start** of
month ``t``; so ``pols_if(t) = l(t-1)``, and ``pols_if(1) = l(0) = pols_if_init()``.
Every BOM cash flow is weighted by ``pols_if(t)``, which is the notes' own
``l(t-1)`` weighting.
The notes write the monthly deduction as ``MD(t)`` and split it into a per-policy
charge, a per-unit charge, rider charges and the COI charge. ``CashValue_SE`` calls the
non-COI part of an account-value deduction ``maint_fee``; that name is kept here for
:func:`maint_fee_pp`, and :func:`mth_deduction_pp` is the notes' ``MD(t)`` in full.
:func:`expenses` is something different -- the insurer's own **[std]** maintenance
expense of $75 per policy per year, a cash flow, not a charge against the account
value. The two must not be confused: ``maint_fee`` is income, ``expenses`` is outgo.
The notes' ``t`` in the surrender-charge formula ``SC(t) = max(0, (9.00 - t/12) x U)``
counts the current month, so it equals ``duration_mth(t) + 1``, not ``duration_mth(t)``.
:func:`surr_charge_rate` says so explicitly; getting it wrong shifts the whole run-off
by a month.
``q_coi`` is quoted per $1,000 of net amount at risk **per month** and ``e_unit`` per
$1,000 of face per month, so both are divided by 1,000 -- or multiplied by
:func:`units` -- before they meet a currency amount. :func:`coi_rate` is therefore not
comparable with ``CashValue_SE.coi_rate``, which is a rate per unit of account value.
``surr_charge_pp(t)`` is the surrender charge *scheduled* at month ``t``;
``surr_charge(t)`` is the amount actually *collected* from lapsing policies, which is
capped by the account value because :func:`csv_pp` is floored at zero. In the first
policy years the schedule exceeds the account value and the two differ by a lot.
The notes' cash flow table lists "Withdrawal outgo" beside "Death claims" and
"Surrender outgo", but a partial withdrawal is a payment made on the **owner's
election**, not on a contingency, so it is not a claim here: it is :func:`withdrawals`,
its own cash flow line and its own ``result_cf()`` column, and ``"WITHDRAWAL"`` is not
a ``kind`` of :func:`claims` or of :func:`claim_pp`. The consequence to hold on to is
that ``claims(t)`` with no ``kind`` is the death and surrender total *only*, so
:func:`net_cf` subtracts :func:`withdrawals` as a separate term. The per-policy amount
stays :func:`wd_pp`, which is the notes' ``W(t)``.
Finally, the notes write ``AV'(t)`` for one quantity and use it for two: the balance
the monthly deduction comes out of, and the balance the death benefit and the net
amount at risk are measured against. The two coincide until a policy in permanent
shortfall drives the account value negative -- which this model does not prevent,
because it does not terminate such a policy (:func:`is_shortfall`).
:func:`av_pp_at(t, "BEF_FEE")<av_pp_at>` is the first and stays signed;
:func:`av_pp_db_basis` is the second and is floored at zero **[std]**, so an Option B
death benefit can never fall below the face amount.
"""
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 age_at_entry():
"""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."""
return model_point()["sex"]
[docs]
def rate_class():
"""The underwriting class of the selected model point (one of six)."""
return model_point()["rate_class"]
[docs]
def sum_assured():
"""The initial total face amount F of the selected model point."""
return float(model_point()["sum_assured"])
[docs]
def db_option():
"""The death benefit option: ``"A"`` (level) or ``"B"`` (face plus account value).
Option C (return of premium) is out of scope in the product spec.
"""
return model_point()["db_option"]
[docs]
def qual_test():
"""The IRC 7702 qualification test elected at issue; only ``"GPT"`` is modeled.
CVAT is out of scope in the product spec, so :func:`corridor_factor` raises on
anything else rather than silently applying GPT corridor factors.
"""
return model_point()["qual_test"]
[docs]
def premium_type():
"""The premium pattern: ``"LEVEL"``, ``"SINGLE"`` or ``"TARGET"`` **[std]**."""
return model_point()["premium_type"]
[docs]
def premium_pp_ann():
"""The planned annual premium per policy; a billing target only [S3]."""
return float(model_point()["premium_pp_ann"])
[docs]
def load_prem_rate():
"""The current premium load rate, 6% **[std]** (guaranteed maximum 9%) [S1].
The load is itself a non-guaranteed element in the source design, which is why it
sits in the model point table rather than in a Reference.
"""
return float(model_point()["load_prem_rate"])
[docs]
def av_pp_init():
"""AV(0): the account value per policy at the outset, 0 at issue."""
return float(model_point()["av_pp_init"])
[docs]
def loan_bal_init():
"""L(0): the policy loan balance per policy at the outset, 0 at issue."""
return float(model_point()["loan_bal_init"])
[docs]
def pols_if_init():
"""l(0): the in-force probability at the outset, 1 for a single-policy point."""
return float(model_point()["pols_if_init"])
[docs]
def duration_mth_init():
"""Completed policy months already elapsed when the projection starts.
0 for a new-business model point, so that ``t = 1`` is the issue month; positive
for an in-force cell. This is the notes' ``policy_month_offset``.
"""
return int(model_point()["duration_mth"])
[docs]
def has_surr_charge():
"""Whether a surrender charge schedule applies to this model point."""
return bool(model_point()["has_surr_charge"])
[docs]
def surr_charge_id():
"""The surrender charge schedule ID, a row label of *surr_charge_table.csv*."""
return model_point()["surr_charge_id"]
[docs]
def gsp():
"""The guideline single premium (GPT compliance input) [S3]."""
return float(model_point()["gsp"])
[docs]
def glp():
"""The guideline level premium (GPT compliance input) [S3]."""
return float(model_point()["glp"])
[docs]
def seven_pay_prem():
"""The 7-pay premium (IRC 7702A compliance input) [S3]."""
return float(model_point()["seven_pay_prem"])
[docs]
def duration_mth(t):
"""Completed policy months at the beginning of policy month t.
``duration_mth_init() + t - 1``, so it is 0 in the issue month of a new-business
model point. Note the contrast with the notes' own ``t``, which counts the
current month as well; see :func:`surr_charge_rate`.
"""
return duration_mth_init() + t - 1
[docs]
def duration(t):
"""Completed policy years at the beginning of policy month t."""
return duration_mth(t) // 12
[docs]
def policy_year(t):
"""y: the policy year containing policy month t, 1-based."""
return duration(t) + 1
[docs]
def age(t):
"""The attained age (ANB) in policy month t: ``age_at_entry() + duration(t)``.
This is the notes' ``x + y - 1``. Age changes on the policy anniversary, not on
the birthday, which is the ANB convention the whole model is built on **[std]**.
"""
return age_at_entry() + duration(t)
[docs]
def proj_len():
"""Projection length in policy months.
``12 * (omega_age - age_at_entry() + 1) - duration_mth_init()``: the projection
runs through the policy year in which the insured attains ``omega_age`` (120), the
last age of *mort_table.csv*, where the annual rate is 1.0. The contract has no
maturity date [S2][S3]; the projection is truncated by mortality, not by the
policy.
"""
return 12 * (omega_age - age_at_entry() + 1) - duration_mth_init() # noqa: F821
[docs]
def crediting_rate_ann(t):
"""i_cr: the current declared annual effective credited rate, 4.00% **[std]**.
A non-guaranteed element under ASOP 2 [R8]. The base deterministic run holds the
snapshot scale level, as the notes prescribe. The notes' optional revision rule
``i_cr(t) = max(i_guar, earned_rate(t) - spread)`` needs an earned-rate input the
notes do not supply and is not implemented.
"""
return crediting_rate_curr # noqa: F821
[docs]
def inv_return_mth(t):
"""i_m: the monthly credited rate, ``(1 + i_cr)^(1/12) - 1``.
0.0032737 at the **[std]** 4.00% current rate. The contract credits daily on a
365-day year [S3]; monthly compounding is the model's discretization **[std]** --
do not also compound daily.
"""
return (1 + crediting_rate_ann(t)) ** (1 / 12) - 1
[docs]
def guar_rate_mth():
"""i_gm: the monthly guaranteed rate, ``(1 + i_guar)^(1/12) - 1`` = 0.0016516."""
return (1 + guar_rate_ann) ** (1 / 12) - 1 # noqa: F821
[docs]
def naar_factor():
"""The specimen's NAAR factor, ``1 + i_gm`` = 1.0016516 **[std]** at 2% [S3].
The death benefit is discounted one month at the **guaranteed** rate, never the
credited rate; the specimen prints 1.0024663 = 1.03^(1/12) at its own 3%
guarantee [S3].
"""
return 1 + guar_rate_mth()
[docs]
def loan_rate_mth():
"""The monthly charged loan rate, ``(1 + r_L)^(1/12) - 1`` at r_L = 2.75%.
The contract accrues daily and capitalizes annually [S3]; monthly compounding is
the model's discretization **[std]**.
"""
return (1 + loan_rate_ann) ** (1 / 12) - 1 # noqa: F821
[docs]
def prem_persistency(t):
"""pp(y): the fraction of the planned premium actually paid in policy year y **[std]**.
100% in year 1, declining 2 percentage points a year to a 70% floor from year 16,
read from *prem_persistency.csv*. Policy years beyond the table take its last
row. The shape is informed qualitatively by the SOA/LIMRA UL study [R7]; the
levels are a standardization.
"""
tbl = data.prem_persistency_table() # noqa: F821
y = min(policy_year(t), int(tbl.index.max()))
return float(tbl.loc[y, "prem_persistency"])
[docs]
def premium_pp(t):
"""GP(t): the gross premium per policy received at BOM of policy month t.
``LEVEL`` planned annual premium / 12, times :func:`prem_persistency`.
``SINGLE`` one premium in the issue month, capped at the guideline single premium.
``TARGET`` as ``LEVEL`` but capped so cumulative premium stays inside the GPT
limit **[std]**; the cap looks at ``cum_prem_pp(t - 1)``, so there is no
circularity.
Zero from attained age 121, when premiums are no longer accepted [S2][S3].
"""
if age(t) >= charges_cease_age: # noqa: F821
return 0.0
if premium_type() == "SINGLE":
return min(premium_pp_ann(), gsp()) if duration_mth(t) == 0 else 0.0
elif premium_type() == "LEVEL":
return premium_pp_ann() / 12 * prem_persistency(t)
elif premium_type() == "TARGET":
level = premium_pp_ann() / 12 * prem_persistency(t)
return min(level, max(0.0, gpt_limit(t) - cum_prem_pp(t - 1)))
else:
raise ValueError("invalid premium type")
[docs]
def prem_to_av_pp(t):
"""NP(t): the net premium credited to the account value, ``GP(t) x (1 - pl)``."""
return premium_pp(t) * (1 - load_prem_rate())
[docs]
def prem_to_av(t):
"""Net premium credited to account value, for the policies in force."""
return prem_to_av_pp(t) * pols_if(t)
[docs]
def premiums(t):
"""Premium income at BOM of policy month t, weighted by the in force at BOM."""
return premium_pp(t) * pols_if(t)
[docs]
def wd_pp(t):
"""W(t): the partial withdrawal per policy at BOM of policy month t.
Withdrawals are allowed on and after the first policy anniversary and not after
attained age 121 [S2][S3]. The amount is the constant monthly figure in the model
point's ``wd_pp`` column, **0 in every shipped model point**: the notes give no
withdrawal utilization pattern, so the mechanics are implemented and the behaviour
is left to the data **[std]**.
"""
if duration_mth(t) < 12 * wd_first_year: # noqa: F821
return 0.0
if age(t) >= charges_cease_age: # noqa: F821
return 0.0
return float(model_point()["wd_pp"])
[docs]
def wd_used_year(t):
"""The free-withdrawal allowance already consumed in the current policy year.
The notes' ``wd_used_year`` state variable, "free-withdrawal usage in current
policy year, updated on withdrawal": reset to zero on each policy anniversary and
increased month by month by the free part of each withdrawal taken since,
``min(W(t-1), wd_free_pp(t-1))``.
This is what makes the notes' allowance an **annual** one. Without it the 10% is
granted afresh every month, and a policyholder withdrawing 10% of the account value
every month takes twelve full annual allowances in a policy year and never triggers
the Option A face reduction of :func:`face_reduction_pp`.
Zero at ``t = 1`` for an in-force model point as well: the notes give no opening
withdrawal-usage attribute, so the first projected policy year starts unused
**[std]**.
"""
if t <= 1 or duration_mth(t) % 12 == 0:
return 0.0
return wd_used_year(t - 1) + min(wd_pp(t - 1), wd_free_pp(t - 1))
[docs]
def wd_free_pp(t):
"""The free partial withdrawal allowance still available in policy month t **[std]**.
``max(0, 10% of AV - wd_used_year(t))``: the allowance is 10% of the account value
measured after the premium and before the withdrawal, less the part of it already
taken earlier in the same policy year, and it resets on each policy anniversary --
the notes' "free-amount rule (10% of AV per policy year)" with its ``wd_used_year``
state (:func:`wd_used_year`). In the first withdrawal month of a policy year
nothing is used yet and this is the full 10%.
The specimen's carve-out is the lesser of $10,000 and 10% of net cash surrender
value, on the first withdrawal of each of the first 15 policy years [S3]; the
composite simplifies to 10% of the account value, with no dollar cap and no
15-year limit **[std]**.
"""
allowance = wd_free_rate * av_pp_at(t, "BEF_WD") # noqa: F821
return max(0.0, allowance - wd_used_year(t))
[docs]
def wd_fee_pp(t):
"""wf: the $25 withdrawal fee, charged only in a month with a withdrawal [S2][S3].
The fee is retained by the insurer, so it is account-value outgo but not a
liability cash flow; it appears in :func:`margin_expense`, not in :func:`claims`.
"""
return wd_fee if wd_pp(t) > 0 else 0.0 # noqa: F821
[docs]
def face_reduction_pp(t):
"""The total face reduction forced by a withdrawal under Option A [S3].
Under Option A a withdrawal beyond the free amount would otherwise increase the
net amount at risk, so the face is cut by the excess. The free amount is whatever
is left of the policy year's allowance (:func:`wd_free_pp`), not a fresh 10% each
month. Under Option B the withdrawal reduces the account value only, and this is
zero.
"""
if db_option() != "A" or wd_pp(t) <= 0:
return 0.0
return max(0.0, wd_pp(t) - wd_free_pp(t))
[docs]
def sum_assured_at(t):
"""F(t): the total face amount after any withdrawal-driven reductions.
``F(0) = sum_assured()``; face increases, elective decreases and option changes
are not modeled, so the only movement is the Option A withdrawal reduction.
"""
if t == 0:
return sum_assured()
return max(0.0, sum_assured_at(t - 1) - face_reduction_pp(t))
[docs]
def units(t):
"""U: the total face amount in $1,000 units, ``sum_assured_at(t) / 1000``.
The per-unit charge is quoted per $1,000 of face per month, so it is multiplied by
this. The surrender charge is quoted per $1,000 of **initial** face, because face
decreases do not reduce it [S3]; see :func:`surr_charge_pp`.
"""
return sum_assured_at(t) / 1000
[docs]
def corridor_factor(t):
"""cf(a): the GPT corridor factor at the attained age [S3][R2].
250% to age 40, grading to 101% above age 93. Ages beyond the table take its last
row. Only the Guideline Premium Test is modeled; CVAT is out of scope in the
product spec, so any other ``qual_test`` raises rather than being treated as GPT.
"""
if qual_test() != "GPT":
raise ValueError("invalid qual_test")
tbl = data.corridor_factors() # noqa: F821
a = min(max(age(t), int(tbl.index.min())), int(tbl.index.max()))
return float(tbl.loc[a, "corridor_factor"])
[docs]
def av_pp_db_basis(t):
"""AV'(t) floored at zero: the balance the death benefit is measured against.
The notes' step 4 sets the Option B death benefit to ``F + AV'(t)`` and step 5
measures the net amount at risk against the same ``AV'(t)``, both on the premise
that ``AV'`` is a real account balance. It stops being one as soon as a policy in
permanent shortfall keeps being projected: the grace and lapse-for-insufficiency
cascade is not implemented (see :func:`is_shortfall`), so nothing terminates such a
policy and its account value runs deeply negative. Fed to ``F + AV'`` unfloored
that gives an Option B death benefit **below the face amount**, and in the end a
negative one -- a death claim paid *by* the beneficiary, which no universal life
contract can produce.
Flooring the balance at zero here is **[std]**, and is the same floor
:func:`csv_pp` already carries for the same reason: a negative account balance is
an artifact of the missing termination, not a contract state. It holds the Option
B death benefit at or above the face amount, and it stops the net amount at risk
from growing with the shortfall and charging COI on a fund that is not there.
Wherever ``AV'(t) >= 0`` -- every month of model points 1 and 3, and the first 677
of point 2's 1,032 months -- this is exactly ``av_pp_at(t, "BEF_FEE")`` and changes
nothing, so the worked example is untouched.
"""
return max(0.0, av_pp_at(t, "BEF_FEE"))
[docs]
def db_pp(t):
"""DB(t): the death benefit per policy after the corridor test [S1][S3].
``max(option_db, cf(a) x AV'(t))`` where ``option_db`` is the face amount under
Option A and face plus account value under Option B, and ``AV'(t)`` is the account
value after premium and withdrawal and **before** the monthly deduction. Taking
the account value at that point is what removes the circularity the notes warn
about: under Option B the death benefit depends on the account value and the net
amount at risk depends on the death benefit, but with this BOM ordering neither
depends on the deduction.
The ``AV'(t)`` used here is :func:`av_pp_db_basis`, floored at zero **[std]**, so
the death benefit is never less than the face amount; see that cells for why the
floor is needed at all.
"""
av = av_pp_db_basis(t)
if db_option() == "A":
opt = sum_assured_at(t)
elif db_option() == "B":
opt = sum_assured_at(t) + av
else:
raise ValueError("invalid db_option")
return max(opt, corridor_factor(t) * av)
[docs]
def net_amt_at_risk(t):
"""NAAR(t): ``DB(t) / (1 + i_gm) - AV'(t)``, floored at zero [S3].
Two conventions here are the specimen's, not conveniences: the death benefit is
discounted one month at the **guaranteed** rate (:func:`naar_factor`), and the
account value is measured **before** the monthly deduction. Using the credited
rate in the discount, or the post-deduction account value, makes the COI charge
implicit and requires iteration, and produces small systematic COI errors.
The ``AV'(t)`` subtracted is :func:`av_pp_db_basis`, the same floored balance the
death benefit is measured on, so the two sides of the subtraction stay consistent.
"""
return max(0.0, db_pp(t) / naar_factor() - av_pp_db_basis(t))
[docs]
def coi_rate_scale():
"""The guaranteed maximum monthly COI scale for this model point's cell.
A Series indexed by policy year, per $1,000 of net amount at risk, sliced once
from *coi_rates.csv* for this ``sex`` / ``rate_class`` / ``age_at_entry``. The
shipped table covers the specimen anchor cell M / StdNT / 35 only; a model point
on any other cell needs the table extended first.
"""
return data.coi_rates().loc[ # noqa: F821
(sex(), rate_class(), age_at_entry())]["coi_rate_guar"]
[docs]
def coi_rate_guar(t):
"""q_coi_guar(s): the guaranteed maximum monthly COI rate per $1,000 NAAR [S3].
A 2001 CSO-era specimen table, log-linearly interpolated between the printed
anchor years **[std]**; it grades to 1000/12 at attained ages 112-120 and to zero
from attained age 121. Policy years beyond the table take its last row.
"""
scale = coi_rate_scale()
y = min(policy_year(t), int(scale.index.max()))
return float(scale[y])
[docs]
def coi_rate(t):
"""q_coi(s): the current monthly COI rate, ``coi_curr_factor`` x guaranteed **[std]**.
60% of the guaranteed maximum at every duration. Current COI scales are not
published -- only guaranteed maxima appear in the specimen [S3] -- so this factor
is a pure modeling assumption and one of the two assumptions to sensitivity-test
first.
Note the units: this is per $1,000 of net amount at risk per **month**, so it is
divided by 1,000 in :func:`coi_pp`. It is not comparable with
``CashValue_SE.coi_rate``, which is a rate per unit of account value.
"""
return coi_curr_factor * coi_rate_guar(t) # noqa: F821
[docs]
def coi_pp(t):
"""The cost of insurance charge per policy, ``q_coi(y) / 1000 x NAAR(t)`` [S3].
Zero from attained age 121, when monthly deductions cease [S2][S3].
"""
if age(t) >= charges_cease_age: # noqa: F821
return 0.0
return coi_rate(t) / 1000 * net_amt_at_risk(t)
[docs]
def coi(t):
"""Cost of insurance charges deducted from account values, for the policies in force."""
return coi_pp(t) * pols_if(t)
[docs]
def rider_charge_pp(t):
"""rc(t): rider charges, 0 in the base model **[std]**.
The notes carry this term in the monthly deduction so that rider modules can be
added without changing the recursion [S3].
"""
return 0.0
[docs]
def maint_fee_pp(t):
"""The non-COI part of the monthly deduction per policy [S3].
``e_pol + e_unit(y) x U + rc(t)``: the $7.50 per-policy administrative charge, the
per-unit coverage charge of $0.26 per $1,000 of face per month in policy years
1-10 and $0.156 thereafter, and rider charges. Zero from attained age 121, when
charges cease [S2][S3].
The name follows ``CashValue_SE.maint_fee``: this is a *charge* against the
account value and therefore insurer income. It is not :func:`expenses`, which is
the insurer's own outgo.
"""
if age(t) >= charges_cease_age: # noqa: F821
return 0.0
if policy_year(t) <= expense_unit_step_year: # noqa: F821
e_unit = expense_unit_mth_1 # noqa: F821
else:
e_unit = expense_unit_mth_2 # noqa: F821
return expense_pol_mth + e_unit * units(t) + rider_charge_pp(t) # noqa: F821
[docs]
def maint_fee(t):
"""Non-COI monthly charges deducted from account values, for the policies in force."""
return maint_fee_pp(t) * pols_if(t)
[docs]
def mth_deduction_pp(t):
"""MD(t): the monthly deduction per policy, taken at BOM [S3].
``e_pol + e_unit(y) x U + rc(t) + q_coi(y) / 1000 x NAAR(t)``, i.e.
:func:`maint_fee_pp` plus :func:`coi_pp`. The deduction at the start of month t
pays for month t's coverage: the specimen says the deduction provides coverage for
the following policy month, which with BOM indexing is the month it is taken in.
"""
return maint_fee_pp(t) + coi_pp(t)
[docs]
def mth_deduction(t):
"""Monthly deductions taken from account values, for the policies in force."""
return mth_deduction_pp(t) * pols_if(t)
[docs]
def av_pp_at(t, timing):
"""Account value per policy at an intra-month point of policy month t.
The BOM events change the balance in this order, and ``timing`` names the point
just before each of them:
``"BEF_PREM"``
Before the premium: the closing balance of the previous month, ``AV(t-1)``.
``"BEF_WD"``
After the net premium, before the withdrawal.
``"BEF_FEE"``
After the withdrawal and its fee, before the monthly deduction. This is the
notes' ``AV'(t)``, and the balance the death benefit, corridor test and net
amount at risk are all measured against.
``"BEF_INV"``
After the monthly deduction, before interest. Interest is credited on this
post-deduction balance; reversing the two overstates the account value by
about one month's interest on the deduction every month.
The end-of-month balance ``AV(t)`` is :func:`av_pp`.
"""
if timing == "BEF_PREM":
return av_pp(t - 1)
elif timing == "BEF_WD":
return av_pp_at(t, "BEF_PREM") + prem_to_av_pp(t)
elif timing == "BEF_FEE":
return av_pp_at(t, "BEF_WD") - wd_pp(t) - wd_fee_pp(t)
elif timing == "BEF_INV":
return av_pp_at(t, "BEF_FEE") - mth_deduction_pp(t)
else:
raise ValueError("invalid timing")
[docs]
def inv_income_pp(t):
"""Interest credited to the account value per policy at EOM of policy month t.
The unloaned part of the post-deduction balance earns the current monthly rate and
the loaned part earns the guaranteed monthly rate [S2][S3]::
(AV'(t) - MD(t) - L(t-1)) x i_m + L(t-1) x i_gm
which is the notes' step 8 rearranged. The loaned portion is taken as the opening
loan balance exactly, as the notes write it, without capping it at the account
value.
"""
loaned = loan_bal_pp(t - 1)
unloaned = av_pp_at(t, "BEF_INV") - loaned
return unloaned * inv_return_mth(t) + loaned * guar_rate_mth()
[docs]
def av_pp(t):
"""AV(t): the account value per policy at the end of policy month t.
``AV(0) = av_pp_init()``; thereafter the post-deduction balance plus one month's
interest. With no loans and no withdrawals this collapses to the notes' core
recursion ``AV(t) = [AV(t-1) + NP(t) - MD(t)] x (1 + i_m)``, which reproduces the
contractual policy-date rule that the account value equals the net premium less
the first monthly deduction [S3].
"""
if t == 0:
return av_pp_init()
return av_pp_at(t, "BEF_INV") + inv_income_pp(t)
[docs]
def loan_bal_pp(t):
"""L(t): the policy loan balance per policy at the end of policy month t.
``L(0) = loan_bal_init()``; thereafter ``L(t-1) x (1 + r_L)^(1/12)``. Interest
accrues daily and is capitalized annually under the contract [S3]; monthly
compounding is the model's discretization **[std]**. New loans and repayments are
not modeled -- the notes give no utilization pattern -- so this only rolls the
model point's opening balance forward.
"""
if t == 0:
return loan_bal_init()
return loan_bal_pp(t - 1) * (1 + loan_rate_mth())
[docs]
def av_at(t, timing):
"""Account value in force at an intra-month point of policy month t.
:func:`av_pp_at` times the number of policies in force, which is constant through
the month because decrements are end-of-month events. ``timing`` takes the same
values as :func:`av_pp_at`, plus ``"EOM"`` for the closing balance before
decrements.
"""
if timing == "EOM":
return av_pp(t) * pols_if(t)
return av_pp_at(t, timing) * pols_if(t)
[docs]
def inv_income(t):
"""Interest credited to account values, for the policies in force.
Decrements fall after the credit, so every policy in force at BOM earns a full
month's interest.
"""
return inv_income_pp(t) * pols_if(t)
[docs]
def av_change(t):
"""Change in the account value in force over policy month t.
``av_at(t + 1, "BEF_PREM") - av_at(t, "BEF_PREM")``, following ``CashValue_SE``.
"""
return av_at(t + 1, "BEF_PREM") - av_at(t, "BEF_PREM")
[docs]
def surr_charge_rate(t):
"""SC per $1,000 of initial face in policy month t **[std amount; mechanics [S3]]**.
``max(0, sc_init - (sc_init / runoff_years) x m / 12)`` where ``m`` is the notes'
own month index ``duration_mth(t) + 1`` -- the current month counts. With the
shipped schedule of $9.00 per $1,000 running off over 9 years this is the notes'
``max(0, 9.00 - t/12)``: $8.916667 in the issue month, zero from the last month of
policy year 9 onward. Reading the notes' ``t`` as ``duration_mth(t)`` instead
would shift the entire run-off by a month.
"""
if not has_surr_charge():
return 0.0
row = data.surr_charge_table().loc[surr_charge_id()] # noqa: F821
init = float(row["sc_per_1000_init"])
yrs = float(row["runoff_years"])
m = duration_mth(t) + 1
return max(0.0, init - (init / yrs) * (m / 12))
[docs]
def surr_charge_pp(t):
"""SC(t): the surrender charge scheduled per policy in policy month t.
Quoted on the **initial** face amount, because face decreases do not reduce the
surrender charge [S3]. This is the schedule, not the amount collected: see
:func:`surr_charge`.
"""
return surr_charge_rate(t) * sum_assured() / 1000
[docs]
def csv_pp(t):
"""CSV(t): the cash surrender value per policy, ``AV(t) - SC(t)``, floored at zero.
The floor is **[std]**: the notes write ``CSV = AV - SC`` without one, but a
negative cash surrender value would be a payment *from* the policyholder. In the
early policy years the scheduled surrender charge exceeds the account value and
the floor binds, so the charge actually collected is the whole account value.
"""
return max(0.0, av_pp(t) - surr_charge_pp(t))
[docs]
def ncsv_pp(t):
"""NCSV(t): the net cash surrender value, ``CSV(t) - L(t)``, floored at zero.
This is what a surrendering policyholder is paid, and the notes' surrender outgo.
"""
return max(0.0, csv_pp(t) - loan_bal_pp(t))
[docs]
def surr_charge(t):
"""Surrender charge actually collected from the policies lapsing in month t.
``(AV(t) - CSV(t)) x pols_lapse(t)``, so it is capped by the account value where
the :func:`csv_pp` floor binds. Insurer income, and part of
:func:`margin_expense`.
"""
return (av_pp(t) - csv_pp(t)) * pols_lapse(t)
[docs]
def is_shortfall(t):
"""The grace trigger: ``AV'(t) - L(t-1) < MD(t)`` on a monthiversary [S2][S3].
A diagnostic. The full grace and lapse-for-insufficiency cascade is **not
implemented**: the notes leave the in-grace account value treatment (deductions
accrue as due and unpaid) and the cash flow of a cure payment undetermined, so no
policy is terminated for insufficiency here.
It is not inert. Of the three shipped model points:
* point 1, the worked-example anchor, is never in shortfall in any of its 1,032
months -- ``$150`` a month comfortably covers the ``$39.54`` month-1 deduction,
and the account value grows from there;
* point 3, the in-force cell, is never in shortfall in any of its 912 months;
* point 2, the Option B cell, **is** in shortfall from month 677 (policy year 57,
attained age 91) to the end of the projection, 356 months. Level $150 premiums
stop covering a COI charge on a ~$100,000 net amount at risk at those ages.
Because nothing terminates point 2, its deductions keep coming out of an account
value that is already empty and it ends the projection about $1.84m overdrawn.
That is an artifact of the missing cascade, and it is why the death benefit is
measured on the floored :func:`av_pp_db_basis` rather than on a signed ``AV'``.
Treat cash flows for a model point in shortfall as **not meaningful** past the
trigger month; the README says so too.
"""
return av_pp_at(t, "BEF_FEE") - loan_bal_pp(t - 1) < mth_deduction_pp(t)
[docs]
def cure_premium_pp(t):
"""The payment required to cure a grace: ``3 x MD(t)`` grossed up for the load [S3].
The specimen requires at least three times the monthly deduction due plus the
premium load, so the gross payment is ``3 x MD / (1 - pl)``. A diagnostic; see
:func:`is_shortfall`.
"""
return 3 * mth_deduction_pp(t) / (1 - load_prem_rate())
[docs]
def cum_prem_pp(t):
"""CumPrem(t): cumulative premiums less withdrawals, for the GPT and 7-pay tests.
``CumPrem(0) = 0`` even for an in-force model point, because the notes give no
opening cumulative-premium attribute **[std]**; the GPT and 7-pay flags are
therefore only meaningful for points projected from issue. The notes' "less a
portion of withdrawals" is taken as the whole withdrawal **[std]**.
"""
if t == 0:
return 0.0
return cum_prem_pp(t - 1) + premium_pp(t) - wd_pp(t)
[docs]
def gpt_limit(t):
"""The guideline premium limit, ``max(GSP, GLP x policy years elapsed)`` [S3][R2]."""
return max(gsp(), glp() * policy_year(t))
[docs]
def gpt_ok(t):
"""Whether cumulative premium is still inside the guideline premium limit.
A compliance side-calculation with no cash flow of its own: a refused premium
simply never enters the model [S3][R2][R3].
"""
return cum_prem_pp(t) <= gpt_limit(t)
[docs]
def seven_pay_limit(t):
"""The 7-pay limit, ``seven_pay_prem x min(7, policy years elapsed)`` [R3][REG-R14]."""
return seven_pay_prem() * min(7, policy_year(t))
[docs]
def is_mec(t):
"""Whether the 7-pay test has failed by policy month t -- once true, true for good.
``CumPrem(t) > 7-pay limit`` in any of the first seven policy years, **latched**:
the flag stays set for the rest of the projection. The latch is not decoration.
Under IRC 7702A the contract *is* a modified endowment contract once the 7-pay test
fails, permanently and for every later year [R3][REG-R14]; and the in-year test
itself stops applying in policy year 8, so an unlatched flag would quietly switch
itself off exactly when the failure becomes permanent. The notes ask the base
model to "flag (does not project) 7-pay failures", which a flag that turns itself
off does not do.
A flag, not a cash flow: MEC status changes policyholder taxation, not insurer
liability cash flows, and modeling it as a charge or refund would distort premium
income [S3][R2][R3].
"""
if policy_year(t) <= 7 and cum_prem_pp(t) > seven_pay_limit(t):
return True
return t > 1 and is_mec(t - 1)
[docs]
def class_factor():
"""The underwriting-class multiplier on the base mortality table **[std]**."""
return float(data.class_factor_table().loc[rate_class(), "factor"]) # noqa: F821
[docs]
def mort_rate(t):
"""The annual best-estimate mortality rate in policy month t.
Base table times :func:`class_factor` times the A/E factor, which is 100% in the
base run **[std]** with no mortality improvement. The shipped table is a small
illustrative one **[std]**, *not* the 2015 VBT the notes recommend -- that family
is licensed and may not be reproduced here. Ages beyond the table take its last
row, where the rate is 1.0.
"""
tbl = data.mort_table() # noqa: F821
a = min(max(age(t), int(tbl.index.min())), int(tbl.index.max()))
return float(tbl.loc[a, "mort_rate"]) * class_factor() * mort_ae_factor # noqa: F821
[docs]
def mort_rate_mth(t):
"""q_m(t): the monthly mortality rate, ``1 - (1 - q)^(1/12)``."""
return 1 - (1 - mort_rate(t)) ** (1 / 12)
[docs]
def lapse_rate_base(t):
"""w_base(y): the base annual lapse rate by policy year **[std]**.
6% in year 1, 5% in year 2, 4% in years 3-10, 3% thereafter, read from
*lapse_table.csv*; policy years beyond the table take its last row. The shape is
informed qualitatively by the SOA/LIMRA UL persistency studies [R7][REG-R20],
whose detailed tables are behind a paid package, so the levels are a
standardization.
"""
tbl = data.lapse_table() # noqa: F821
y = min(policy_year(t), int(tbl.index.max()))
return float(tbl.loc[y, "lapse_rate_ann"])
[docs]
def lapse_shock_year():
"""The policy year of the surrender-charge-expiry lapse shock.
The first policy year with no surrender charge, i.e. the run-off length plus one,
derived from *surr_charge_table.csv* rather than hard-coded, so a different
schedule moves the shock with it. Zero when the model point carries no surrender
charge, which no policy year can equal.
"""
if not has_surr_charge():
return 0
return int(data.surr_charge_table().loc[surr_charge_id(), "runoff_years"]) + 1 # noqa: F821
[docs]
def lapse_rate_sc_mult(t):
"""M_sc: the surrender-charge-expiry lapse shock multiplier, 2.0 **[std]**.
Applied in :func:`lapse_shock_year` only. The surrender charge suppresses
surrender while it is positive and its expiry is a known industry shock point;
product-specific studies are proprietary, so the size is a shape assumption.
"""
return lapse_shock_mult if policy_year(t) == lapse_shock_year() else 1.0 # noqa: F821
[docs]
def comp_rate_ann(t):
"""r_comp(t): the competitor / market new-money rate driving dynamic lapse **[std]**.
The base deterministic run sets it equal to the current credited rate, exactly as
the notes prescribe, so :func:`lapse_rate_dyn_mult` is 1. Override this cells to
switch interest-sensitive lapse on.
"""
return crediting_rate_ann(t)
[docs]
def lapse_rate_dyn_mult(t):
"""M_rate(t): the interest-sensitive lapse multiplier **[std]**.
``min(3.0, 1 + 5 x max(0, r_comp(t) - i_cr(t) - 0.01))``: no effect until the
competitor rate exceeds the credited rate by more than 100 basis points. Equal to
1 throughout the base run.
"""
excess = max(0.0, comp_rate_ann(t) - crediting_rate_ann(t) - lapse_dyn_threshold) # noqa: F821
return min(lapse_dyn_cap, 1 + lapse_dyn_slope * excess) # noqa: F821
[docs]
def lapse_rate(t):
"""w_annual(y, t): the total annual lapse rate **[std]**.
``min(0.35, w_base(y) x M_sc(y) x M_rate(t))`` -- the base rate, the
surrender-charge-expiry shock and the dynamic multiplier, capped **[std]**.
"""
rate = lapse_rate_base(t) * lapse_rate_sc_mult(t) * lapse_rate_dyn_mult(t)
return min(lapse_rate_cap, rate) # noqa: F821
[docs]
def lapse_rate_mth(t):
"""w_m(t): the monthly lapse rate, ``1 - (1 - w_annual)^(1/12)``."""
return 1 - (1 - lapse_rate(t)) ** (1 / 12)
[docs]
def pols_if(t):
"""Number of policies in force at the beginning of policy month t.
This is the notes' ``l(t-1)``: decrements are end-of-month events, so the number
in force is constant through the month and every BOM cash flow is weighted by it.
``pols_if(1) = l(0) = pols_if_init()``.
"""
if t == 1:
return pols_if_init()
return pols_if(t - 1) - pols_death(t - 1) - pols_lapse(t - 1)
[docs]
def pols_if_at(t, timing):
"""Number of policies in force at time t, by ``timing``.
All three ``CashValue_SE`` timings coincide for this product, and all equal
:func:`pols_if`: there is no new business inside a projection, and the contract
has no maturity date, so nothing changes the policy count between BOM and the
end-of-month decrements. The cells exists so that products built on this chassis
which *do* have a maturity or new business can specialize it without moving every
caller.
"""
if timing in ("BEF_MAT", "BEF_NB", "BEF_DECR"):
return pols_if(t)
else:
raise ValueError("invalid timing")
[docs]
def pols_death(t):
"""Number of deaths at the end of policy month t, ``pols_if(t) x q_m(t)``."""
return pols_if(t) * mort_rate_mth(t)
[docs]
def pols_lapse(t):
"""Number of lapses at the end of policy month t.
``pols_if(t) x (1 - q_m(t)) x w_m(t)``: death is applied before lapse **[std
order]**, matching the notes' ``l(t) = l(t-1)(1 - q_m)(1 - w_m)``.
"""
return pols_if(t) * (1 - mort_rate_mth(t)) * lapse_rate_mth(t)
[docs]
def pols_maturity(t):
"""Number of maturing policies: always zero.
Universal life has no maturity date -- at attained age 121 charges cease, premiums
stop and coverage continues for life [S2][S3]. The cells is kept so the in-force
roll-forward identity has the same shape as in the term and annuity models of this
library, where it is not zero.
"""
return 0.0
[docs]
def claim_pp(t, kind):
"""The claim amount per policy by ``kind``.
``"DEATH"``
``DB(t) - L(t-1)``: the death benefit less policy debt [S3]. Due and unpaid
deductions during grace would also be subtracted, but the grace cascade is not
implemented.
``"LAPSE"``
``NCSV(t)``, the net cash surrender value.
A partial withdrawal is **not** a claim -- it is a payment on the owner's election
rather than on a contingency -- so ``"WITHDRAWAL"`` is not a ``kind`` here. Its
per-policy amount is :func:`wd_pp` and its cash flow is :func:`withdrawals`; the
$25 fee is retained by the insurer and is not part of the payment.
"""
if kind == "DEATH":
return db_pp(t) - loan_bal_pp(t - 1)
elif kind == "LAPSE":
return ncsv_pp(t)
else:
raise ValueError("invalid kind")
[docs]
def claims_from_av(t, kind):
"""The part of a claim paid out of the account value, by ``kind``.
Death and lapse both release the end-of-month account value ``AV(t)``, because
decrements follow the interest credit. ``"MATURITY"`` is zero: the contract has
no maturity date.
"""
if kind == "DEATH":
return av_pp(t) * pols_death(t)
elif kind == "LAPSE":
return av_pp(t) * pols_lapse(t)
elif kind == "MATURITY":
return 0.0
else:
raise ValueError("invalid kind")
[docs]
def claims_over_av(t):
"""Death claims in excess of the account value released.
``(claim_pp(t, "DEATH") - AV(t)) x pols_death(t)``. The cost of insurance charge
net of this is the mortality margin.
"""
return (claim_pp(t, "DEATH") - av_pp(t)) * pols_death(t)
[docs]
def claims(t, kind=None):
"""Claim outgo in policy month t, optionally by ``kind``.
``kind`` is ``"DEATH"`` or ``"LAPSE"``, or ``None`` for the total. Death claims
are weighted by :func:`pols_death` and surrenders by :func:`pols_lapse`, both
end-of-month events.
Partial withdrawals are **not** here, neither as a ``kind`` nor in the ``None``
total: a withdrawal is a payment on the owner's election rather than a claim, and
is :func:`withdrawals`. So ``claims(t)`` is the death and surrender total only,
and :func:`net_cf` subtracts the withdrawal row separately.
"""
if kind == "DEATH":
return claim_pp(t, "DEATH") * pols_death(t)
elif kind == "LAPSE":
return claim_pp(t, "LAPSE") * pols_lapse(t)
elif kind is None:
return sum(claims(t, k) for k in ("DEATH", "LAPSE"))
else:
raise ValueError("invalid kind")
[docs]
def withdrawals(t):
"""Partial withdrawal payments at BOM of policy month t.
``W(t) x pols_if(t)``: a withdrawal is taken at the beginning of the month by the
policies still in force, so it is weighted by :func:`pols_if` and not by a
decrement.
It is a cash flow line of its own -- the ``withdrawals`` column of
:func:`result_cf` -- and not a ``kind`` of :func:`claims`, because a withdrawal is
a payment the owner elects rather than a claim on a contingency. The $25
withdrawal fee is retained by the insurer and is not part of the payment; see
:func:`wd_fees`.
Zero in every shipped model point: the mechanics are implemented and the
utilization is left to the data **[std]**, as :func:`wd_pp` explains.
"""
return wd_pp(t) * pols_if(t)
[docs]
def wd_fees(t):
"""Withdrawal fees retained by the insurer, for the policies in force.
Account-value outgo but not a liability cash flow, so this appears in
:func:`margin_expense` and not in :func:`claims`.
"""
return wd_fee_pp(t) * pols_if(t)
[docs]
def inflation_factor(t):
"""The expense inflation factor, ``(1 + inflation_rate)^(y - 1)`` **[std]**.
Expenses inflate by policy year, not by month, which is how the notes write the
$75 per policy per year maintenance expense.
"""
return (1 + inflation_rate) ** (policy_year(t) - 1) # noqa: F821
[docs]
def expenses(t):
"""The insurer's own expenses in policy month t **[std]**.
``expense_maint / 12`` inflating at 2.5% a year, plus ``expense_acq`` in the issue
month. The notes specify no acquisition expense for this product -- the higher
per-unit charge in policy years 1-10 is the contractual acquisition-cost recovery,
which is income, not outgo -- so ``expense_acq`` is zero and the term is carried
only so products built on this chassis can switch it on.
Not to be confused with :func:`maint_fee`, which is the charge *against the
account value*.
"""
acq = expense_acq if duration_mth(t) == 0 else 0.0 # noqa: F821
return (acq + expense_maint / 12 * inflation_factor(t)) * pols_if(t) # noqa: F821
[docs]
def premium_taxes(t):
"""Premium tax and other percent-of-premium expense, 2.5% of premium **[std]**."""
return premium_tax_rate * premiums(t) # noqa: F821
[docs]
def margin_expense(t):
"""Expense margin: the charges the insurer keeps, net of its own outgo.
``load x GP + withdrawal fees + maint_fee + surrender charges - expenses
- premium taxes``. Follows ``CashValue_SE.margin_expense``; see
:func:`check_margin` for the identity it takes part in.
"""
return (load_prem_rate() * premium_pp(t) * pols_if(t)
+ wd_fees(t)
+ maint_fee(t)
+ surr_charge(t)
- expenses(t)
- premium_taxes(t))
[docs]
def margin_mortality(t):
"""Mortality margin: :func:`coi` net of :func:`claims_over_av`."""
return coi(t) - claims_over_av(t)
[docs]
def net_cf(t):
"""Net liability cash flow in policy month t, **undiscounted**.
``premiums - death claims - surrender payments - withdrawals - expenses
- premium taxes``, **income-positive** as in every model of this library.
:func:`claims` carries the death and surrender rows only, so the withdrawal row is
subtracted as its own term. Like the rest of this library the model projects
*gross liability cash flows*: there is no discounting and no change in account
value in this figure, because reserves are a separate layer that consumes these
flows. Investment income on the account value is a credit to the policyholder,
not an insurer cash flow, so it does not appear either -- see :func:`check_margin`
for how it reconciles.
"""
return (premiums(t) - claims(t) - withdrawals(t)
- expenses(t) - premium_taxes(t))
[docs]
def check_av_roll_fwd():
"""Check the account value roll-forward.
Returns ``True`` when, for every projected month, the opening account value in
force of month ``t + 1`` equals::
av_at(t, "BEF_PREM")
+ prem_to_av(t)
- withdrawals(t)
- wd_fees(t)
- mth_deduction(t)
+ inv_income(t)
- claims_from_av(t, "DEATH")
- claims_from_av(t, "LAPSE")
This pins the notes' processing order, in particular that interest is credited on
the *post-deduction* balance and that decrements come after the credit.
"""
res = []
for t in range(1, proj_len() + 1):
av = (av_at(t, "BEF_PREM")
+ prem_to_av(t)
- withdrawals(t)
- wd_fees(t)
- mth_deduction(t)
+ inv_income(t)
- claims_from_av(t, "DEATH")
- claims_from_av(t, "LAPSE"))
res.append(math.isclose(av_at(t + 1, "BEF_PREM"), av, # noqa: F821
rel_tol=1e-9, abs_tol=1e-9))
return all(res)
[docs]
def check_margin():
"""Check the net cash flow against the expense and mortality margins.
Returns ``True`` when, for every projected month::
net_cf(t) == margin_expense(t) + margin_mortality(t)
+ av_change(t) - inv_income(t)
+ loan_bal_pp(t) * pols_lapse(t)
The last three terms are what separates a *gross liability cash flow* model from
``CashValue_SE``, whose ``net_cf`` already nets the change in account value and
the investment income; the loan term is the debt extinguished against the account
value when a policy with a loan surrenders. The identity holds while neither the
:func:`csv_pp` nor the :func:`ncsv_pp` floor binds against a policy loan, which is
the case for every shipped model point.
"""
res = []
for t in range(1, proj_len() + 1):
rhs = (margin_expense(t) + margin_mortality(t)
+ av_change(t) - inv_income(t)
+ loan_bal_pp(t) * pols_lapse(t))
res.append(math.isclose(net_cf(t), rhs, # noqa: F821
rel_tol=1e-9, abs_tol=1e-9))
return all(res)
[docs]
def result_cf():
"""Result table of cashflows, a DataFrame indexed by policy month ``t``.
The surrender column is ``claims_lapse``, matching the ``"LAPSE"`` ``kind`` that
produces it, and partial withdrawals sit in their own ``withdrawals`` column rather
than among the claims. The cash flow columns sum to ``net_cf`` under the
income-positive sign convention: ``premiums`` less every other flow.
"""
ts = list(range(1, proj_len() + 1))
return pd.DataFrame( # noqa: F821
{
"pols_if": [pols_if(t) for t in ts],
"premiums": [premiums(t) for t in ts],
"claims_death": [claims(t, "DEATH") for t in ts],
"claims_lapse": [claims(t, "LAPSE") for t in ts],
"withdrawals": [withdrawals(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 policy decrements, a DataFrame indexed by policy month ``t``."""
ts = list(range(1, proj_len() + 1))
return pd.DataFrame( # noqa: F821
{
"pols_if": [pols_if(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],
"mort_rate_mth": [mort_rate_mth(t) for t in ts],
"lapse_rate_mth": [lapse_rate_mth(t) for t in ts],
},
index=pd.Index(ts, name="t"), # noqa: F821
)
[docs]
def result_av():
"""Result table of the per-policy account value roll-forward.
The columns are the columns of the worked example in the technical notes, in the
notes' own order: ``AV(t-1)``, ``NP``, ``AV'``, ``DB``, ``NAAR``, ``COI``, ``MD``,
``AV' - MD``, interest, ``AV(t)``, followed by the surrender and loan values.
"""
ts = list(range(1, proj_len() + 1))
return pd.DataFrame( # noqa: F821
{
"av_pp_bef_prem": [av_pp_at(t, "BEF_PREM") for t in ts],
"prem_to_av_pp": [prem_to_av_pp(t) for t in ts],
"av_pp_bef_fee": [av_pp_at(t, "BEF_FEE") for t in ts],
"db_pp": [db_pp(t) for t in ts],
"net_amt_at_risk": [net_amt_at_risk(t) for t in ts],
"coi_pp": [coi_pp(t) for t in ts],
"mth_deduction_pp": [mth_deduction_pp(t) for t in ts],
"av_pp_bef_inv": [av_pp_at(t, "BEF_INV") for t in ts],
"inv_income_pp": [inv_income_pp(t) for t in ts],
"av_pp": [av_pp(t) for t in ts],
"surr_charge_pp": [surr_charge_pp(t) for t in ts],
"ncsv_pp": [ncsv_pp(t) for t in ts],
"loan_bal_pp": [loan_bal_pp(t) for t in ts],
},
index=pd.Index(ts, name="t"), # noqa: F821
)
# ---------------------------------------------------------------------------
# References
data = ("Interface", ("..", "Data"), "auto")
point_id = 1
omega_age = 120
charges_cease_age = 121
guar_rate_ann = 0.02
crediting_rate_curr = 0.04
coi_curr_factor = 0.6
expense_pol_mth = 7.5
expense_unit_mth_1 = 0.26
expense_unit_mth_2 = 0.156
expense_unit_step_year = 10
loan_rate_ann = 0.0275
wd_fee = 25.0
wd_free_rate = 0.1
wd_first_year = 1
mort_ae_factor = 1.0
lapse_shock_mult = 2.0
lapse_dyn_slope = 5.0
lapse_dyn_threshold = 0.01
lapse_dyn_cap = 3.0
lapse_rate_cap = 0.35
expense_acq = 0.0
expense_maint = 75.0
inflation_rate = 0.025
premium_tax_rate = 0.025
pd = ("Module", "pandas")
math = ("Module", "math")