# 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:`~.VUL_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/variable_ul/``, 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
``VUL_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:`~.VUL_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
subaccount_file data.subaccount_table() subaccount_table.csv
scenario_file data.scenario_table() scenario_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`` -- ``SA_i(0)``, ``FA(0)``, ``LA(0)``, ``D(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. advance the policy year, the attained age and the year-dependent parameters -- the
loan tier (:func:`loan_rate_ann`), the surrender charge (:func:`surr_charge_rate`)
and the corridor factor (:func:`corridor_factor`);
2. gross premium and its load, the net premium allocated by :func:`alloc` and
:func:`alloc_fixed` (:func:`premium_pp`, :func:`prem_to_av_pp`);
3. withdrawal and the $25 fee, taken pro rata from the unloaned accounts, and under
Option A the proportionate face reduction they force (:func:`wd_pp`,
:func:`wd_fee_pp`, :func:`face_reduction_pp`, :func:`sum_assured_at`) -- after which
the account value is :func:`av_pp_at(t, "BEF_FEE")<av_pp_at>`, the notes'
post-premium value;
4. loan activity -- not modeled; the opening debt and collateral roll forward;
5. death benefit and the GPT corridor test (:func:`db_pp`), then the net amount at
risk (:func:`net_amt_at_risk`), **with no one-month discount**;
6. the monthly deduction (:func:`mth_deduction_pp`), allocated across the unloaned
accounts pro rata (:func:`mth_deduction_sa_pp`, :func:`mth_deduction_fa_pp`);
7. growth over the month: the separate-account unit-value factor
(:func:`inv_return_mth`), fixed-option interest (:func:`fixed_return_mth`), loan
account interest (:func:`loan_cr_rate_mth`) and debt accrual
(:func:`loan_rate_mth`);
8. end of month: the death benefit and net amount at risk recomputed on end-of-month
balances (:func:`db_pp_eom`, :func:`net_amt_at_risk_eom`), then the decrements,
death before lapse (:func:`pols_death`, :func:`pols_lapse`);
9. the default test (:func:`is_default`) -- a diagnostic; see the model docstring.
Cash flows are **undiscounted**. Premiums, expenses and percent-of-premium expenses
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)``.
.. rubric:: Two views of the same run
The notes require two reports and warn that confusing them is "a common specification
error". :func:`result_cf` is the **gross (policyholder) view**: the insurer's liability
outflow on death is the *full death benefit less policy debt*, and the account value
seized is the *funding* of part of it. :func:`result_net` is the **net-of-account
(general-account strain) view**, derived arithmetically from the same run: the margins
collected (premium loads, monthly deductions, M&E, loan spread, surrender charges) less
the net mortality cost :func:`claims_net`, which is the net amount at risk. Projecting
``DB - AV`` as the claim understates gross benefit outgo; projecting the full death
benefit *and* separately expensing the net amount at risk double counts.
:func:`check_net_view` pins the two views to each other.
.. rubric:: Naming
Cells names follow :mod:`.UL_US_S` -- the universal-life chassis this product is
built 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-policy amounts,
``timing`` and ``kind`` string arguments. Names are added only where variable UL has a
concept fixed universal life does not: the subaccount vector, the fixed option, the
loan account, the M&E charge, the return scenario, the funding ratio and the pricing
path. The technical notes use compact actuarial symbols instead. The mapping is:
========================= ============================== ==========================
Notes symbol Cells Meaning
========================= ============================== ==========================
t (the ``t`` argument) Policy month, 1-based
(policy year) policy_year(t) Policy year, 1-based
(t - 1 in months) duration_mth(t) Completed policy months
(completed years) duration(t) Completed policy years
(issue age) age_at_entry Issue age (ANB)
x_t age(t) Attained age (ANB)
(none) proj_len Last projected month
F_0 sum_assured Initial face amount
F_t sum_assured_at(t) Face after reductions
(F_0 / 1000) units Initial face in $1,000s
i (subaccount index) subaccount_ids The subaccount lineup
alpha_i alloc(i) Allocation to subaccount i
alpha_F alloc_fixed Allocation to fixed option
e_i fund_expense_ann(i) Fund expense ratio (annual)
m me_rate_ann M&E rate (annual)
r_{i,t} gross_return_mth(t, i) Gross subaccount return
(net unit-value return) inv_return_mth(t, i) (1+r)(1-e/12)(1-m/12) - 1
i_fix crediting_rate_ann(t) Declared fixed rate
(monthly fixed rate) fixed_return_mth(t) (1+i_fix)^(1/12) - 1
(fixed floor) guar_rate_ann 1.0% fixed-option floor
i_L loan_rate_ann(t) Loan rate charged
i_C loan_cr_rate_ann(t) Loan rate credited
P_t premium_pp(t) Premium paid per policy
(planned) premium_pp_ann Planned annual premium
rho_t prem_persistency(t) Premium persistency
rho^base_t prem_persistency_base(t) Base persistency factor
gamma load_prem_rate Premium load rate
(net premium) prem_to_av_pp(t) Net premium to the accounts
W(t) wd_pp(t) Partial withdrawal
(withdrawal fee) wd_fee_pp(t) $25 withdrawal fee
(face cut) face_reduction_pp(t) Option A face reduction
SA_{i,t} sa_pp(t, i) Subaccount value, EOM
SA'_{i,t} sa_pp_at(t, i, "BEF_INV") Subaccount post-deduction
FA_t fa_pp(t) Fixed-option value, EOM
LA_t la_pp(t) Loan-account collateral
D_t loan_bal_pp(t) Outstanding policy debt
AV_t av_pp(t) Total account value, EOM
AV_t (BOM) av_pp_at(t, "BEF_PREM") Account value, start of month
(post-premium AV) av_pp_at(t, "BEF_FEE") The notes' step-5 balance
(unloaned AV) unloaned_av_pp_at(t, timing) Deduction allocation base
(aggregate AV) av_at(t, timing) Account value in force
(none) av_change(t) Change in account value
e_pol expense_pol_mth $10.00 per-policy charge
e_face expense_unit_mth $0.20 per $1,000 of F_0
rc(t) rider_charge_pp(t) Rider charges (0)
(e_pol + e_face U + rc) maint_fee_pp(t) Non-COI monthly charges
MD_t mth_deduction_pp(t) Monthly deduction
(MD share) mth_deduction_sa_pp(t, i) Pro-rata share, subaccount i
(MD share) mth_deduction_fa_pp(t) Pro-rata share, fixed option
c_t coi_rate(t) Current monthly COI rate
(2017 CSO max) coi_rate_guar(t) Guaranteed maximum COI rate
(83.34 cap) coi_rate_cap Monthly COI rate cap
COI_t coi_pp(t) Cost of insurance charge
kappa_t corridor_factor(t) GPT corridor factor
(corridor minimum) db_corridor_pp(t) kappa_t x AV
DB_t db_pp(t) Death benefit, BOM
DB_t^EOM db_pp_eom(t) Death benefit, EOM
NAAR_t net_amt_at_risk(t) Net amount at risk, BOM
NAAR_t^EOM net_amt_at_risk_eom(t) Net amount at risk, EOM
(M&E collected) me_charge_pp(t, i) M&E taken in unit values
(interest credited) inv_income_pp(t) Total credit to the accounts
SC_t surr_charge_pp(t) Surrender charge scheduled
(SC per $1,000) surr_charge_rate(t) Surrender charge rate
(AV - SC) csv_pp(t) Cash value before debt
CSV_t ncsv_pp(t) AV - SC - D, the notes' CSV
(SC collected) surr_charge(t) sc_income
(default test) is_default(t) CSV_t <= 0
(deduction shortfall) is_shortfall(t) Unloaned AV cannot pay MD_t
(none) first_default_month() First month in default
(none) first_shortfall_month() First month short of MD_t
phi_t funding_ratio(t) AV_t / AV*_t
AV*_t av_pricing_pp(m) At-issue pricing path
lambda_t lapse_rate_dyn_mult(t) Dynamic lapse multiplier
(SC cliff spike) lapse_rate_sc_mult(t) Surrender-charge cliff spike
q^d,annual mort_rate(t) Annual mortality rate
q^d_t mort_rate_mth(t) Monthly mortality rate
q^w,annual lapse_rate(t) Total annual lapse rate
q^w_t lapse_rate_mth(t) Monthly lapse rate
q^w,base lapse_rate_base(t) Base annual lapse rate
l_t 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
prem_gross premiums(t) Premium income
load_income premium_loads(t) Premium loads collected
md_income mth_deduction(t) Monthly deductions collected
me_income me_charge(t) M&E collected
loan_spread loan_spread(t) (i_L - i_C) accrual on D_t
claim_gross claims(t, "DEATH") Death claims, DB - debt
claim_net claims_net(t) Net GA strain, NAAR^EOM
surr_outgo claims(t, "LAPSE") Surrender payments
(withdrawals) withdrawals(t) Withdrawal payments
sc_income surr_charge(t) Surrender charges collected
expense expenses(t), premium_taxes(t) Insurer expenses
sa_transfer sa_transfer(t) Separate -> general account
av_eop av_at(t, "EOM") Account value in force, EOM
(net GA cash flow) net_cf_ga(t) Net-of-account view
NetCF(t) net_cf(t) Gross liability cash flow
========================= ============================== ==========================
Eight names needed care.
``l_t`` in the notes is the in-force probability at the **start** of month ``t`` and
``l_{t+1} = l_t (1 - q^d_t)(1 - q^w_t)`` is its roll-forward; ``pols_if(t)`` follows
``BasicTerm_S`` and is the number in force at the start of month ``t``, so the two
coincide and ``pols_if(1) = l_0 = pols_if_init()``. Every BOM cash flow is weighted by
``pols_if(t)``.
The notes' ``AV_{t+1}`` is an **end-of-month** balance, not the next month's opening
event: ``av_pp(t)`` is the notes' ``AV_{t+1}`` and ``av_pp_at(t, "BEF_PREM")`` is the
notes' ``AV_t``. The same shift applies to ``D_{t+1}``, which is ``loan_bal_pp(t)``.
This is why the notes write the death claim as ``DB_t^EOM - D_{t+1}``: both are
end-of-month quantities of month ``t``.
The notes use ``m`` for the M&E rate and also index the pricing path by policy month.
``me_rate_ann`` names the rate; the pricing-path cells take an argument ``m`` counting
policy months **from issue**, which is ``duration_mth(t) + 1`` at projection month
``t`` -- not ``t`` itself, which restarts at 1 for an in-force model point.
The notes call ``AV_t - SC_t - D_t`` the cash surrender value ``CSV_t``. The chassis
name for that quantity, net of policy debt, is ``ncsv_pp``; ``csv_pp`` is the
intermediate ``AV_t - SC_t`` before debt. Both are kept so the chassis reads across,
and the symbol table above records which is which.
``coi_rate`` is quoted per $1,000 of net amount at risk **per month** and
``expense_unit_mth`` 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.
:func:`units` takes **no** ``t``. Both the $0.20 monthly charge and the surrender
charge are quoted on ``F_0``, the *initial* face [S2], where the fixed-UL chassis
charges its per-unit fee on the current face and so writes ``units(t)``. Using the
current face here would silently reduce both charges after an Option A withdrawal.
:func:`maint_fee_pp` is the non-COI part of the *monthly deduction* -- a charge against
the account value, and therefore insurer income. :func:`expenses` is something
different: the insurer's own **[std]** maintenance outgo of $75 per policy per year.
The two must not be confused. Note also that the variable-UL notes specify a flat $75 a
year where the fixed-UL chassis inflates it at 2.5%, so ``inflation_rate`` is **0.0**
here; the cells is kept so the two models read alike.
A partial withdrawal is **not a claim**: it is a payment the owner elects, not an event
that terminates coverage, so it is :func:`withdrawals` in its own ``withdrawals`` column
and :func:`claims` neither accepts ``"WITHDRAWAL"`` nor counts it in the ``kind is None``
total. The per-policy amount is still reached through
:func:`claim_pp(t, "WITHDRAWAL")<claim_pp>`, which is where the rule that the $25 fee is
not part of the payment is written down, and :func:`withdrawals` weights it by
:func:`pols_if` -- a withdrawal is taken at the monthiversary by policies still in force,
where a death claim is weighted by :func:`pols_death` and a surrender by
:func:`pols_lapse`.
"""
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():
"""F_0: the initial face amount of the selected model point.
Both the $0.20 per $1,000 monthly charge and the surrender charge are quoted on
this, the *initial* face, not on :func:`sum_assured_at`.
"""
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 observed at one insurer only and 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 a documented variation, out of scope in the baseline, 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"`` or ``"SINGLE"`` **[std]**.
The notes do not enforce guideline premium or 7-pay limits in the baseline --
premiums are assumed within limits -- so there is no cap on either pattern and no
``TARGET`` pattern.
"""
return model_point()["premium_type"]
[docs]
def premium_pp_ann():
"""The planned annualized premium per policy; flexible in amount and timing [S1]."""
return float(model_point()["premium_pp_ann"])
[docs]
def load_prem_rate():
"""gamma: the current premium load rate, 4.0% flat **[std]** (guaranteed max 6.0% [S2]).
The load is a non-guaranteed element under ASOP 2 [R11], which is why it sits in
the model point table rather than in a Reference.
"""
return float(model_point()["load_prem_rate"])
[docs]
def subaccount_ids():
"""The separate-account subaccount lineup, from *subaccount_table.csv*.
Two subaccounts -- equity and bond -- a **[std]** collapse of the observed menus.
Extending the lineup means adding a row here *and* the matching ``sa_pp_init_*``
and ``alloc_*`` columns to the model point table.
"""
return list(data.subaccount_table().index) # noqa: F821
[docs]
def alloc(i):
"""alpha_i: the share of each net premium allocated to subaccount i."""
return float(model_point()["alloc_" + str(i)])
[docs]
def alloc_fixed():
"""alpha_F: the share of each net premium allocated to the fixed option."""
return float(model_point()["alloc_fixed"])
[docs]
def fund_expense_ann(i):
"""e_i: the annual fund operating expense ratio of subaccount i **[std]**.
0.75% equity and 0.55% bond, chosen inside the observed lineup ranges
(0.29%-1.18% [S1], 0.55%-2.88% gross [S2], 0.46%-2.54% [S3], 0.08%-1.93% [S4]).
Borne through the unit value, so it reduces the policyholder's return and is *not*
insurer income.
"""
return float(data.subaccount_table().loc[i, "fund_expense_ann"]) # noqa: F821
[docs]
def sa_pp_init(i):
"""SA_i(0): the value of subaccount i per policy at the outset, 0 at issue."""
return float(model_point()["sa_pp_init_" + str(i)])
[docs]
def fa_pp_init():
"""FA(0): the fixed-option value per policy at the outset, 0 at issue."""
return float(model_point()["fa_pp_init"])
[docs]
def loan_bal_init():
"""D(0): the outstanding policy debt per policy at the outset, 0 at issue.
The loan-account collateral LA(0) is taken equal to it **[std]**: a loan moves
value from the investment options into a general-account loan account [S3], so at
the outset the two balances coincide and only their accrual rates differ.
"""
return float(model_point()["loan_bal_init"])
[docs]
def av_pp_init():
"""AV(0): the total account value per policy at the outset.
``sum(SA_i(0)) + FA(0) + LA(0)`` -- the loan account is part of the account value
[S1][S2][S3][S4], the debt is not.
"""
return (sum(sa_pp_init(i) for i in subaccount_ids())
+ fa_pp_init() + 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' ``duration_inforce``.
"""
return int(model_point()["duration_mth"])
[docs]
def has_surr_charge():
"""Whether a surrender charge schedule applies to this model point.
False models the low-load / no-load archetype [S3], a documented variation.
"""
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 scenario_id():
"""The return scenario ID, a row label of *scenario_table.csv*.
The separate-account return path is the dominant assumption for this product, so
it is a model point attribute rather than a Reference: different cells can be run
on different paths in one model.
"""
return model_point()["scenario_id"]
[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. The pricing-path cells index policy months from issue as
``duration_mth(t) + 1``; see :func:`funding_ratio`.
"""
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):
"""The policy year containing policy month t, 1-based."""
return duration(t) + 1
[docs]
def age(t):
"""x_t: the attained age (ANB) in policy month t, ``age_at_entry() + duration(t)``.
Age advances 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`` (121), the
last age of *mort_table.csv*, where the annual rate is 1.0. The contract has no
maturity date [S1][S2][S4], so the projection is truncated by mortality **[std]**,
not by the policy. Ending at 121 rather than 120 is deliberate: it is the age at
which premiums and monthly deductions cease while the asset charges continue, and
the notes list missing that regime switch among the modeling pitfalls.
"""
return 12 * (omega_age - age_at_entry() + 1) - duration_mth_init() # noqa: F821
[docs]
def units():
"""U: the **initial** face amount in $1,000 units, ``sum_assured() / 1000``.
Takes no ``t``. The $0.20 monthly charge is quoted per $1,000 of ``F_0`` [S2] and
the surrender charge per $1,000 of initial face **[std]**, so neither follows
:func:`sum_assured_at`. The fixed-UL chassis charges its per-unit fee on the
*current* face and therefore writes ``units(t)``; copying that here would silently
shrink both charges after an Option A withdrawal.
"""
return sum_assured() / 1000
[docs]
def gross_return_mth(t, i):
"""r_{i,t}: the **gross** monthly return of subaccount i, a scenario input.
Read from *scenario_table.csv* for this model point's :func:`scenario_id`. Months
beyond the end of a scenario take its last row, so a two-row scenario is a level
path with one distinguished opening month -- which is exactly the shipped ``WE``
scenario, whose month 1 is the worked example's (+1.00% equity, -0.50% bond).
Gross means before the fund expense ratio and before the M&E charge; both are
applied in :func:`inv_return_mth`. A stochastic set is more rows in this table,
not a formula change.
"""
tbl = data.scenario_table().loc[(scenario_id(), i)] # noqa: F821
m = min(t, int(tbl.index.max()))
return float(tbl.loc[m, "gross_return_mth"])
[docs]
def inv_return_mth(t, i):
"""The net monthly unit-value return of subaccount i.
``(1 + r_{i,t}) (1 - e_i/12) (1 - m/12) - 1``. In the contract the fund expenses
and (at one insurer [S1]) the M&E charge accrue daily inside the unit value; the
monthly product form is a **[std]** approximation, and insurers that deduct M&E
monthly [S2][S3][S4] are captured by the same factor.
The M&E charge is applied **here and only here**. Applying it again as a monthly
deduction would double count it across insurer conventions -- the notes list that
among the modeling pitfalls, and this model picks the unit-value factor.
"""
return ((1 + gross_return_mth(t, i))
* (1 - fund_expense_ann(i) / 12)
* (1 - me_rate_ann / 12) - 1) # noqa: F821
[docs]
def crediting_rate_ann(t):
"""i_fix: the declared annual effective rate on the fixed option, 1.0% **[std]**.
Floored at the contractual guaranteed minimum ``guar_rate_ann`` of 1.0% [S1].
Declared rates are non-guaranteed and are not published, so the baseline holds the
declared rate at the floor; in practice it would move with general-account yields.
"""
return max(guar_rate_ann, crediting_rate_curr) # noqa: F821
[docs]
def fixed_return_mth(t):
"""The monthly fixed-option rate, ``(1 + i_fix)^(1/12) - 1``.
The contract credits daily [S1]; monthly compounding is the model's discretization
**[std]** -- do not also compound daily.
"""
return (1 + crediting_rate_ann(t)) ** (1 / 12) - 1
[docs]
def loan_rate_ann(t):
"""i_L: the annual effective rate charged on policy debt [S1].
2.0% in policy years 1-9 (the standard loan), 1.05% from the 10th anniversary (the
preferred loan). Against the 1.0% credited to the loan account this is a net
spread of 1.0% falling to 0.05%.
"""
if policy_year(t) < loan_pref_year: # noqa: F821
return loan_rate_ann_std # noqa: F821
return loan_rate_ann_pref # noqa: F821
[docs]
def loan_rate_mth(t):
"""The monthly charged loan rate, ``(1 + i_L)^(1/12) - 1``.
Contractually the interest is due each anniversary and capitalized if unpaid [S1];
monthly compounding is the model's discretization **[std]**.
"""
return (1 + loan_rate_ann(t)) ** (1 / 12) - 1
[docs]
def loan_cr_rate_ann(t):
"""i_C: the annual effective rate credited to the loan account, 1.0% [S1].
Loaned value earns this, not fund returns -- the notes list ignoring that among
the modeling pitfalls.
"""
return loan_cr_rate_ann_lvl # noqa: F821
[docs]
def loan_cr_rate_mth(t):
"""The monthly loan-account credited rate, ``(1 + i_C)^(1/12) - 1`` **[std]**."""
return (1 + loan_cr_rate_ann(t)) ** (1 / 12) - 1
[docs]
def pricing_return_mth():
"""The monthly gross subaccount return on the notes' at-issue pricing path.
``(1 + 6%)^(1/12) - 1`` **[std]**: the level gross return the notes specify for
``AV*``, the denominator of the funding ratio. Independent of the projection's own
return scenario, which is the point -- the funding ratio compares the realized path
against a fixed pricing path.
"""
return (1 + pricing_return_ann) ** (1 / 12) - 1 # noqa: F821
[docs]
def age_pricing(m):
"""The attained age (ANB) in policy month m of the pricing path, counted from issue."""
return age_at_entry() + (m - 1) // 12
[docs]
def policy_year_pricing(m):
"""The policy year containing policy month m of the pricing path, 1-based."""
return (m - 1) // 12 + 1
[docs]
def prem_pricing_pp(m):
"""The premium paid in policy month m on the pricing path: the *planned* premium.
The pricing path is funded at ``rho = 1`` by construction -- it is the benchmark
the realized funding level is measured against -- so premium persistency does not
enter it. Zero from attained age 121.
"""
if age_pricing(m) >= charges_cease_age: # noqa: F821
return 0.0
if premium_type() == "SINGLE":
return premium_pp_ann() if m == 1 else 0.0
elif premium_type() == "LEVEL":
return premium_pp_ann() / 12
else:
raise ValueError("invalid premium type")
[docs]
def sa_pricing_pp_at(m, i, timing):
"""Pricing-path subaccount value at an intra-month point of policy month m.
``"BEF_PREM"`` the closing balance of month ``m - 1``; ``"BEF_FEE"`` after the net
premium; ``"BEF_INV"`` after this subaccount's pro-rata share of the monthly
deduction. There are no withdrawals and no loans on the pricing path, so the
``"BEF_WD"`` point of :func:`sa_pp_at` has no counterpart.
"""
if timing == "BEF_PREM":
return sa_pricing_pp(m - 1, i)
elif timing == "BEF_FEE":
return (sa_pricing_pp_at(m, i, "BEF_PREM")
+ alloc(i) * (1 - load_prem_rate()) * prem_pricing_pp(m))
elif timing == "BEF_INV":
den = av_pricing_pp_at(m, "BEF_FEE")
bef = sa_pricing_pp_at(m, i, "BEF_FEE")
share = bef / den if den > 0 else alloc(i)
return bef - mth_deduction_pricing_pp(m) * share
else:
raise ValueError("invalid timing")
[docs]
def sa_pricing_pp(m, i):
"""SA*_i: the pricing-path value of subaccount i at the end of policy month m.
``SA*_i(0) = 0``: the path starts at issue with no account value, whatever the
model point's own opening balances are. Growth uses the level pricing return
rather than the projection's scenario, with the same fund expense and M&E factors.
"""
if m == 0:
return 0.0
return (sa_pricing_pp_at(m, i, "BEF_INV")
* (1 + pricing_return_mth())
* (1 - fund_expense_ann(i) / 12)
* (1 - me_rate_ann / 12)) # noqa: F821
[docs]
def fa_pricing_pp_at(m, timing):
"""Pricing-path fixed-option value at an intra-month point of policy month m."""
if timing == "BEF_PREM":
return fa_pricing_pp(m - 1)
elif timing == "BEF_FEE":
return (fa_pricing_pp_at(m, "BEF_PREM")
+ alloc_fixed() * (1 - load_prem_rate()) * prem_pricing_pp(m))
elif timing == "BEF_INV":
den = av_pricing_pp_at(m, "BEF_FEE")
bef = fa_pricing_pp_at(m, "BEF_FEE")
share = bef / den if den > 0 else alloc_fixed()
return bef - mth_deduction_pricing_pp(m) * share
else:
raise ValueError("invalid timing")
[docs]
def fa_pricing_pp(m):
"""FA*: the pricing-path fixed-option value at the end of policy month m."""
if m == 0:
return 0.0
return fa_pricing_pp_at(m, "BEF_INV") * (1 + fixed_return_mth(m))
[docs]
def av_pricing_pp_at(m, timing):
"""Pricing-path total account value at an intra-month point of policy month m."""
return (sum(sa_pricing_pp_at(m, i, timing) for i in subaccount_ids())
+ fa_pricing_pp_at(m, timing))
[docs]
def av_pricing_pp(m):
"""AV*_m: the account value the notes' pricing path reaches by policy month m.
"The account value projected at issue under the pricing path (level 6% gross
subaccount return, current charges, planned premiums)" **[std]**. It is the
denominator of :func:`funding_ratio`, and it is a genuine second recursion: no
decrements, no loans, no withdrawals, ``rho = 1``, starting from zero at issue.
"""
if m == 0:
return 0.0
return (sum(sa_pricing_pp(m, i) for i in subaccount_ids()) + fa_pricing_pp(m))
[docs]
def db_pricing_pp(m):
"""The pricing-path death benefit in policy month m, after the corridor test.
Uses :func:`corridor_factor_at` at the pricing path's own attained age rather than
:func:`corridor_factor`, whose month-1 pin belongs to the projection and not to
this benchmark.
"""
av = av_pricing_pp_at(m, "BEF_FEE")
if db_option() == "A":
opt = sum_assured()
elif db_option() == "B":
opt = sum_assured() + av
else:
raise ValueError("invalid db_option")
return max(opt, corridor_factor_at(age_pricing(m)) * av)
[docs]
def naar_pricing_pp(m):
"""The pricing-path net amount at risk, ``max(0, DB* - AV*')``, undiscounted [S2]."""
return max(0.0, db_pricing_pp(m) - av_pricing_pp_at(m, "BEF_FEE"))
[docs]
def mth_deduction_pricing_pp(m):
"""The pricing-path monthly deduction: current charges, as the notes specify.
``e_pol + e_face x U + c x NAAR*/1000``, zero from attained age 121. The current
COI scale comes from :func:`coi_rate_at`, the rule, never from a model point's
month-1 pin: the pricing path is a seventy-year benchmark and a single disclosed
rate is not a scale.
"""
if age_pricing(m) >= charges_cease_age: # noqa: F821
return 0.0
return (expense_pol_mth + expense_unit_mth * units() # noqa: F821
+ coi_rate_at(policy_year_pricing(m)) / 1000 * naar_pricing_pp(m))
[docs]
def funding_ratio(t):
"""phi_t: the funding ratio ``AV_t / AV*_t`` **[std]**.
Both sides are measured at the **start** of policy month t, before the premium, so
the dynamic multipliers that consume it cannot depend on the premium they help
determine. ``phi_t < 1`` is a performance or funding shortfall.
Returns 1.0 -- no dynamic effect -- when the behavior module is off, and also when
the pricing path has not accumulated anything yet, which is the issue month of a
new-business point.
"""
if not dyn_behavior_on: # noqa: F821
return 1.0
star = av_pricing_pp(duration_mth(t))
if star <= 0:
return 1.0
return av_pp_at(t, "BEF_PREM") / star
[docs]
def prem_persistency_base(t):
"""rho^base_t: the base fraction of the planned premium paid in policy year y **[std]**.
1.00 in year 1 grading to 0.85 in year 5 and 0.80 thereafter, read from
*prem_persistency.csv*; policy years beyond the table take its last row. The
levels come from the UL premium persistency study [REG-R21] applied to VUL by
analogy -- no VUL-specific study was retrieved, which the notes flag.
"""
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 prem_persistency(t):
"""rho_t: the fraction of the planned premium actually paid **[std]**.
``rho^base_t x min(1.3, max(0.7, phi_t^-0.25))``: a funding shortfall induces
catch-up funding by retained policyholders, strong performance induces premium
holidays -- the signature flexible-premium behavior the UL studies measure
[REG-R21].
**1.0 when the behavior module is off**, which is the default. The base
deterministic run therefore pays the planned premium in full, which is what the
notes' worked example does ("planned premium $500/month paid"); switching
``dyn_behavior_on`` on brings in both the base persistency scale and the funding
adjustment. ``Term_US_A`` switches conversion off for the same reason.
"""
if not dyn_behavior_on: # noqa: F821
return 1.0
phi = funding_ratio(t)
if phi <= 0:
adj = prem_pers_cap # noqa: F821
else:
adj = min(prem_pers_cap, # noqa: F821
max(prem_pers_floor, phi ** (-prem_pers_delta))) # noqa: F821
return prem_persistency_base(t) * adj
[docs]
def premium_pp(t):
"""P_t: the gross premium per policy paid at the monthiversary of month t.
``LEVEL`` the planned annual premium / 12, times :func:`prem_persistency`.
``SINGLE`` one premium in the issue month.
Zero from attained age 121, when premiums are no longer accepted [S1][S2][S4].
Guideline premium and 7-pay limits are not enforced in the baseline; premiums are
assumed within limits **[std]**.
"""
if age(t) >= charges_cease_age: # noqa: F821
return 0.0
if premium_type() == "SINGLE":
return premium_pp_ann() if duration_mth(t) == 0 else 0.0
elif premium_type() == "LEVEL":
return premium_pp_ann() / 12 * prem_persistency(t)
else:
raise ValueError("invalid premium type")
[docs]
def prem_to_av_pp(t):
"""The net premium credited to the accounts, ``P_t x (1 - gamma)``.
Split by :func:`alloc` and :func:`alloc_fixed`. The net premium is a pass-through
into the policyholder's accounts; the load is insurer revenue.
"""
return premium_pp(t) * (1 - load_prem_rate())
[docs]
def prem_to_av(t):
"""Net premium credited to the accounts, for the policies in force."""
return prem_to_av_pp(t) * pols_if(t)
[docs]
def premiums(t):
"""prem_gross: premium income at BOM of policy month t, weighted by the in force.
The **full** premium, not the load: the net premium is a pass-through into the
accounts and shows up again as an account release when a claim is paid.
"""
return premium_pp(t) * pols_if(t)
[docs]
def premium_loads(t):
"""load_income: the premium load the insurer keeps, ``gamma x P_t x l_t``."""
return load_prem_rate() * premium_pp(t) * pols_if(t)
[docs]
def wd_pp(t):
"""W(t): the partial withdrawal per policy at the monthiversary of month t.
The constant monthly figure in the model point's ``wd_pp`` column, **0 in every
shipped model point**: the notes make withdrawals explicitly none in the baseline,
so the mechanics are implemented and the behavior is left to the data **[std]**.
Not taken after attained age 121.
"""
if age(t) >= charges_cease_age: # noqa: F821
return 0.0
return float(model_point()["wd_pp"])
[docs]
def wd_fee_pp(t):
"""The $25 withdrawal fee, charged only in a month with a withdrawal [S1].
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 wd_sa_pp(t, i):
"""The part of the withdrawal and its fee taken from subaccount i.
Pro rata over the **unloaned** accounts, on balances measured after the premium
**[std]**; the loan account is collateral and is not available. The denominator is
guarded exactly as :func:`mth_deduction_sa_pp` guards it, and for the same reason.
"""
den = unloaned_av_pp_at(t, "BEF_WD")
share = sa_pp_at(t, i, "BEF_WD") / den if den > 0 else alloc(i)
return (wd_pp(t) + wd_fee_pp(t)) * share
[docs]
def wd_fa_pp(t):
"""The part of the withdrawal and its fee taken from the fixed option."""
den = unloaned_av_pp_at(t, "BEF_WD")
share = fa_pp_at(t, "BEF_WD") / den if den > 0 else alloc_fixed()
return (wd_pp(t) + wd_fee_pp(t)) * share
[docs]
def face_reduction_pp(t):
"""The face reduction a withdrawal forces under Option A [S1][S2].
**Proportionate**: the face is cut in the same proportion as the withdrawal bears
to the account value, ``F x W / AV``. Note the contrast with the fixed-UL chassis,
which instead cuts the face by the excess of the withdrawal over a free amount --
the variable-UL notes say proportionate, and that is what is implemented here.
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
av = av_pp_at(t, "BEF_WD")
if av <= 0:
return 0.0
return sum_assured_at(t - 1) * wd_pp(t) / av
[docs]
def sum_assured_at(t):
"""F_t: the 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 unloaned_av_pp_at(t, timing):
"""The unloaned account value: subaccounts plus the fixed option, excluding LA.
This is the base the withdrawal and the monthly deduction are allocated over
**[std]** -- the loan account is collateral for the debt and is not drawn on.
``timing`` takes the :func:`av_pp_at` values other than ``"BEF_PREM"``.
"""
return (sum(sa_pp_at(t, i, timing) for i in subaccount_ids())
+ fa_pp_at(t, timing))
[docs]
def corridor_factor_at(a):
"""kappa: the GPT corridor factor at attained age a [S2][R3].
250% to age 40, 215% at 45, 185% at 50, 150% at 55, 130% at 60, then **grading
linearly to 100% at attained age 95** and level at 100% from there on. The quoted
quinquennial factors are sourced; every age between them is **linear interpolation
[std]**, carried in ``corridor_factors.csv`` to six decimals.
The notes write the tail as "to 100% at 90-95", which does not say which end of
that range reaches 100%; the product spec's footnote 11 does -- *"The reference
model linearly interpolates between the quoted ages and grades to 100% at 95"* --
so the last quoted age (130% at 60) is joined to 100% at 95, not to 100% at 90.
The two readings differ over attained ages 61-94: at age 90 this table gives
``kappa = 1.042857`` where grading to 90 would give 1.00, and a corridor of exactly
1.00 would collapse the net amount at risk to zero on an Option A policy funded
above its face.
Ages outside the table take its first or last row. Interpolating is what keeps the
death benefit continuous: stepping between quinquennial factors would make it jump
on every fifth anniversary, which the notes list among the modeling pitfalls.
"""
tbl = data.corridor_factors() # noqa: F821
a = min(max(a, int(tbl.index.min())), int(tbl.index.max()))
return float(tbl.loc[a, "corridor_factor"])
[docs]
def corridor_factor(t):
"""kappa_t: the corridor factor in policy month t, at the **attained** age.
Only the Guideline Premium Test is modeled; CVAT is a documented variation, so any
other ``qual_test`` raises rather than being treated as GPT.
A model point may pin the factor **in the projection's first month** through the
``corridor_override_m1`` column. Model point 1 does, at 2.15, because the notes'
worked example sits in policy year 3 -- attained age 47, where the rule gives
2.03 -- but quotes ``kappa(45) = 215%``, the **issue**-age factor. The pin
reproduces the worked example's corridor product exactly.
The pin is deliberately confined to ``t == 1``, the one month the worked example
describes. It is a *lookup* that the notes performed at the wrong age, not a
parameter of the contract; holding an issue-age corridor factor across the
seventy-seven years this projection runs would misstate every later month and is no
reading of the notes at all. Model point 2 is the same cell with the pin blank, so
the rule applies from the first month too, and a test holds the gap open in both
directions.
"""
if qual_test() != "GPT":
raise ValueError("invalid qual_test")
o = model_point()["corridor_override_m1"]
if t == 1 and not pd.isna(o): # noqa: F821
return float(o)
return corridor_factor_at(age(t))
[docs]
def db_corridor_pp(t):
"""The corridor minimum death benefit, ``kappa_t x AV'(t)``.
Named because the worked example displays it: at the anchor cell it is
``2.15 x 50,480 = 108,532.00``, which loses to the $500,000 face. The corridor
binds only on heavily funded, older cells.
"""
return corridor_factor(t) * av_pp_at(t, "BEF_FEE")
[docs]
def db_pp(t):
"""DB_t: the death benefit per policy at the monthiversary, after the corridor test.
Option A ``max(F_t, kappa_t x AV'(t))``; Option B ``max(F_t + AV'(t),
kappa_t x AV'(t))``, where ``AV'(t)`` is the account value after premium and
withdrawal and **before** the monthly deduction [S2][R3]. Measuring the account
value at that point is what removes the circularity: 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 ordering neither depends on the deduction.
"""
av = av_pp_at(t, "BEF_FEE")
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, db_corridor_pp(t))
[docs]
def net_amt_at_risk(t):
"""NAAR_t: ``max(0, DB_t - AV'(t))`` [S2]; floor **[std]**.
**No one-month discount.** This is the single sourced deviation from the
fixed-UL chassis, which divides the death benefit by ``1 + i_gm`` first: the VUL
prospectuses define the net amount at risk as death benefit less account value
outright [S2], so there is no ``naar_factor`` in this model. Carrying the chassis
recursion across unexamined would understate the net amount at risk, and with it
the cost of insurance, by about one month's guaranteed interest on the death
benefit every month.
The floor at zero is a standardization: the corridor keeps ``DB >= AV`` in normal
operation, and forgetting the floor is on the notes' pitfall list.
"""
return max(0.0, db_pp(t) - av_pp_at(t, "BEF_FEE"))
[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 notes' anchor cell M / StdNT / 45 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_at(y):
"""The guaranteed maximum monthly COI rate per $1,000 NAAR in policy year y.
An illustrative **[std]** stand-in for the licensed 2017 CSO sex-distinct
smoker/nonsmoker ultimate ANB table the notes require [S2][S4][R12], anchored on
the one disclosed guaranteed point -- male 45 standard non-tobacco, policy year 1
= $0.22 [S4]. Capped at ``coi_rate_cap`` ($83.34, observed $83.33-$83.34 across
filings), the monthly rate that fully consumes the net amount at risk near attained
age 120 [S1][S2][S3][S4]. Policy years beyond the table take its last row.
"""
scale = coi_rate_scale()
y = min(y, int(scale.index.max()))
return min(float(scale[y]), coi_rate_cap) # noqa: F821
[docs]
def coi_rate_guar(t):
"""The guaranteed maximum monthly COI rate in policy month t."""
return coi_rate_guar_at(policy_year(t))
[docs]
def coi_rate_at(y):
"""c: the current monthly COI rate per $1,000 NAAR in policy year y **[std]**.
``coi_curr_factor`` (50%) of the guaranteed maximum -- the notes' stated default
placeholder for the current scale -- bounded above by the guaranteed maximum and by
the $83.34 cap [S2][S4][R12]. Current COI tables are not publicly disclosed; only
minima, maxima and representative points appear in prospectuses, so this factor is
a pure modeling assumption and the notes rank it the second most important
assumption in the model.
Note the units: 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.
"""
rate = coi_curr_factor * coi_rate_guar_at(y) # noqa: F821
return min(rate, coi_rate_guar_at(y), coi_rate_cap) # noqa: F821
[docs]
def coi_rate(t):
"""c_t: the current monthly COI rate in policy month t.
A model point may pin the rate **in the projection's first month** through the
``coi_rate_override_m1`` column. Model point 1 does, at 0.04: that is the year-1
current rate disclosed for this cell [S4], which the notes' worked example applies
in policy year 3, and it is 18% of the year-1 guaranteed $0.22 rather than the 50%
the placeholder assumes. The notes acknowledge the gap themselves -- "disclosed
year-1 current/guaranteed ratios are much lower (select effect)" -- so the
placeholder is conservative early and the select-to-ultimate shape matters.
As with :func:`corridor_factor`, the pin is confined to ``t == 1``: a single
disclosed point is not a scale, and holding $0.04 flat for seventy-seven years
would leave a policy paying almost nothing for its insurance at age 100. Model
point 2 is the same cell with the pin blank and takes the placeholder from the
first month, and a test holds the gap open in both directions.
"""
o = model_point()["coi_rate_override_m1"]
if t == 1 and not pd.isna(o): # noqa: F821
return min(float(o), coi_rate_guar(t), coi_rate_cap) # noqa: F821
return coi_rate_at(policy_year(t))
[docs]
def coi_pp(t):
"""COI_t: the cost of insurance charge per policy, ``c_t / 1000 x NAAR_t``.
Zero from attained age 121, when monthly deductions cease [S1][S2][S4].
The rate here is the **current COI scale** -- insurer revenue, a non-guaranteed
element under ASOP 2 [R11]. It is not the death decrement: that is
:func:`mort_rate`, best-estimate experience. The notes are emphatic that the two
must never be conflated, and they are deliberately different tables here.
"""
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 baseline **[std]**.
The no-lapse guarantee and overloan protection riders are documented variations
that the notes exclude from the baseline; this term is carried in the monthly
deduction so a rider module can be added without changing the recursion.
"""
return 0.0
[docs]
def maint_fee_pp(t):
"""The non-COI part of the monthly deduction per policy.
``e_pol + e_face x U + rc(t)``: the $10.00 per-policy administrative charge
[S2][S4] and the $0.20 per $1,000 of **initial** face charge [S2], both level in
all years, plus rider charges. Zero from attained age 121, when deductions cease
[S1][S2][S4].
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
return (expense_pol_mth + expense_unit_mth * units() # noqa: F821
+ rider_charge_pp(t))
[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 the monthiversary.
``COI_t + e_pol + e_face x F_0/1000``, i.e. :func:`maint_fee_pp` plus
:func:`coi_pp`. The M&E charge is **not** part of it -- that is collected inside
the unit values by :func:`inv_return_mth`, and taking it in both places is on the
notes' pitfall list.
"""
return maint_fee_pp(t) + coi_pp(t)
[docs]
def mth_deduction(t):
"""md_income: monthly deductions collected, for the policies in force."""
return mth_deduction_pp(t) * pols_if(t)
[docs]
def mth_deduction_sa_pp(t, i):
"""The monthly deduction's pro-rata share taken from subaccount i **[std]**.
``MD_t x SA_i / sum(unloaned)``, on balances measured after the premium and the
withdrawal. The loan account is excluded because it is collateral.
The denominator is guarded -- the notes list an unguarded one among the modeling
pitfalls -- and the guard falls back to the **premium allocation** shares
``alpha_i`` **[std]**. That keeps the full deduction applied, so the account value
roll-forward stays exact, and it matters only once the unloaned balance has run to
zero or below, which is well past the month :func:`is_shortfall` first fires. The
notes would have defaulted the policy there; the grace cascade is not implemented,
so the arithmetic simply continues, as it does on the fixed-UL chassis. Whatever
fallback is chosen, the shares sum to one, which is what the identity needs.
"""
den = unloaned_av_pp_at(t, "BEF_FEE")
share = sa_pp_at(t, i, "BEF_FEE") / den if den > 0 else alloc(i)
return mth_deduction_pp(t) * share
[docs]
def mth_deduction_fa_pp(t):
"""The monthly deduction's pro-rata share taken from the fixed option **[std]**."""
den = unloaned_av_pp_at(t, "BEF_FEE")
share = fa_pp_at(t, "BEF_FEE") / den if den > 0 else alloc_fixed()
return mth_deduction_pp(t) * share
[docs]
def sa_pp_at(t, i, timing):
"""Subaccount i's value per policy at an intra-month point of policy month t.
The 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.
``"BEF_WD"``
After this subaccount's share ``alpha_i`` of the net premium.
``"BEF_FEE"``
After the withdrawal and its fee, before the monthly deduction. Summed across
the accounts this is the notes' post-premium value ``AV'``, the balance the
death benefit, corridor test and net amount at risk are all measured against.
``"BEF_INV"``
After the monthly deduction, before growth. Growth applies to this
post-deduction balance; reversing the two overstates the account value by about
one month's return on the deduction every month.
``"BEF_ME"``
After the gross return and the fund expense, before the M&E charge. This is
the balance the M&E charge is taken from, so it is what :func:`me_charge_pp`
measures.
The end-of-month balance is :func:`sa_pp`.
"""
if timing == "BEF_PREM":
return sa_pp(t - 1, i)
elif timing == "BEF_WD":
return sa_pp_at(t, i, "BEF_PREM") + alloc(i) * prem_to_av_pp(t)
elif timing == "BEF_FEE":
return sa_pp_at(t, i, "BEF_WD") - wd_sa_pp(t, i)
elif timing == "BEF_INV":
return sa_pp_at(t, i, "BEF_FEE") - mth_deduction_sa_pp(t, i)
elif timing == "BEF_ME":
return (sa_pp_at(t, i, "BEF_INV") * (1 + gross_return_mth(t, i))
* (1 - fund_expense_ann(i) / 12))
else:
raise ValueError("invalid timing")
[docs]
def sa_pp(t, i):
"""SA_{i,t}: subaccount i's value per policy at the end of policy month t.
``SA_i(0) = sa_pp_init(i)``; thereafter the post-deduction balance times the
unit-value factor ``(1 + r)(1 - e_i/12)(1 - m/12)``, which is
``1 + inv_return_mth(t, i)``. Separate-account assets: the policyholder bears the
investment experience and the insurer's general account does not.
"""
if t == 0:
return sa_pp_init(i)
return sa_pp_at(t, i, "BEF_ME") * (1 - me_rate_ann / 12) # noqa: F821
[docs]
def fa_pp_at(t, timing):
"""The fixed-option value per policy at an intra-month point of policy month t.
``timing`` takes the same values as :func:`sa_pp_at` except ``"BEF_ME"``: the
fixed option is a general-account balance and bears neither fund expenses nor the
M&E charge.
"""
if timing == "BEF_PREM":
return fa_pp(t - 1)
elif timing == "BEF_WD":
return fa_pp_at(t, "BEF_PREM") + alloc_fixed() * prem_to_av_pp(t)
elif timing == "BEF_FEE":
return fa_pp_at(t, "BEF_WD") - wd_fa_pp(t)
elif timing in ("BEF_INV", "BEF_ME"):
return fa_pp_at(t, "BEF_FEE") - mth_deduction_fa_pp(t)
else:
raise ValueError("invalid timing")
[docs]
def fa_pp(t):
"""FA_t: the fixed-option value per policy at the end of policy month t.
``FA(0) = fa_pp_init()``; thereafter ``FA'(t) x (1 + i_fix)^(1/12)`` at the
declared rate, floored at the contractual 1.0% [S1]. A general-account liability,
unlike the subaccounts.
"""
if t == 0:
return fa_pp_init()
return fa_pp_at(t, "BEF_INV") * (1 + fixed_return_mth(t))
[docs]
def la_pp(t):
"""LA_t: the loan-account collateral per policy at the end of policy month t [S3].
``LA(0) = loan_bal_init()`` **[std]** -- a loan moves value out of the investment
options into a general-account loan account, so collateral and debt coincide at the
outset. Thereafter it earns the credited loan rate ``i_C``, **not** fund returns,
which is why it is held apart from the subaccounts and excluded from the pro-rata
deduction base. It is part of the account value; the debt is not.
New loans and repayments are not modeled -- the notes give no utilization pattern
-- so this only rolls the model point's opening collateral forward.
"""
if t == 0:
return loan_bal_init()
return la_pp(t - 1) * (1 + loan_cr_rate_mth(t))
[docs]
def loan_bal_pp(t):
"""D_t: the outstanding policy debt per policy at the end of policy month t.
``D(0) = loan_bal_init()``; thereafter ``D(t-1) x (1 + i_L)^(1/12)``, the charged
rate, monthly **[std]** where the contract charges interest annually in arrears and
capitalizes it if unpaid [S1]. Debt reduces both the death benefit and the
surrender value; it grows faster than the collateral in :func:`la_pp`, and the
difference is the insurer's :func:`loan_spread`.
Note the notes' indexing: their ``D_{t+1}`` is this end-of-month balance of month
``t``, which is why the death claim is ``DB_t^EOM - D_{t+1}``.
"""
if t == 0:
return loan_bal_init()
return loan_bal_pp(t - 1) * (1 + loan_rate_mth(t))
[docs]
def loan_spread(t):
"""loan_spread: the insurer's margin on policy debt, for the policies in force.
``l_t x D(t-1) x [(1 + i_L)^(1/12) - (1 + i_C)^(1/12)]`` -- one month of the
charged rate less one month of the credited rate on the opening debt. 1.0% a year
in policy years 1-9 and 0.05% from the 10th [S1].
"""
return (loan_bal_pp(t - 1)
* (loan_rate_mth(t) - loan_cr_rate_mth(t)) * pols_if(t))
[docs]
def av_pp_at(t, timing):
"""AV per policy at an intra-month point of policy month t.
``sum(SA_i) + FA + LA``. The loan account does not move intra-month -- it accrues
only at end of month -- so it enters at its opening balance whatever the
``timing``, which takes the :func:`sa_pp_at` values. An unknown ``timing`` raises
from :func:`sa_pp_at`.
"""
if timing == "BEF_PREM":
return av_pp(t - 1)
return unloaned_av_pp_at(t, timing) + la_pp(t - 1)
[docs]
def av_pp(t):
"""AV_t: the total account value per policy at the end of policy month t.
``sum(SA_i) + FA + LA`` [S1][S2][S3][S4]. ``AV(0) = av_pp_init()`` by
construction, since each component starts at its own opening balance.
"""
return (sum(sa_pp(t, i) for i in subaccount_ids())
+ fa_pp(t) + la_pp(t))
[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 -- the notes' ``av_eop`` reconciliation column.
"""
if timing == "EOM":
return av_pp(t) * pols_if(t)
return av_pp_at(t, timing) * 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 me_charge_pp(t, i=None):
"""me_income per policy: the M&E risk charge collected inside the unit values.
``SA_i (after the gross return and the fund expense) x m / 12`` for subaccount
``i``, or the total over the lineup when ``i`` is ``None``. 0.45% a year current
[S1], under a 0.60% guaranteed ceiling **[std]**.
This is insurer revenue taken from separate-account assets, and it is the only
charge in this model that is *not* an explicit deduction -- which is why it has to
be reported separately for the net-of-account view to reconcile. It continues
after attained age 121, when the monthly deduction stops [S1][S2][S4].
"""
if i is None:
return sum(me_charge_pp(t, j) for j in subaccount_ids())
return sa_pp_at(t, i, "BEF_ME") * me_rate_ann / 12 # noqa: F821
[docs]
def me_charge(t):
"""me_income: the M&E risk charge collected, for the policies in force."""
return me_charge_pp(t) * pols_if(t)
[docs]
def inv_income_pp(t):
"""The total investment credit to the accounts per policy over policy month t.
The subaccount growth **net** of fund expenses and the M&E charge, plus the
fixed-option interest, plus the loan-account interest. Because it is net, the
insurer's M&E revenue is *not* in it: :func:`me_charge_pp` reports that separately
and :func:`check_net_view` puts the two back together.
"""
return (sum(sa_pp(t, i) - sa_pp_at(t, i, "BEF_INV")
for i in subaccount_ids())
+ fa_pp(t) - fa_pp_at(t, "BEF_INV")
+ la_pp(t) - la_pp(t - 1))
[docs]
def inv_income(t):
"""Investment credit to the accounts, for the policies in force.
Decrements fall after the credit, so every policy in force at BOM earns a full
month of it.
"""
return inv_income_pp(t) * pols_if(t)
[docs]
def db_pp_eom(t):
"""DB_t^EOM: the death benefit recomputed on end-of-month balances **[std]**.
The notes weight the death claim by ``l_t q^d_t`` at end of month and recompute the
option and corridor formula on the end-of-month account value, so a death in month
``t`` is paid on ``DB_t^EOM``, not on the monthiversary figure that priced the
month's cost of insurance.
"""
av = av_pp(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_eom(t):
"""NAAR_t^EOM: ``max(0, DB_t^EOM - AV_t)``, the net general-account strain.
This, not the death benefit, is what the insurer's own funds have to find when a
policy dies: the account value is seized to fund the rest. It is the notes'
``claim_net`` before survivorship weighting -- and it is emphatically **not** the
claim cash flow; see :func:`claims`.
"""
return max(0.0, db_pp_eom(t) - av_pp(t))
[docs]
def surr_charge_rate(t):
"""SC per $1,000 of initial face in policy year y **[std]**.
``sc_init x (runoff_years + 1 - y) / runoff_years``, floored at zero: $18.00 per
$1,000 in policy year 1, declining **by policy year** to zero at the end of policy
year 14. In policy year 3 that is ``18.00 x 12/14 = 15.428571``, the factor the
worked example quotes.
Note the contrast with the fixed-UL chassis, whose surrender charge amortizes every
*month*. The variable-UL notes step it by policy year, and the worked example pins
the step: carrying the chassis's monthly run-off across would give
``18 x (1 - 25/168)`` here instead.
"""
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 = int(row["runoff_years"])
return max(0.0, init * (yrs + 1 - policy_year(t)) / yrs)
[docs]
def surr_charge_pp(t):
"""SC_t: the surrender charge scheduled per policy in policy month t.
Quoted on the **initial** face amount [S1][S2]. This is the schedule; the amount
actually collected is :func:`surr_charge`, which is capped by the account value.
"""
return surr_charge_rate(t) * units()
[docs]
def csv_pp(t):
"""``AV_t - SC_t``: the cash value before policy debt, floored at zero **[std]**.
The chassis name for this intermediate. The floor is a standardization -- a
negative cash value would be a payment *from* the policyholder -- and it binds in
the early policy years, where the scheduled surrender charge exceeds the account
value, so the charge actually collected is the whole account value. The notes'
own ``CSV_t`` is net of debt: that is :func:`ncsv_pp`.
"""
return max(0.0, av_pp(t) - surr_charge_pp(t))
[docs]
def ncsv_pp(t):
"""CSV_t: the cash surrender value, ``AV_t - SC_t - D_t``, floored at zero [S1].
What a surrendering policyholder is paid, and the notes' surrender outgo. Note
the notes' indexing: their ``AV_{t+1} - SC_t - D_{t+1}`` is this end-of-month
quantity of month ``t``.
"""
return max(0.0, csv_pp(t) - loan_bal_pp(t))
[docs]
def surr_charge(t):
"""sc_income: surrender charges collected from the policies lapsing in month t.
``(AV_t - csv_pp(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_default(t):
"""The notes' default test: ``AV_t - SC_t - D_t <= 0`` at the end of month t [S1].
The contractual trigger for grace, and the same test as the excess-debt default
(debt at or above fund less surrender charge) [S1]. It is a **diagnostic only**:
the notes lapse a defaulted policy "at the next monthiversary if not cured" without
defining the cure test, the in-grace deduction accrual or the death benefit during
grace, so the grace cascade is not implemented and no policy is terminated for
insufficiency here.
Read literally the test is true from **issue** on any front-loaded design: in
policy year 1 the scheduled $18 per $1,000 surrender charge is far larger than the
account value a first premium buys, so ``AV - SC`` is negative on a perfectly
healthy new policy. That is what model point 3 shows. The test is reported as the
notes write it, and :func:`is_shortfall` is the companion diagnostic that answers
the question the default rule is really asking.
"""
return av_pp(t) - surr_charge_pp(t) - loan_bal_pp(t) <= 0
[docs]
def is_shortfall(t):
"""The deduction-shortfall test: the unloaned accounts cannot pay ``MD_t``.
``unloaned_av_pp_at(t, "BEF_FEE") < mth_deduction_pp(t)``. The fixed-UL chassis
makes this its grace trigger, and it is the point at which the account genuinely
stops being able to carry the contract, as opposed to the notes' literal
surrender-charge test in :func:`is_default`. A diagnostic; nothing terminates.
"""
return unloaned_av_pp_at(t, "BEF_FEE") < mth_deduction_pp(t)
[docs]
def first_default_month():
"""The first policy month in which :func:`is_default` is true, or 0 if never.
On a front-loaded model point this is usually month 1; see :func:`is_default`.
"""
for t in range(1, proj_len() + 1):
if is_default(t):
return t
return 0
[docs]
def first_shortfall_month():
"""The first policy month in which :func:`is_shortfall` is true, or 0 if never.
The month past which the projection is arithmetic rather than a description of a
live contract: the notes' default rule would have terminated the policy at the
following monthiversary, and that cascade is not implemented. A level-premium cell
whose cost of insurance eventually outruns its premium reaches this point at some
late duration; run it to see where.
"""
for t in range(1, proj_len() + 1):
if is_shortfall(t):
return t
return 0
[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):
"""q^d,annual: the annual best-estimate mortality rate in policy month t.
Base table times :func:`class_factor` times the A/E factor, 100% in the base run
**[std]** with no mortality improvement. The shipped table is a small illustrative
one **[std]**, *not* the 2015 VBT calibrated to ILEC experience the notes recommend
[REG-R18][REG-R19] -- that family is licensed and may not be reproduced here. Ages
beyond the table take its last row, where the rate is 1.0.
This is the **death decrement**, best-estimate experience. It is not
:func:`coi_rate`, the current COI scale, which is a revenue item. Conflating them
is the first entry on the notes' list of modeling pitfalls, and the two tables
shipped here are deliberately different.
"""
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^d_t: the monthly mortality rate, ``1 - (1 - q^d,annual)^(1/12)`` **[std]**."""
return 1 - (1 - mort_rate(t)) ** (1 / 12)
[docs]
def lapse_rate_base(t):
"""q^w,base: 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 levels
come from the LIMRA/SOA UL persistency and lapse studies [REG-R20][REG-R21] applied
to VUL **by analogy** -- VUL is not broken out separately in them, which the notes
flag -- and the detailed tables are behind a paid package, so the numbers 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_month():
"""The policy month of the surrender-charge cliff, counted from issue.
``12 x runoff_years + 1``: the first month in which the surrender charge is zero,
which for the shipped fourteen-year schedule is policy month 169. Derived from
*surr_charge_table.csv* rather than hard-coded, so a different schedule moves the
cliff with it. Zero when the model point carries no surrender charge, which no
policy month can equal.
"""
if not has_surr_charge():
return 0
yrs = int(data.surr_charge_table().loc[surr_charge_id(), "runoff_years"]) # noqa: F821
return 12 * yrs + 1
[docs]
def lapse_rate_sc_mult(t):
"""The surrender-charge cliff spike multiplier **[std]**.
Applied in :func:`lapse_shock_month` alone -- the notes make it a *one-month* spike
"in the month after SC_t reaches zero", not a whole shock year as on the fixed-UL
chassis. The notes call the spike optional and its magnitude an input, so
``lapse_shock_mult`` ships at 1.0, i.e. off; set it to 2.0 to switch it on.
"""
if not has_surr_charge():
return 1.0
if duration_mth(t) + 1 == lapse_shock_month():
return lapse_shock_mult # noqa: F821
return 1.0
[docs]
def lapse_rate_dyn_mult(t):
"""lambda_t: the dynamic lapse multiplier **[std]**.
``min(2.0, max(0.5, 1 + beta (1 - phi_t)))`` with ``beta = 0.5``: a performance
shortfall raises the premium needed to sustain coverage and pushes marginal
policyholders to lapse, while overfunded policies are stickier. The bounds stop
extreme extrapolation. No public VUL dynamic-behavior study was retrieved, so the
form is standardized with the notes' rationale.
**1.0 when the behavior module is off**, which is the default -- see
:func:`prem_persistency`.
"""
if not dyn_behavior_on: # noqa: F821
return 1.0
raw = 1 + lapse_dyn_beta * (1 - funding_ratio(t)) # noqa: F821
return min(lapse_dyn_cap, max(lapse_dyn_floor, raw)) # noqa: F821
[docs]
def lapse_rate(t):
"""q^w,annual: the total annual lapse rate **[std]**.
``q^w,base x cliff spike x lambda_t``. The notes set no cap on the product, unlike
the fixed-UL chassis's 35% **[std]** cap, and none is imposed: the dynamic
multiplier is already bounded at 2.0.
"""
return lapse_rate_base(t) * lapse_rate_sc_mult(t) * lapse_rate_dyn_mult(t)
[docs]
def lapse_rate_mth(t):
"""q^w_t: the monthly lapse rate, ``1 - (1 - q^w,annual)^(1/12)`` **[std]**."""
return 1 - (1 - lapse_rate(t)) ** (1 / 12)
[docs]
def pols_if(t):
"""l_t: the number of policies in force at the beginning of policy month t.
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()`` and ``l_{t+1} = l_t (1 - q^d_t)(1 - q^w_t)``, which is the
subtraction below.
"""
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.
"""
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, ``l_t x q^d_t``."""
return pols_if(t) * mort_rate_mth(t)
[docs]
def pols_lapse(t):
"""Number of surrenders at the end of policy month t.
``l_t x (1 - q^d_t) x q^w_t``: death is applied before lapse **[std order]**,
matching the notes' ``l_{t+1} = l_t (1 - q^d_t)(1 - q^w_t)``.
"""
return pols_if(t) * (1 - mort_rate_mth(t)) * lapse_rate_mth(t)
[docs]
def pols_maturity(t):
"""Number of maturing policies: always zero.
Variable universal life has no maturity date -- at attained age 121 premiums and
monthly deductions cease, the asset charges continue, and coverage runs to death or
surrender [S1][S2][S4]. 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^EOM - D_t``: the **full death benefit** less outstanding policy debt
[S1][S3]. Not ``DB - AV``. The notes carry an explicit warning here: the
insurer's liability outflow is the whole death benefit, and seizing the account
value is the *funding* of part of it. ``DB - AV`` is the net
general-account strain, reported separately as :func:`claims_net`.
``"LAPSE"``
``ncsv_pp(t)``, the cash surrender value net of debt and the surrender charge.
``"WITHDRAWAL"``
``W(t)``. The $25 fee is retained by the insurer and is not part of the
payment. A withdrawal is a payment on the owner's election rather than a
claim, so it is *not* one of the ``kind`` values :func:`claims` accepts; this
branch is the per-policy amount :func:`withdrawals` weights by
:func:`pols_if`.
"""
if kind == "DEATH":
return db_pp_eom(t) - loan_bal_pp(t)
elif kind == "LAPSE":
return ncsv_pp(t)
elif kind == "WITHDRAWAL":
return wd_pp(t)
else:
raise ValueError("invalid kind")
[docs]
def claims_from_av(t, kind):
"""The part of a claim funded by releasing the account value, by ``kind``.
Death and surrender both release the end-of-month account value ``AV_t``, because
decrements follow the investment 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. It differs from :func:`claims_net` by the
debt extinguished on death, which the account release also covers.
"""
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`` -- the **gross view**.
``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** claims and are **not** in the ``kind is None``
total: a withdrawal is a payment on the owner's election rather than an event that
terminates coverage, and it is reported by :func:`withdrawals` in its own column.
``"WITHDRAWAL"`` therefore raises here, while remaining a valid ``kind`` of
:func:`claim_pp`, which carries the per-policy amount.
``claims(t, "DEATH")`` is the notes' ``claim_gross``. Projecting the net amount at
risk here instead understates gross benefit outgo and breaks reconciliation with
statutory exhibits; projecting the full death benefit here *and* separately
expensing the net amount at risk double counts. The general-account view is
derived from the same run in :func:`result_net`.
"""
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 claims_net(t):
"""claim_net: the net general-account cost of the deaths in policy month t.
``l_t x q^d_t x NAAR_t^EOM`` -- the death benefit less the account value seized to
fund it. A **derived report**, not a cash flow: it belongs to the net-of-account
view in :func:`result_net`, never to :func:`result_cf`.
"""
return net_amt_at_risk_eom(t) * pols_death(t)
[docs]
def withdrawals(t):
"""Partial withdrawal payments in policy month t, for the policies in force.
``claim_pp(t, "WITHDRAWAL") x l_t`` -- the withdrawal is taken at the monthiversary
by policies still in force, so it is weighted by :func:`pols_if` and not by a
decrement. Zero in every shipped model point, which is the notes' baseline.
A payment on the owner's election, not a claim: it terminates nothing and it is
reported in its own ``withdrawals`` column of :func:`result_cf`, outside
:func:`claims` and outside the ``kind is None`` claim total. The $25 fee is
retained by the insurer and is :func:`wd_fees`, not part of this payment.
"""
return claim_pp(t, "WITHDRAWAL") * 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 sa_transfer(t):
"""sa_transfer: separate-account value released to the general account (memo).
On death the subaccount values move separate account to general account; on
surrender the separate account liquidates to fund the cash surrender value. The
fixed option and the loan account release internally and are not part of this. A
memo column for reconciliation, not a liability cash flow.
"""
return (sum(sa_pp(t, i) for i in subaccount_ids())
* (pols_death(t) + pols_lapse(t)))
[docs]
def inflation_factor(t):
"""The expense inflation factor, ``(1 + inflation_rate)^(y - 1)``.
``inflation_rate`` is **0.0** here: the variable-UL notes specify a flat $75 per
policy per year maintenance expense **[std]** with no inflation, where the fixed-UL
chassis inflates its own at 2.5%. The cells is kept so the two models read alike
and so an inflation assumption can be switched on with one Reference.
"""
return (1 + inflation_rate) ** (policy_year(t) - 1) # noqa: F821
[docs]
def expenses(t):
"""The insurer's own maintenance expenses in policy month t **[std]**.
``expense_maint / 12`` per policy in force, $75 a year, plus ``expense_acq`` in the
issue month. The notes specify no acquisition expense for this product -- the
premium load and the surrender charge are the contractual acquisition-cost
recovery, which is income, not outgo -- so ``expense_acq`` is zero and the term is
carried only so a user can switch it on.
Not to be confused with :func:`maint_fee`, which is the charge *against the account
value*. Internal expense assumptions are not public, so both figures are
placeholders.
"""
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):
"""The percent-of-premium collection expense, 2% of premium **[std]**.
The chassis name; for this product the notes call it a premium collection expense
rather than a premium tax, but it is the same percent-of-premium line.
"""
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.
``premium loads + withdrawal fees + maint_fee + surrender charges - expenses
- premium taxes``. Follows ``CashValue_SE.margin_expense``. The M&E charge is
*not* in it -- it is an asset-based charge on separate-account assets, reported by
:func:`me_charge` and reconciled in :func:`check_net_view`.
"""
return (premium_loads(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`.
The gap between the cost of insurance *charged* on the current COI scale and the
cost of the deaths that actually occur on best-estimate mortality -- the two bases
the notes insist must never be conflated, meeting here and only here.
"""
return coi(t) - claims_over_av(t)
[docs]
def net_cf(t):
"""NetCF(t): net liability cash flow in policy month t, **undiscounted**.
``premiums - claims (death and surrender) - withdrawals - expenses - premium
taxes``: the **gross (policyholder) view**, which the notes make the reference
model's primary projection. Withdrawals are a separate term because they are not
claims -- :func:`claims` no longer carries them -- and dropping them here would
lose an outgo the account value has already released. 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 credit on the account value is the policyholder's, not an
insurer cash flow, so it does not appear either -- see :func:`check_margin` and
:func:`check_net_view` for how it reconciles.
"""
return (premiums(t) - claims(t) - withdrawals(t)
- expenses(t) - premium_taxes(t))
[docs]
def net_cf_ga(t):
"""The net-of-account (general-account strain) view of policy month t.
``load_income + md_income + me_income + loan_spread + sc_income - claim_net
- expense``, the notes' reconciliation identity. A **derived report** from the
same run as :func:`net_cf`, not a second projection: :func:`check_net_view` shows
the gross view reproduces it once the account pass-throughs are added back.
"""
return (premium_loads(t) + mth_deduction(t) + me_charge(t)
+ loan_spread(t) + surr_charge(t)
- claims_net(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: that the deduction comes out before growth,
that growth applies to the post-deduction balance, that the M&E charge is inside
the investment credit rather than a second deduction, and that the 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 credit; 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 -- the
early-duration months of a new-business point have the surrender charge above the
account value, but they carry no debt, so it holds 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 check_net_view():
"""Check the gross view against the net-of-account view.
Returns ``True`` when, for every projected month::
net_cf(t) == net_cf_ga(t)
+ av_change(t) - inv_income(t) + wd_fees(t)
- me_charge(t) - loan_spread(t)
+ loan_bal_pp(t) * (pols_death(t) + pols_lapse(t))
which is the notes' "the gross view must reproduce it after adding back the account
pass-throughs". Reading the correction terms: ``av_change - inv_income`` is the
net premium in and the account releases out; ``- me_charge`` and ``- loan_spread``
remove the two margins the gross view never sees because they are collected inside
the accounts; the debt term is the policy debt extinguished against the account
value on death and on surrender.
Same floor caveat as :func:`check_margin`.
"""
res = []
for t in range(1, proj_len() + 1):
rhs = (net_cf_ga(t)
+ av_change(t) - inv_income(t) + wd_fees(t)
- me_charge(t) - loan_spread(t)
+ loan_bal_pp(t) * (pols_death(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 the gross-view cash flows, 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 net to ``net_cf`` under the library's
income-positive sign; ``pols_if`` is a policy count, not a cash 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, 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 follow the worked example in the technical notes: the two subaccount
balances at the start of the month, the net premium, the post-premium account
value, the death benefit and net amount at risk it is measured against, the cost of
insurance and the monthly deduction, the post-deduction balance, the two
end-of-month subaccount balances and the total, then the M&E collected, the
surrender charge, the cash surrender value and the end-of-month death benefit and
net amount at risk.
"""
ts = list(range(1, proj_len() + 1))
ids = subaccount_ids()
out = {}
for i in ids:
out["sa{}_bef_prem".format(i)] = [sa_pp_at(t, i, "BEF_PREM") for t in ts]
out["fa_bef_prem"] = [fa_pp_at(t, "BEF_PREM") for t in ts]
out["prem_to_av_pp"] = [prem_to_av_pp(t) for t in ts]
out["av_pp_bef_fee"] = [av_pp_at(t, "BEF_FEE") for t in ts]
out["db_pp"] = [db_pp(t) for t in ts]
out["net_amt_at_risk"] = [net_amt_at_risk(t) for t in ts]
out["coi_pp"] = [coi_pp(t) for t in ts]
out["mth_deduction_pp"] = [mth_deduction_pp(t) for t in ts]
out["av_pp_bef_inv"] = [av_pp_at(t, "BEF_INV") for t in ts]
for i in ids:
out["sa{}_pp".format(i)] = [sa_pp(t, i) for t in ts]
out["fa_pp"] = [fa_pp(t) for t in ts]
out["av_pp"] = [av_pp(t) for t in ts]
out["me_charge_pp"] = [me_charge_pp(t) for t in ts]
out["surr_charge_pp"] = [surr_charge_pp(t) for t in ts]
out["ncsv_pp"] = [ncsv_pp(t) for t in ts]
out["loan_bal_pp"] = [loan_bal_pp(t) for t in ts]
out["db_pp_eom"] = [db_pp_eom(t) for t in ts]
out["net_amt_at_risk_eom"] = [net_amt_at_risk_eom(t) for t in ts]
return pd.DataFrame(out, index=pd.Index(ts, name="t")) # noqa: F821
[docs]
def result_net():
"""Result table of the net-of-account (general-account strain) view.
The notes' derived report: the margins the insurer collects, the net mortality
cost, and the memo columns for reconciliation. Column names are the notes' own.
"""
ts = list(range(1, proj_len() + 1))
return pd.DataFrame( # noqa: F821
{
"prem_gross": [premiums(t) for t in ts],
"load_income": [premium_loads(t) for t in ts],
"md_income": [mth_deduction(t) for t in ts],
"me_income": [me_charge(t) for t in ts],
"loan_spread": [loan_spread(t) for t in ts],
"claim_gross": [claims(t, "DEATH") for t in ts],
"claim_net": [claims_net(t) for t in ts],
"surr_outgo": [claims(t, "LAPSE") for t in ts],
"sc_income": [surr_charge(t) for t in ts],
"expense": [expenses(t) + premium_taxes(t) for t in ts],
"sa_transfer": [sa_transfer(t) for t in ts],
"av_eop": [av_at(t, "EOM") for t in ts],
"naar": [net_amt_at_risk_eom(t) for t in ts],
"pols_if": [pols_if(t) for t in ts],
"net_cf_ga": [net_cf_ga(t) for t in ts],
},
index=pd.Index(ts, name="t"), # noqa: F821
)
# ---------------------------------------------------------------------------
# References
data = ("Interface", ("..", "Data"), "auto")
point_id = 1
omega_age = 121
charges_cease_age = 121
expense_pol_mth = 10.0
expense_unit_mth = 0.2
me_rate_ann = 0.0045
coi_curr_factor = 0.5
coi_rate_cap = 83.34
guar_rate_ann = 0.01
crediting_rate_curr = 0.01
loan_rate_ann_std = 0.02
loan_rate_ann_pref = 0.0105
loan_cr_rate_ann_lvl = 0.01
loan_pref_year = 10
wd_fee = 25.0
mort_ae_factor = 1.0
lapse_shock_mult = 1.0
dyn_behavior_on = False
pricing_return_ann = 0.06
lapse_dyn_beta = 0.5
lapse_dyn_floor = 0.5
lapse_dyn_cap = 2.0
prem_pers_delta = 0.25
prem_pers_floor = 0.7
prem_pers_cap = 1.3
expense_acq = 0.0
expense_maint = 75.0
inflation_rate = 0.0
premium_tax_rate = 0.02
pd = ("Module", "pandas")
math = ("Module", "math")