Source code for uslib.products.whole_life.WholeLife_US_A.Projection

# modelx: pseudo-python
# This file is part of a modelx model.
# It can be imported as a Python module, but functions defined herein
# are model formulas and may not be executable as standard Python.

"""The by-policy projection of the :mod:`~.WholeLife_US_A` model.

The Space is parameterized by ``point_id``, so ``Projection[1]`` is an ItemSpace
projecting model point 1::

    >>> Projection[1].result_cf()          # the worked example's 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/whole_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
``WholeLife_US_A`` folder without its parent's CSVs produces a model that reads and then
fails on first evaluation.

Each table has a filename Reference and a reader Cells, both on the ``Data`` Space,
reached here through the ``data`` Reference:

======================  ==============================  ==========================
Reference               Cells                           File
======================  ==============================  ==========================
model_point_file        data.model_point_table()        model_point_table.csv
cv_file                 data.cv_table()                 cv_table.csv
nsp_file                data.nsp_table()                nsp_table.csv
np_guar_file            data.np_guar_table()            np_guar_table.csv
mort_table_file         data.mort_table()               mort_table.csv
premium_rates_file      data.premium_rates()            premium_rates.csv
======================  ==============================  ==========================

.. rubric:: Naming

Cells names follow lifelib's ``basiclife.BasicTerm_S`` and ``savings.CashValue_SE``
wherever those models have an analogue — ``pols_*`` for policy counts, plural nouns
for cash flows, ``*_rate`` for rates, ``*_pp`` for per-policy amounts,
``claim_pp(t, kind)`` and ``pols_if_at(t, timing)`` for the argument-keyed families.
The technical notes use compact actuarial symbols instead. The mapping is:

=========================  ============================  ==========================
Notes symbol               Cells                         Meaning
=========================  ============================  ==========================
x                          age_at_entry                  Issue age (ANB)
x + t - 1                  age(t)                        Attained age at BOY of year t
x + t                      age_anniv(t)                  Attained age at anniversary t
T = 100 - x                proj_len                      Last policy year
t0                         duration_inforce              Durations already elapsed
t0 + 1                     proj_start                    First projected policy year
m                          policy_term                   Premium-paying period, years
F                          sum_assured                   Base face amount
G                          premium_pp(t)                 Gross annual premium
G^net_t                    premium_net_pp(t)             Premium after dividend offset
A_t                        rider_premium_pp(t)           PUA rider premium
i_g                        int_rate_guar                 Guarantee interest, 4.00%
i_d                        int_rate_div                  Dividend interest rate, 6.00%
i_L                        int_rate_loan                 Policy loan rate, 6.00%
q^g_{x+t-1}                mort_rate_guar(t)             Guarantee mortality
q^g_y                      mort_rate_guar_at(y)          The same, keyed by age
q^sc_{x+t-1}               mort_rate_scale(t)            Dividend-scale mortality
q^e_{x+t-1}                mort_rate(t)                  Best-estimate mortality
w_t                        lapse_rate(t)                 Surrender rate
w^dyn multiplier           dyn_lapse_factor(t)           Interest-sensitive overlay
l_{t-1}                    pols_if(t)                    In force at BOY t (weight)
l_t                        pols_if_at(t, "AFT_DECR")     In force at EOY t
l_{t-1}(1-q^e)             pols_if_at(t, timing)         In force inside year t
(none)                     pols_death(t)                 Deaths in year t
(none)                     pols_lapse(t)                 Surrenders at EOY t
(none)                     pols_maturity(t)              Maturities at T
CV_t                       cv_pp(t)                      Guaranteed cash value
F - CV_t                   net_amt_at_risk(t)            Guarantee net amount at risk
NSP_y                      nsp(y)                        Net single premium, endow 100
NP_g                       np_guar()                     Nonforfeiture net level premium
D^int_t                    div_int(t)                    Interest margin
D^mort_t                   div_mort(t)                   Mortality margin
D^exp_t (e^m_t)            div_exp(t)                    Expense margin
D_t                        div_base(t)                   Base-block dividend, floored
D^PUA_t                    div_pua(t)                    PUA-block dividend
D_t + D^PUA_t              div_credited(t)               Dividend credited at EOY t
D^cash_t                   div_cash(t)                   Cash dividend per policy
(none)                     div_to_pua(t)                 Dividend applied to PUAs
dPUAF_t                    pua_face_purch(t)             PUA face bought by dividend
dPUAF^rider_t              pua_face_rider(t)             PUA face bought by the rider
(none)                     pua_face_offset(t)            PUA face from the RPD excess
PUAF_t                     pua_face(t)                   PUA face in force at EOY t
PUACV_t                    pua_cv(t)                     PUA cash value at EOY t
DA_t                       div_accum(t)                  Dividend accumulation balance
L_t                        loan_bal(t)                   Loan balance at EOY t
(none)                     loan_int(t)                   Loan interest capitalized
(none)                     loan_draw(t)                  Net new borrowing per policy
TF                         term_blend_target             Term-blend target face
OYT_t                      oyt_face(t)                   One-year-term face in the blend
(none)                     oyt_cost(t)                   Dividend absorbed by the OYT
DB_t                       claim_pp(t, "DEATH")          Death benefit per policy
CSV_t                      claim_pp(t, "LAPSE")          Surrender value per policy
MAT                        claim_pp(t, "MATURITY")       Maturity benefit per policy
(none)                     prem_cum(t)                   Cumulative premiums paid
E_t                        expenses(t)                   Acquisition + maintenance
(none)                     premium_taxes(t)              Premium tax outgo
(none)                     premiums(t)                   Premium income
(none)                     rider_premiums(t)             PUA rider premium income
(none)                     claims(t, kind)               Benefit outgo
(none)                     div_cash_paid(t)              Cash dividend outgo
(none)                     loan_draws(t)                 Net loan advances
NetCF_t                    liability_cf(t)               Net liability CF, outgo +
(none)                     net_cf(t)                     The same, income positive
=========================  ============================  ==========================

Seven names needed care.

``mort_rate`` is the **best-estimate** rate — the one that actually decrements the
block — while the guarantee and dividend-scale rates are ``mort_rate_guar`` and
``mort_rate_scale``. All three come off the same shipped table through the References
``ae_best_est`` and ``ae_scale``. The notes call this out as a consistency trap: the
same table feeds claim outgo and the dividend's mortality margin **with opposite
signs**, so raising best-estimate mortality raises claims *and*, if the scale factor
moves with it, cuts the dividend.

``policy_term`` is the **premium-paying** period ``m``, not the coverage period.
Coverage runs to ``proj_len()`` in every variant; on the base pay-to-100 design the two
coincide, on ``PAY_10`` they do not.

``pols_lapse`` counts **surrenders**. The notes say "surrenders", ``BasicTerm_S`` says
``pols_lapse``, and ``CashValue_SE``'s ``kind`` string is ``"LAPSE"``; the model keeps
the lifelib name so the ``kind`` vocabulary stays intact.

The notes' ``CSV_t`` is the cash surrender value, not a file. It is reached as
``claim_pp(t, "LAPSE")`` so that all three benefit amounts share one cells and one
``kind`` vocabulary with ``CashValue_SE``.

``age(t)`` and ``age_anniv(t)`` are both needed: the notes index mortality at
``x + t - 1`` (the age entering policy year t) but price paid-up additions bought at the
end of that year at ``x + t``. Getting them the wrong way round shifts every dividend
purchase by a year.

``pols_if(t)`` is the **start**-of-year count — the notes' ``l_{t-1}``, not their
``l_t``. That is the library-wide convention (``Term_US_A.pols_if(1)`` is
``pols_if_init()``, ``CashValue_SE.pols_if(t)`` is ``pols_if_at(t, "BEF_MAT")``), and it
is the number every cash flow on the same ``result_cf()`` row is weighted by: the notes
themselves write every term of ``NetCF_t`` over ``l_{t-1}``. The notes' end-of-year
state variable is not lost — it is ``pols_if_at(t, "AFT_DECR")``, and
``pols_if_at(t, "AFT_DECR") == pols_if(t + 1)`` by construction.

``liability_cf(t)`` and ``net_cf(t)`` are the same stream with opposite signs — see the
sign-convention rubric below.

.. rubric:: Timing and kind arguments

``pols_if_at(t, timing)`` takes ``"BEF_DECR"`` (start of year t, before any decrement —
the same number as ``pols_if(t)``), ``"BEF_SURR"`` (after deaths, before surrenders),
``"BEF_MAT"`` (after surrenders, before maturity) and ``"AFT_DECR"`` (after every
decrement, the notes' ``l_t``) — the notes' end-of-year processing order, deaths then
dividend then surrenders then maturity. ``"BEF_DECR"`` and ``"BEF_MAT"`` are
``CashValue_SE``'s names; ``"BEF_SURR"`` is added because this product settles deaths
and surrenders in one end-of-year step, and ``"AFT_DECR"`` because ``CashValue_SE`` has
no string for the point past the last decrement, which is where the notes' state
variable lives. ``claim_pp(t, kind)`` and ``claims(t, kind)`` take ``"DEATH"``,
``"LAPSE"`` and ``"MATURITY"``. Both raise ``ValueError("invalid timing")`` /
``ValueError("invalid kind")`` on anything else.

.. rubric:: The dividend is rounded to the cent

``div_round_digits = 2`` rounds the credited base dividend before it buys paid-up
additions. This is **[std]**, and it is not cosmetic: the notes' worked example adds the
*displayed* margins (216.00 + 85.25 + 25.00 = 326.25) and then divides 326.25 by the
net single premium. Carrying the unrounded 326.248 through instead moves the purchased
paid-up-additions face from 776.79 to 776.78 — a full displayed cent, because the exact
value sits just under the rounding boundary. Declared dividends are credited in whole
cents, so the model rounds; setting ``div_round_digits = None`` turns it off and a test
pins the size of the gap in both directions.

.. rubric:: The worked example sets the PUA-block dividend aside

The notes' worked-example table computes steps 11-15 from the base-block dividend
alone, saying so explicitly: "For clarity the PUA-block dividend ``D^PUA_10`` is
omitted from this table; in the model it adds ... to the amount in step 9." The
Reference ``pua_div_on`` ships **False** so the base deterministic run reproduces the
worked example exactly, the same way ``Term_US_A`` ships ``conv_rate_base = 0``. It is
a reproduction switch, not a claim that paid-up additions are excluded from the
dividend — they are dividend-eligible, and ``pua_div_on = True`` is the
product-faithful setting. ``div_pua(t)`` implements the notes' formula either way, and
a test asserts its value on the anchor cell against the notes' own parenthetical.

.. rubric:: The four guarantee-basis tables are not one construction

The notes' first "known modeling pitfall" is a mismatch between the cash value table and
the ``NSP``/annuity functions, and prescribes regenerating every guarantee-basis
quantity from one 2017 CSO / 4% source. **The shipped tables do not satisfy that**, and
no set of tables carrying the worked example's anchors could. On any single mortality
basis at interest ``i``, endowment insurance and the annuity-due satisfy
``A_{x:n} = 1 - d ae_{x:n}`` with ``d = i/(1+i)``, so the notes' own definition collapses
to ``NNLP = 1000 d NSP_45 / (1 - NSP_45)``; the worked example's ``NNLP = 13.00`` then
forces ``NSP_45 = 0.252616``. But ``NSP_y = v(NSP_{y+1} + q_y (1 - NSP_{y+1})) >= v NSP_{y+1}``
for any ``q_y >= 0``, so ``NSP_55 <= NSP_45 (1.04)^10 = 0.373933`` — below the worked
example's ``NSP_55 = 0.42``. Read the other way: ``NSP_55 = 0.42`` forces
``NSP_45 >= 0.283737`` and hence ``NNLP >= 15.236`` per $1,000, 17% above the notes'
13.00. The worked example's steps 3 and 10 are mutually unreachable, whatever mortality
table is used.

What the shipped tables do instead **[std]**: *mort_table.csv* is an illustrative Makeham
curve pinned to ``q^g_54 = 0.00320``; *nsp_table.csv* is a separate parametric curve
pinned to ``NSP_55 = 0.42`` and ``NSP_100 = 1``; *np_guar_table.csv* holds
``1000 NSP_x / ae_{x:(100-x)}`` with the annuity taken on the mortality table's own
survivorship, which is what makes ``NP_g = 13.00``; and *cv_table.csv* is a monotone
shape solved through ``CV_9 = 95.00`` and ``CV_10 = 112.00`` to ``1000.00`` at attained
age 100. Reconciling the NSP curve with the mortality table needs a guarantee interest
rate that falls from 5.99% at age 45 to 0.02% at age 99, and inverting the curve for the
implied ``q`` at 4% gives a negative rate at every age up to 57 and a rate above 1 from
age 89 on.

The two *consequences* the pitfall names are nevertheless absent, and are asserted by
tests: ``NSP_100 = 1`` exactly, so ``pua_cv(T) == pua_face(T)``, and the schedule reaches
exactly face in the final policy year, so ``cv_pp(T) == sum_assured()``. Neither block
leaks at maturity. What is missing is the *means* — one basis — not the endpoints. Swap
in a licensed 2017 CSO / 4% set and all four files must be replaced together; the worked
example will then no longer reproduce, which is the honest price of the notes' own
arithmetic.

.. rubric:: Sign convention — two names, one stream

The whole-life notes print ``NetCF_t`` with **outgo positive**, which is the opposite of
the sign the other eleven reference models in ``products/`` carry. Rather than pick one
and lose the other, the model publishes both and names them apart:

``liability_cf(t)``
    The notes' ``NetCF_t``, verbatim: **outgo positive**. Premium and rider income enter
    with a minus sign; expenses, premium tax, claims, cash dividends and net loan
    advances enter with a plus. A positive ``liability_cf`` is money leaving the
    insurer. This is the number to compare against the notes.

``net_cf(t)``
    ``-liability_cf(t)``: **income positive**, the sign convention every model in
    ``products/`` carries, so that ``result_cf()["net_cf"]`` can be summed or compared
    across products. A positive ``net_cf`` is money arriving at the insurer.

Both are columns of ``result_cf()``. The pattern is ``SPIA_US_S`` and
``DIA_US_S``'s, which face the same clash. Nothing about the whole-life
notes' own convention is being denied — it is being kept under a name that does not
collide with the library-wide one, because a sign error in a 55-year liability
projection is invisible in a summary statistic.
"""

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 product(): """The product code: WL_PAR, WL_FE_LEVEL or WL_FE_GRADED.""" return model_point()["product"]
[docs] def is_par(): """True for the participating design, which is the only one that pays dividends.""" return product() == "WL_PAR"
[docs] def premium_period(): """The premium period code: TO_100, PAY_10, PAY_20 or TO_65.""" return model_point()["premium_period"]
[docs] def age_at_entry(): """The issue age (ANB) of the selected model point.""" return int(model_point()["issue_age"])
[docs] def sex(): """The sex of the selected model point; rates are sex-distinct throughout.""" return model_point()["sex"]
[docs] def risk_class(): """The underwriting class of the selected model point (PREF_NT / STD_NT / TOB).""" return model_point()["risk_class"]
[docs] def sum_assured(): """The base face amount F of the selected model point.""" return float(model_point()["face_amount"])
[docs] def dividend_option(): """The elected dividend option: PUA, CASH, ACCUM, REDUCE_PREM, or NONE for non-par.""" return model_point()["dividend_option"]
[docs] def pols_if_init(): """Initial number of policies in force, l at duration_inforce().""" return float(model_point()["pols_if_init"])
[docs] def duration_inforce(): """t0: policy years already elapsed at the valuation date; 0 for new business.""" return int(model_point()["duration_inforce"])
[docs] def puaf_inforce(): """PUAF at t0: paid-up additions face already in force at the valuation date.""" return float(model_point()["puaf_inforce"])
[docs] def loan_inforce(): """L at t0: policy loan balance already outstanding at the valuation date.""" return float(model_point()["loan_inforce"])
[docs] def loan_utilization(): """Fraction of the guaranteed cash value held as a policy loan **[std]**. 0 in the base run; the notes' variant is 0.20, maintained by borrowing and repaying at each anniversary. """ return float(model_point()["loan_utilization"])
[docs] def term_blend_target(): """TF: the term-blend rider's target face amount; 0 switches the rider off.""" return float(model_point()["term_blend_target"])
[docs] def is_blended(): """True when the term-blend rider is active on this model point **[std]**. The blend needs the dividend to pay the one-year-term cost, so it is modelled only under the PUA dividend option and only on the participating design. """ return is_par() and dividend_option() == "PUA" and term_blend_target() > sum_assured()
[docs] def proj_len(): """T = maturity_age - x: the last policy year, ending at attained age 100. The contract itself matures at 121, but the guaranteed cash value equals face at 100 and the paid-up-additions cash value equals paid-up-additions face there, so from 100 the policy is economically an endowment at face. Truncating at 100 is a **[std]** simplification that moves the age 100-121 payments from death to maturity without changing their amount per survivor. """ return maturity_age - age_at_entry() # noqa: F821
[docs] def proj_start(): """The first projected policy year, t0 + 1. New business starts at 1; an in-force model point starts at the year following the durations it has already run. """ return duration_inforce() + 1
[docs] def policy_term(): """m: the premium-paying period in years, from the premium period code. This is *not* the coverage period, which is proj_len() in every variant. """ p = premium_period() if p == "TO_100": return proj_len() elif p == "PAY_10": return 10 elif p == "PAY_20": return 20 elif p == "TO_65": return max(65 - age_at_entry(), 1) else: raise ValueError("invalid premium_period")
[docs] def age(t): """The attained age x + t - 1 entering policy year t. Mortality in year t is indexed here; paid-up additions bought at the end of the year are priced at age_anniv(t). """ return age_at_entry() + t - 1
[docs] def age_anniv(t): """The attained age x + t at the anniversary that ends policy year t.""" return age_at_entry() + t
[docs] def mec_flag(): """True where the model point would need a Sect. 7702A test this model does not run. The notes are explicit that the reference model does not police Sect. 7702 or Sect. 7702A limits and should **flag** model points that would fail rather than silently projecting them **[std]**. Limited-pay designs sit near the 7-pay limit and paid-up-additions rider payments consume 7-pay room, so both raise the flag. """ return premium_period() != "TO_100" or float(model_point()["pua_rider_premium"]) > 0.0
[docs] def premium_rate(): """Final-expense annual premium per $1,000 of face, from *premium_rates.csv* [S7].""" key = (product(), sex(), risk_class(), age_at_entry()) return float(data.premium_rates().loc[key, "rate_per_1000"]) # noqa: F821
[docs] def premium_pp(t): """G: the gross annual premium per policy payable at the start of policy year t. Level and guaranteed while premiums are payable, zero after policy_term(). The participating design takes G from the model point (**[std illustrative]** — carrier rate books are not public); the final-expense variant computes it from the sourced rate table as ``(F / 1000) * rate(x, sex, class) + 36`` [S7]. """ if t < 1 or t > policy_term(): return 0.0 elif is_par(): return float(model_point()["annual_premium"]) else: return sum_assured() / 1000.0 * premium_rate() + fe_policy_fee # noqa: F821
[docs] def premium_net_pp(t): """G^net_t: the premium actually collected at the start of policy year t. Under REDUCE_PREM the prior anniversary's dividend offsets the premium, ``max(G - D_{t-1}, 0)``, and any excess buys paid-up additions through pua_face_offset(). The dynamic premium-offset overlay (**[std]**, off by default) applies a prem_offset_share fraction of the same offset once the dividend has grown to cover the premium. """ g = premium_pp(t) if g == 0.0: return 0.0 d = div_credited(t - 1) if dividend_option() == "REDUCE_PREM": return max(g - d, 0.0) elif prem_offset_on and d >= g: # noqa: F821 return g - prem_offset_share * min(d, g) # noqa: F821 else: return g
[docs] def rider_premium_pp(t): """A_t: the paid-up-additions rider premium paid at the start of policy year t. Level while base premiums are payable **[std]**; the notes set it within limits fixed at issue and do not schedule it. """ if not is_par() or t < proj_start() or t > policy_term(): return 0.0 return float(model_point()["pua_rider_premium"])
[docs] def prem_cum(t): """Cumulative gross premium paid per policy through the start of policy year t. Only the final-expense graded plan uses it, for the 110%-of-premiums-paid death benefit in policy years 1-2 [S6][S7]. """ if t < 1: return 0.0 return prem_cum(t - 1) + premium_pp(t)
[docs] def cv_pp(t): """CV_t: the guaranteed cash value per policy at the anniversary ending year t. Read from *cv_table.csv* per $1,000 of face. The notes give the Standard Nonforfeiture Law adjusted-premium formula conceptually but prescribe a table input in practice, because contractual cash value tables are policy-form documents that are not public. The shipped schedule is **[std]**, calibrated to the worked example's CV_9 and CV_10 and reaching exactly face at attained age 100. It is **sex-distinct**, as the notes require of every rate in this product: the male pay-to-100 schedule carries the worked example's anchors, and the female schedule is that schedule's funding-progress shape ``f_t = CV^M_t / (F NSP^M_{x+t})`` applied to the female paid-up value ``F NSP^F_{x+t}`` **[std]**. The shape — how far along the way to paid-up status the schedule has come — is a design choice that does not depend on sex; the value it is progressing towards does, through NSP. See the guarantee-basis rubric in the Space docstring for what this construction is *not*. """ if t < 1 or t > proj_len(): return 0.0 key = (premium_period(), sex(), age_at_entry(), t) return sum_assured() / 1000.0 * float( data.cv_table().loc[key, "cv_per_1000"]) # noqa: F821
[docs] def net_amt_at_risk(t): """F - CV_t: the guarantee-basis net amount at risk carried by the mortality margin.""" return sum_assured() - cv_pp(t)
[docs] def nsp(y): """NSP_y: net single premium per 1 of paid-up endowment-at-100 face at attained age y. The purchase basis for paid-up additions, unloaded, on the guarantee basis **[std]**. NSP_100 = 1 by construction, which is what makes the paid-up-additions cash value equal paid-up-additions face at maturity. The shipped curve is *not* the endowment-at-100 net single premium implied by *mort_table.csv* at 4%, and cannot be: see the guarantee-basis rubric in the Space docstring, which shows that the worked example's own anchors rule out any single mortality basis at 4%. """ return float(data.nsp_table().loc[(sex(), y), "nsp"]) # noqa: F821
[docs] def np_guar(): """NP_g: the nonforfeiture net level premium per policy, from *np_guar_table.csv*. The dividend's interest margin is credited on the guaranteed fund *including* the year's net premium, so this quantity sits inside div_int() **[std]**. The notes define it over the **endowment** period, ``NNLP = F NSP_x / ae_{x:(100-x)}``, and annotate the *other* nonforfeiture quantity — the adjusted premium P_adj — with "(m = premium period)", so the (100 - x) subscript is deliberate: NNLP does not vary with the premium period. The shipped table therefore carries one value per (sex, issue age), repeated across the premium periods; the premium_period key is kept so a carrier table that *does* vary by m can be dropped in without a formula change. """ key = (premium_period(), sex(), age_at_entry()) return sum_assured() / 1000.0 * float( data.np_guar_table().loc[key, "np_guar_per_1000"]) # noqa: F821
[docs] def mort_rate_guar_at(y): """q^g_y: the guaranteed mortality rate at attained age y, from *mort_table.csv*. The shipped table is **[std]** illustrative and is *not* the 2017 CSO the notes name; that table is licensed and cannot be shipped here. """ return float(data.mort_table().loc[(sex(), y), "mort_rate"]) # noqa: F821
[docs] def mort_rate_guar(t): """q^g at the attained age entering policy year t.""" return mort_rate_guar_at(age(t))
[docs] def mort_rate_scale(t): """q^sc: the dividend scale's experience mortality, ae_scale x q^g **[std]**.""" return ae_scale * mort_rate_guar(t) # noqa: F821
[docs] def mort_rate(t): """q^e: the best-estimate mortality rate that decrements the block **[std]**. ``ae_best_est`` x q^g. The notes' sensitivity list flags this as a consistency trap: the same table drives claim outgo here and the dividend's mortality margin through mort_rate_scale(), with opposite signs. """ return ae_best_est * mort_rate_guar(t) # noqa: F821
[docs] def dyn_lapse_factor(t): """The interest-sensitive lapse multiplier **[std]**, for scenario runs. ``min(1 + 2.0 * max(0, r_cmp - i_d - 0.01), 3.0)``, where r_cmp is the competitor or market rate in the scenario. Off unless dyn_lapse_on; the calibration is judgmental and the research base records no dynamic-lapse study for whole life. """ return min(1.0 + 2.0 * max(0.0, competitor_rate - int_rate_div - 0.01), 3.0) # noqa: F821
[docs] def lapse_rate(t): """w_t: the surrender rate applied to survivors at the end of policy year t **[std]**. Participating: 5.0% in year 1 grading linearly to 2.0% at year 10, level 2.0% thereafter. Final expense (simplified issue, so heavier): 12% year 1, 10% year 2, grading linearly to 6% by year 5 and level after. Zero in the final policy year — the notes' "0 within 1 year of maturity" — so that the survivors of year T mature rather than surrender. """ if t >= proj_len(): return 0.0 elif is_par(): w = 0.05 - 0.03 * (min(t, 10) - 1) / 9.0 elif t == 1: w = 0.12 elif t == 2: w = 0.10 else: w = max(0.10 - (0.04 / 3.0) * (t - 2), 0.06) return w * dyn_lapse_factor(t) if dyn_lapse_on else w # noqa: F821
[docs] def div_int(t): """D^int_t: the dividend's interest margin, with direct recognition **[std]**. ``(i_d - i_g)(CV_{t-1} + NP_g - L_{t-1}) + (i_L - i_g) L_{t-1}`` — the loaned portion is credited at the loan rate rather than the portfolio dividend rate. With the snapshot i_L = i_d = 6.00% the adjustment is zero, which is a coincidence of the snapshot and not a model property. """ fund = cv_pp(t - 1) + np_guar() loan = loan_bal(t - 1) return ((int_rate_div - int_rate_guar) * (fund - loan) # noqa: F821 + (int_rate_loan - int_rate_guar) * loan) # noqa: F821
[docs] def div_mort(t): """D^mort_t: the dividend's mortality margin, (q^g - q^sc)(F - CV_t) **[std]**.""" return (mort_rate_guar(t) - mort_rate_scale(t)) * net_amt_at_risk(t)
[docs] def div_exp(t): """D^exp_t: the dividend's expense margin, a flat per-policy amount **[std]**.""" return expense_margin # noqa: F821
[docs] def div_base(t): """D_t: the base-block dividend credited at the anniversary ending year t. ``max(D^int + D^mort + D^exp, 0)``, rounded to div_round_digits. The floor is **[std]**: dividends are non-negative distributions of surplus, so adverse experience does not claw back. No dividend is credited before div_first_year, a real cross-carrier design split that the notes keep as a parameter, and none at all on the non-participating final-expense design. """ if not is_par() or t < div_first_year or t < proj_start() or t > proj_len(): # noqa: F821 return 0.0 d = max(div_int(t) + div_mort(t) + div_exp(t), 0.0) return d if div_round_digits is None else round(d, div_round_digits) # noqa: F821
[docs] def div_pua(t): """D^PUA_t: the dividend earned by the paid-up-additions block **[std]**. ``(i_d - i_g) PUACV_{t-1} + (q^g - q^sc)(PUAF_{t-1} - PUACV_{t-1})``. Paid-up additions are dividend-eligible, and the compounding this creates is the notes' first-ranked sensitivity. Switched **off** by ``pua_div_on`` in the shipped base run because the worked example's table omits it; see the Space docstring. """ if (not pua_div_on or not is_par() or t < div_first_year # noqa: F821 or t < proj_start() or t > proj_len()): return 0.0 return ((int_rate_div - int_rate_guar) * pua_cv(t - 1) # noqa: F821 + (mort_rate_guar(t) - mort_rate_scale(t)) * (pua_face(t - 1) - pua_cv(t - 1)))
[docs] def div_credited(t): """D_t + D^PUA_t: the whole dividend credited to survivors at the end of year t.""" if t < proj_start() or t > proj_len(): return 0.0 return div_base(t) + div_pua(t)
[docs] def oyt_face(t): """OYT_t: the one-year-term face the term-blend rider carries at the end of year t. ``max(TF - F - PUAF_{t-1}, 0)``, capped at what the dividend can fund **[std]**. Two readings had to be settled here, and both are standardizations: The notes write the gap as ``TF - F - PUAF_t``, which is circular — the term cost is deducted from the dividend that buys those very additions — so the model uses the prior anniversary's paid-up-additions face. The notes are also silent on what happens when the dividend cannot pay for the whole gap. Leaving the formula uncapped would report a term face the model never charges for and would inflate the death benefit, so the layer is capped at ``D_t (1 + i_g) / q^sc_{x+t}`` — as much term as the dividend actually buys. Whether the cap binds is a property of how the blend is funded, not of the design. On model point 8, where a $5,000 paid-up-additions rider premium funds the 2x target, it binds only in policy year 1 — where no dividend is payable at all under div_first_year = 2 — and the gap closes at year 8. On model point 14, the same 2x target with no rider premium, it binds in years 1-3 while the dividend is small and again in every year from 30 on as ``q^sc`` outruns it, and the block never crosses over. Crossover, after which the rider is pure paid-up additions, is where the gap itself reaches zero. """ if not is_blended() or t < proj_start() or t > proj_len(): return 0.0 gap = max(term_blend_target() - sum_assured() - pua_face(t - 1), 0.0) q = ae_scale * mort_rate_guar_at(age_anniv(t)) # noqa: F821 if gap == 0.0 or q <= 0.0: return gap return min(gap, div_credited(t) * (1.0 + int_rate_guar) / q) # noqa: F821
[docs] def oyt_cost(t): """The dividend absorbed by the one-year-term layer, q^sc_{x+t} x OYT_t x v_g **[std]**.""" if oyt_face(t) == 0.0: return 0.0 return (ae_scale * mort_rate_guar_at(age_anniv(t)) # noqa: F821 * oyt_face(t) / (1.0 + int_rate_guar)) # noqa: F821
[docs] def div_to_pua(t): """The part of the credited dividend that buys paid-up additions at the end of year t. Under the PUA option, everything left after the term-blend cost. Under CASH it is paid out, under ACCUM it goes to div_accum(), and under REDUCE_PREM it offsets the next premium with the excess handled at the start of the following year by pua_face_offset() — except in the final policy year, where there is no year T + 1 to offset. The notes are silent on that last dividend; routing it anywhere else would drop it, so the whole of D_T is treated as REDUCE_PREM excess and buys paid-up additions at NSP_100 = 1 **[std]**, which is the same rule the option already applies in every year whose premium the dividend has outgrown. """ if t < proj_start() or t > proj_len(): return 0.0 elif dividend_option() == "PUA": return max(div_credited(t) - oyt_cost(t), 0.0) elif dividend_option() == "REDUCE_PREM" and t == proj_len(): return div_credited(t) else: return 0.0
[docs] def div_cash(t): """D^cash_t: the cash dividend paid per surviving policy at the end of year t.""" return div_credited(t) if dividend_option() == "CASH" else 0.0
[docs] def div_accum(t): """DA_t: the dividend accumulation balance at the anniversary ending year t. ``DA_{t-1}(1 + i_d) + D_t`` under the ACCUM option, zero otherwise. The credit rate reuses the dividend interest rate **[std]**: carriers declare an accumulation rate annually with the scale but publish no separate figure. The balance adds to the death, surrender and maturity proceeds. """ if t <= duration_inforce() or t > proj_len() or dividend_option() != "ACCUM": return 0.0 return div_accum(t - 1) * (1 + int_rate_div) + div_credited(t) # noqa: F821
[docs] def pua_face_purch(t): """dPUAF_t: paid-up-additions face bought by the dividend, div / NSP_{x+t}.""" d = div_to_pua(t) return d / nsp(age_anniv(t)) if d else 0.0
[docs] def pua_face_rider(t): """dPUAF^rider_t: paid-up-additions face bought by the rider payment at the start of year t. ``A_t (1 - load) / NSP_{x+t-1}``, a 10% load **[std]** chosen from the observed 7.5%-10% range on rider payments. Dividend purchases carry no load; only rider payments do. """ a = rider_premium_pp(t) return a * (1 - pua_rider_load) / nsp(age(t)) if a else 0.0 # noqa: F821
[docs] def pua_face_offset(t): """Paid-up-additions face bought at the start of year t by the REDUCE_PREM excess. Once the prior dividend exceeds the premium it is offsetting, the excess buys paid-up additions **[std]** rather than being paid out. This carries D_{t-1}, so it can never carry D_T; that last dividend is routed through div_to_pua() instead — see its docstring. """ if dividend_option() != "REDUCE_PREM" or t < proj_start() or t > proj_len(): return 0.0 excess = max(div_credited(t - 1) - premium_pp(t), 0.0) return excess / nsp(age(t)) if excess else 0.0
[docs] def pua_face(t): """PUAF_t: paid-up-additions face in force at the anniversary ending year t. ``PUAF_{t-1}`` plus the dividend, rider and premium-offset purchases of year t. The t0 branch carries the model point's puaf_inforce, which is what makes the worked example's stipulated prior balance of 4,100 an input rather than a projection. """ if t <= duration_inforce(): return puaf_inforce() elif t > proj_len(): return 0.0 return (pua_face(t - 1) + pua_face_purch(t) + pua_face_rider(t) + pua_face_offset(t))
[docs] def pua_cv(t): """PUACV_t: the cash value of the paid-up additions, PUAF_t x NSP_{x+t} **[std]**. Valuing the whole block at the attained-age net single premium is exact at the issue of each layer and again at age 100, and approximate in between. """ if t < 1 or t > proj_len(): return 0.0 return pua_face(t) * nsp(age_anniv(t))
[docs] def loan_int(t): """Loan interest capitalized on the anniversary ending year t, L_{t-1} x i_L.""" return loan_bal(t - 1) * int_rate_loan # noqa: F821
[docs] def loan_bal(t): """L_t: the policy loan balance at the anniversary ending year t **[std]**. The notes' variant holds the loan at ``loan_utilization x CV_t``, maintained by borrowing and repaying at each anniversary; the base run sets loan_utilization to zero. Loans reduce the death, surrender and maturity proceeds. """ if t <= duration_inforce(): return loan_inforce() elif t > proj_len(): return 0.0 return loan_utilization() * cv_pp(t)
[docs] def loan_draw(t): """Net new borrowing per policy at the anniversary ending year t; negative repays. The balance change net of the interest that capitalized into it, so the cash the insurer actually advances. """ return loan_bal(t) - loan_bal(t - 1) - loan_int(t)
[docs] def claim_pp(t, kind): """The benefit amount per policy in policy year t, by kind. ``"DEATH"`` DB_t = F + PUAF_{t-1} + OYT_t + DA_{t-1} - L_{t-1}. Deaths fall at the end of the year *before* the dividend is credited, so the benefit carries the prior anniversary's paid-up additions **[std]**. The one-year-term layer is zero unless the term-blend rider is active, where ``F + PUAF + OYT`` is the notes' "target face plus excess paid-up additions" written so that it still holds when the dividend funds only part of the gap. On the final-expense graded plan, natural-cause deaths in policy years 1-2 pay 110% of cumulative premiums paid and accidental deaths pay the full face from day one [S6][S7]; the two are blended by fe_accid_share **[std]**, since the model carries one mortality decrement. ``"LAPSE"`` CSV_t = CV_t + PUACV_t + DA_t - L_t, the surrender value at the end of year t, including the dividend just credited. ``"MATURITY"`` MAT = F + PUAF_T + DA_T - L_T, paid at T and zero in every other year. On the non-participating design this is F - L_T. """ if kind == "DEATH": if product() == "WL_FE_GRADED" and t <= fe_graded_years: # noqa: F821 return ((1 - fe_accid_share) * fe_graded_factor * prem_cum(t) # noqa: F821 + fe_accid_share * sum_assured()) # noqa: F821 face = sum_assured() + pua_face(t - 1) + oyt_face(t) return face + div_accum(t - 1) - loan_bal(t - 1) elif kind == "LAPSE": return cv_pp(t) + pua_cv(t) + div_accum(t) - loan_bal(t) elif kind == "MATURITY": if t != proj_len(): return 0.0 return sum_assured() + pua_face(t) + div_accum(t) - loan_bal(t) else: raise ValueError("invalid kind")
[docs] def pols_if_at(t, timing): """The number of policies in force at a point inside policy year t. ``"BEF_DECR"`` l_{t-1}, the start of year t, before any decrement; the same number as pols_if(t), and the weight on that year's cash flows. ``"BEF_SURR"`` after deaths, before surrenders — the population the dividend is credited to and the one surrenders are taken from. ``"BEF_MAT"`` after surrenders, before maturity; the survivors of the year. ``"AFT_DECR"`` l_t, the notes' end-of-year state variable: what is left once the year's deaths, surrenders and — in the final policy year — the maturities have all been taken, so it is zero from T on. Equals pols_if(t + 1). The string is added to CashValue_SE's set because that model has no name for the point past the last decrement, which is where these notes keep their in-force probability. """ if timing == "BEF_DECR": return pols_if(t) elif timing == "BEF_SURR": return pols_if_at(t, "BEF_DECR") * (1 - mort_rate(t)) elif timing == "BEF_MAT": return pols_if_at(t, "BEF_SURR") * (1 - lapse_rate(t)) elif timing == "AFT_DECR": if t <= duration_inforce(): return pols_if_init() elif t >= proj_len(): return 0.0 return pols_if_at(t, "BEF_MAT") else: raise ValueError("invalid timing")
[docs] def pols_if(t): """The number of policies in force at the **start** of policy year t. The notes' l_{t-1}: the population that pays the year's premium, carries its expenses and is exposed to its decrements, so it is the weight on every cash flow of the same result_cf() row. Equal to pols_if_init() up to and including t0, and zero from T + 1 on, because everything still in force at T matures there and the contract terminates. The notes' end-of-year l_t is pols_if_at(t, "AFT_DECR"), which is this cells one year on. """ if t <= duration_inforce(): return pols_if_init() return pols_if_at(t - 1, "AFT_DECR")
[docs] def pols_death(t): """Deaths in policy year t, q^e applied to the policies in force at its start.""" return pols_if_at(t, "BEF_DECR") * mort_rate(t)
[docs] def pols_lapse(t): """Surrenders at the end of policy year t, w_t applied to survivors of the year.""" return pols_if_at(t, "BEF_SURR") * lapse_rate(t)
[docs] def pols_maturity(t): """Policies maturing at attained age 100; non-zero only in the final policy year. Not a decrement — the modelled contract simply ends — but the in-force roll-forward does not close without it. """ return pols_if_at(t, "BEF_MAT") if t == proj_len() else 0.0
[docs] def inflation_factor(t): """The expense inflation factor in policy year t.""" return (1 + inflation_rate) ** (t - 1) # noqa: F821
[docs] def premiums(t): """Premium income at the start of policy year t (an inflow).""" return premium_net_pp(t) * pols_if_at(t, "BEF_DECR")
[docs] def rider_premiums(t): """Paid-up-additions rider premium income at the start of policy year t (an inflow).""" return rider_premium_pp(t) * pols_if_at(t, "BEF_DECR")
[docs] def premium_taxes(t): """Premium tax at the start of policy year t **[std]**, on premium and rider income. The notes' processing order collects it at the beginning of the year alongside the expenses; their one-line NetCF formula omits it. The model follows the processing order and keeps it as its own line. """ return premium_tax_rate * (premiums(t) + rider_premiums(t)) # noqa: F821
[docs] def expenses(t): """E_t: acquisition and inflating maintenance expense in policy year t **[std]**. 90% of the first year's premium plus $250 per policy at issue, then $60 per policy per year inflating at 2%. An in-force model point never sees the acquisition charge, because its projection starts after policy year 1. """ acq = expense_acq_prem_rate * premium_pp(t) + expense_acq if t == 1 else 0.0 # noqa: F821 return ((acq + expense_maint * inflation_factor(t)) # noqa: F821 * pols_if_at(t, "BEF_DECR"))
[docs] def claims(t, kind=None): """Benefit outgo in policy year t, by kind; the total when kind is omitted.""" if kind is None: return sum(claims(t, k) for k in ("DEATH", "LAPSE", "MATURITY")) elif kind == "DEATH": return claim_pp(t, "DEATH") * pols_death(t) elif kind == "LAPSE": return claim_pp(t, "LAPSE") * pols_lapse(t) elif kind == "MATURITY": return claim_pp(t, "MATURITY") * pols_maturity(t) else: raise ValueError("invalid kind")
[docs] def div_cash_paid(t): """Cash dividends paid out at the end of policy year t, to survivors of the year.""" return div_cash(t) * pols_if_at(t, "BEF_SURR")
[docs] def loan_draws(t): """Net policy loans advanced at the end of policy year t **[std]**, an outflow. The notes report gross liability flows plus a separate loan account rather than netting the loan into a net-amount-at-risk presentation. Advances go to the policies still in force; the balance is recovered through the death, surrender and maturity benefits, which are all net of the loan. """ return loan_draw(t) * pols_if_at(t, "BEF_MAT")
[docs] def liability_cf(t): """NetCF_t: the net liability cash flow in policy year t, **outgo positive**. The technical notes' formula verbatim, and the one to compare against them: premium and rider income enter with a minus sign; expenses, premium tax, death, surrender and maturity benefits, cash dividends and net loan advances enter with a plus, so a positive value is money leaving the insurer. Internal dividend applications — paid-up additions, accumulation, premium reduction — are not cash flows when credited: they emerge later through the death benefit, the surrender value and the maturity benefit. These notes are the only ones in the library that print the outgo-positive sign, so the stream is published twice: here under the notes' sign, and negated as net_cf(t) under the library's. See the sign-convention rubric in the Space docstring. """ return (-premiums(t) - rider_premiums(t) + expenses(t) + premium_taxes(t) + claims(t) + div_cash_paid(t) + loan_draws(t))
[docs] def net_cf(t): """The net cash flow in policy year t, **income positive**: -liability_cf(t). Income less outgo, the sign every model in ``products/`` carries, so that a ``result_cf()["net_cf"]`` column can be summed or compared across products. A positive value is money arriving at the insurer. liability_cf(t) is the same stream under the technical notes' own outgo-positive sign; both are result_cf() columns because neither reading may be lost. """ return -liability_cf(t)
[docs] def result_cf(): """Result table of cashflows, indexed by policy year. ``pols_if`` is the start-of-year count, which is the weight applied to every cash flow on the same row. Both signs of the net flow are published: ``net_cf`` is income-positive, the library-wide convention, and ``liability_cf`` is the technical notes' outgo-positive ``NetCF_t``; the two are negatives of each other. """ ts = list(range(proj_start(), proj_len() + 1)) return pd.DataFrame( # noqa: F821 { "pols_if": [pols_if(t) for t in ts], "premiums": [premiums(t) for t in ts], "rider_premiums": [rider_premiums(t) for t in ts], "expenses": [expenses(t) for t in ts], "premium_taxes": [premium_taxes(t) for t in ts], "claims_death": [claims(t, "DEATH") for t in ts], "claims_lapse": [claims(t, "LAPSE") for t in ts], "claims_maturity": [claims(t, "MATURITY") for t in ts], "div_cash_paid": [div_cash_paid(t) for t in ts], "loan_draws": [loan_draws(t) for t in ts], "net_cf": [net_cf(t) for t in ts], "liability_cf": [liability_cf(t) for t in ts], }, index=pd.Index(ts, name="t"), # noqa: F821 )
[docs] def result_pols(): """Result table of policy counts and decrement rates, indexed by policy year.""" ts = list(range(proj_start(), 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": [mort_rate(t) for t in ts], "lapse_rate": [lapse_rate(t) for t in ts], }, index=pd.Index(ts, name="t"), # noqa: F821 )
[docs] def result_cv(): """Result table of the guaranteed and non-guaranteed values, indexed by policy year. The account-value analogue for this product: the guaranteed cash value, the paid-up-additions block, the accumulation balance and the loan, plus the dividend that drives them and the two benefit amounts they feed. """ ts = list(range(proj_start(), proj_len() + 1)) return pd.DataFrame( # noqa: F821 { "cv_pp": [cv_pp(t) for t in ts], "div_base": [div_base(t) for t in ts], "div_pua": [div_pua(t) for t in ts], "pua_face": [pua_face(t) for t in ts], "pua_cv": [pua_cv(t) for t in ts], "div_accum": [div_accum(t) for t in ts], "loan_bal": [loan_bal(t) for t in ts], "db_pp": [claim_pp(t, "DEATH") for t in ts], "surr_value_pp": [claim_pp(t, "LAPSE") for t in ts], }, index=pd.Index(ts, name="t"), # noqa: F821 )
[docs] def check_pols_roll_fwd_resid(t): """The in-force roll-forward residual in policy year t; zero everywhere. ``pols_if(t) - pols_if(t + 1) - deaths - surrenders - maturities``, the notes' ``l_{t-1} - l_t - ...``. Maturities are non-zero only in the final policy year, where the survivors neither die nor surrender: their contract ends. Without that term the last year appears to lose lives with no cause. """ return (pols_if(t) - pols_if(t + 1) - pols_death(t) - pols_lapse(t) - pols_maturity(t))
[docs] def check_pols_roll_fwd(): """True when the in-force roll-forward closes in every projected policy year. The library-wide form of a roll-forward check: no argument, one bool over all t, so one test can call it across every model. check_pols_roll_fwd_resid(t) gives the signed residual of the year that failed. The tolerance scales with pols_if_init(), since the residual is an accumulation of rounding on that many policies. """ return all(abs(check_pols_roll_fwd_resid(t)) <= 1e-10 * max(pols_if_init(), 1.0) for t in range(proj_start(), proj_len() + 1))
[docs] def check_pua_roll_fwd_resid(t): """The paid-up-additions roll-forward residual in policy year t; zero everywhere. ``PUAF_t - PUAF_{t-1} - dividend purchases - rider purchases - offset purchases``. The analogue of ``CashValue_SE.check_av_roll_fwd`` for a product whose accumulating state is a face amount rather than an account value. """ return (pua_face(t) - pua_face(t - 1) - pua_face_purch(t) - pua_face_rider(t) - pua_face_offset(t))
[docs] def check_pua_roll_fwd(): """True when the paid-up-additions roll-forward closes in every projected year. No argument, one bool over all t, matching check_pols_roll_fwd(); check_pua_roll_fwd_resid(t) gives the signed residual. The tolerance is relative to the block itself, which reaches six figures of face on a compounding projection. """ return all(abs(check_pua_roll_fwd_resid(t)) <= 1e-9 * max(pua_face(t), 1.0) for t in range(proj_start(), proj_len() + 1))
# --------------------------------------------------------------------------- # References data = ("Interface", ("..", "Data"), "auto") point_id = 1 maturity_age = 100 int_rate_guar = 0.04 int_rate_div = 0.06 int_rate_loan = 0.06 ae_scale = 0.7 ae_best_est = 0.7 expense_margin = 25.0 div_first_year = 2 div_round_digits = 2 pua_div_on = False pua_rider_load = 0.1 expense_acq_prem_rate = 0.9 expense_acq = 250.0 expense_maint = 60.0 inflation_rate = 0.02 premium_tax_rate = 0.02 fe_policy_fee = 36.0 fe_graded_years = 2 fe_graded_factor = 1.1 fe_accid_share = 0.03 dyn_lapse_on = False competitor_rate = 0.06 prem_offset_on = False prem_offset_share = 0.5 pd = ("Module", "pandas")