# 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 only Space in the :mod:`~.Term_US_A` model; holds all formulas and data.
The Space is parameterized by ``point_id``, so ``Projection[1]`` is an ItemSpace
projecting model point 1::
>>> Projection[1].result_cf() # the 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/term_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
``Term_US_A`` folder without its parent's CSVs produces a model that reads and then
fails on first evaluation. A test asserts this by round-tripping the model together
with its inputs.
:func:`~.Term_US_A.Data.input_dir` resolves the directory from ``_model.path.parent`` at run time, so
the model works wherever the repository is checked out. Each table has a filename
Reference and a reader Cells:
====================== ========================== ==============================
Reference Cells File
====================== ========================== ==============================
model_point_file data.model_point_table() model_point_table.csv
premium_rates_file data.premium_rates() premium_rates.csv
mort_table_file data.mort_table() mort_table.csv
class_factor_file data.class_factor_table() class_factor_table.csv
shock_lapse_file data.shock_lapse_table() shock_lapse_table.csv
====================== ========================== ==============================
To swap in a licensed mortality basis, replace ``mort_table.csv`` with a same-schema
file, or point ``mort_table_file`` at a different name. No formula changes.
.. rubric:: Naming
Cells names follow lifelib's ``basiclife.BasicTerm_S`` wherever that model has an
analogue — ``pols_*`` for policy counts, plural nouns for cash flows, ``*_rate`` for
rates, ``*_pp`` for per-policy amounts. 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 in policy year t
n policy_term Level period in years
F sum_assured Face amount
t = 1..95-x proj_len Last policy year
l(t) pols_if(t) In-force at start of year t
(l(1)) pols_if_init In-force at issue
d(t) pols_death(t) Deaths in year t
s(t) pols_surv(t) Survivors to end of year t
x(t) pols_lapse(t) Lapses at end of year t
c(t) pols_conv(t) Conversions at end of year t
(none) pols_maturity(t) Expiries at attained age 95
q(t) mort_rate(t) Mortality, all factors applied
(q_base) mort_rate_base(t) Base table rate before factors
w(t) lapse_rate(t) Lapse rate, incl. the shock
w(n) shock_lapse_rate Shock lapse at level-period end
cv(t) conv_rate(t) Conversion rate
M(d) plt_mort_factor(d) PLT mortality deterioration
M(1) plt_mort_factor_init M(1) actually used
(M(1) rule) plt_mort_factor_init_formula The notes' formula for M(1)
J jump_ratio AP(n+1)/AP(n), fee included
AP(t) premium_pp(t) Guaranteed annual premium
G(t) premiums(t) Premium income
K(t) commissions(t) Commission
k(t) comm_rate(t) Commission rate
X(t) premium_taxes(t) Premium tax
E(t) expenses(t) Acquisition + maintenance
DC(t) claims(t) Death claims
CV(t) conv_credits(t) Conversion credit outflow
NetCF(t) net_cf(t) Net cash flow
phase(t) phase(t) LEVEL / PLT / EXPIRED
conv_elig(t) conv_elig(t) Conversion eligibility
============ ======================================================== ===============================
Three notes on the mapping. The notes write deaths as ``d(t)`` while also using ``d``
as the post-level-term duration index in ``M(d)``; the ``pols_death`` / ``plt_mort_factor``
split removes that collision. The notes' ``x(t)`` (lapses) and ``X(t)`` (premium tax)
differ only by case, which ``pols_lapse`` and ``premium_taxes`` separate. And
``pols_maturity`` has no symbol in the notes at all — see below.
.. rubric:: pols_maturity
The notes give the roll-forward as ``l(t+1) = l(t)(1-q)(1-cv)(1-w)`` and, separately,
the rule ``l(t) = 0 for x+t-1 >= 95``. Those do not reconcile in the final policy year:
its survivors neither die, lapse nor convert — their coverage simply runs out. Without
a term for that, the roll-forward appears to lose lives with no cause.
``pols_maturity(t)`` names it, zero in every year but the last, so that
pols_if(t) - pols_if(t+1) = pols_death(t) + pols_lapse(t) + pols_conv(t) + pols_maturity(t)
holds for every ``t``. It is bookkeeping determined by the notes' own rules, not an
added assumption. The name follows ``BasicTerm_S.pols_maturity``.
"""
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."""
return model_point()["rate_class"]
[docs]
def plan():
"""The plan code of the selected model point (T10 / T20 / T30)."""
return model_point()["plan"]
[docs]
def sum_assured():
"""The face amount of the selected model point."""
return float(model_point()["sum_assured"])
[docs]
def pols_if_init():
"""Initial number of policies in-force."""
return float(model_point()["pols_if_init"])
[docs]
def band():
"""Face-amount band 1-4 **[std]**; the anchor cell is band 1."""
f = sum_assured()
return 1 if f < 250000 else 2 if f < 500000 else 3 if f < 1000000 else 4
[docs]
def policy_term():
"""The level premium period in years, from the plan code."""
return int(plan()[1:])
[docs]
def proj_len():
"""Projection length in policy years: coverage ends at attained age 95."""
return expiry_age - age_at_entry() # noqa: F821
[docs]
def age(t):
"""The attained age at the start of policy year t."""
return age_at_entry() + t - 1
[docs]
def premium_pp(t):
"""Guaranteed annual gross premium per policy in year t, policy fee included."""
key = (plan(), sex(), rate_class(), band(), t)
return float(data.premium_rates().loc[key, "premium_pp"]) # noqa: F821
[docs]
def jump_ratio():
"""Initial premium jump ratio premium_pp(n+1)/premium_pp(n), fee included."""
return premium_pp(policy_term() + 1) / premium_pp(policy_term())
[docs]
def plt_mort_factor_init():
"""M(1) actually used: the model point's override if given, else the formula."""
o = model_point()["plt_mort_factor_override"]
return plt_mort_factor_init_formula() if pd.isna(o) else float(o) # noqa: F821
[docs]
def plt_mort_factor(d):
"""Post-level-term mortality deterioration at PLT duration d; grades to 2.00 **[std]**."""
return max(2.0, plt_mort_factor_init() - 0.15 * (d - 1)) if d >= 1 else 1.0
[docs]
def class_factor():
"""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_base(t):
"""Base-table mortality rate at the attained age in policy year t."""
return float(data.mort_table().loc[age(t), "mort_rate"]) # noqa: F821
[docs]
def mort_rate(t):
"""Mortality rate applied in year t: base x class factor x PLT deterioration."""
d = t - policy_term()
return mort_rate_base(t) * class_factor() * (plt_mort_factor(d) if d >= 1 else 1.0)
[docs]
def shock_lapse_rate():
"""Shock lapse at the end of the level period, by jump-ratio bucket **[std]**."""
j = jump_ratio()
for _, r in data.shock_lapse_table().iterrows(): # noqa: F821
if r["jump_lo"] < j <= r["jump_hi"]:
return float(r["shock_lapse_rate"])
return float(data.shock_lapse_table().iloc[-1]["shock_lapse_rate"]) # noqa: F821
[docs]
def lapse_rate(t):
"""Lapse rate in policy year t **[std]**, including the shock at the level-period end.
Level period: 6%, 5%, 4% for years 3..n-2, 6% anticipatory at n-1, shock at n.
Post-level term: 30%, 15%, then 10%.
"""
n = policy_term()
if t < n:
if t == 1:
return 0.06
if t == 2:
return 0.05
if t == n - 1:
return 0.06
return 0.04
if t == n:
return shock_lapse_rate()
d = t - n
return 0.30 if d == 1 else 0.15 if d == 2 else 0.10
[docs]
def conv_elig(t):
"""True while convertible: within the level period and attained age below 70."""
return t <= policy_term() and age(t) < 70
[docs]
def conv_rate(t):
"""Conversion rate in policy year t **[std]**; zero outside the eligibility window."""
if not conv_elig(t):
return 0.0
return conv_rate_final if not conv_elig(t + 1) else conv_rate_base # noqa: F821
[docs]
def phase(t):
"""LEVEL, PLT or EXPIRED in policy year t."""
if t > proj_len():
return "EXPIRED"
return "LEVEL" if t <= policy_term() else "PLT"
[docs]
def pols_if(t):
"""Number of policies in-force at the start of policy year t."""
if t == 1:
return pols_if_init()
if t > proj_len():
return 0.0
return (pols_if(t - 1) * (1 - mort_rate(t - 1))
* (1 - conv_rate(t - 1)) * (1 - lapse_rate(t - 1)))
[docs]
def pols_death(t):
"""Number of deaths occurring in policy year t."""
return pols_if(t) * mort_rate(t)
[docs]
def pols_surv(t):
"""Number of policies surviving to the end of year t, before voluntary decrements."""
return pols_if(t) * (1 - mort_rate(t))
[docs]
def pols_conv(t):
"""Number of conversions at the end of policy year t."""
return pols_surv(t) * conv_rate(t)
[docs]
def pols_lapse(t):
"""Number of lapses at the end of policy year t, including the shock lapse."""
return pols_surv(t) * (1 - conv_rate(t)) * lapse_rate(t)
[docs]
def pols_maturity(t):
"""Number of policies whose coverage ends at attained age 95.
Non-zero only in the final policy year. Not a decrement - the contract runs out -
but needed for the in-force roll-forward to close; see the Space docstring.
"""
if t != proj_len():
return 0.0
return (pols_if(t) * (1 - mort_rate(t))
* (1 - conv_rate(t)) * (1 - lapse_rate(t)))
[docs]
def comm_rate(t):
"""Commission rate **[std]**: 80% in year 1, 5% to the end of the level period, 2% after."""
return 0.80 if t == 1 else (0.05 if t <= policy_term() else 0.02)
[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 in policy year t, at the beginning of the year."""
return premium_pp(t) * pols_if(t)
[docs]
def commissions(t):
"""Commission in policy year t **[std]**."""
return comm_rate(t) * premiums(t)
[docs]
def premium_taxes(t):
"""Premium tax in policy year t **[std]**."""
return premium_tax_rate * premiums(t) # noqa: F821
[docs]
def expenses(t):
"""Acquisition (year 1) and inflating maintenance expenses in policy year t **[std]**."""
acq = expense_acq if t == 1 else 0.0 # noqa: F821
return acq + expense_maint * inflation_factor(t) * pols_if(t) # noqa: F821
[docs]
def claims(t):
"""Death claims incurred in policy year t, paid at the end of the year."""
return sum_assured() * pols_death(t)
[docs]
def conv_credits(t):
"""Conversion credit outflow: one annual premium per conversion, after year 1."""
return premium_pp(t) * pols_conv(t) if t > 1 else 0.0
[docs]
def net_cf(t):
"""Net cash flow in policy year t."""
return (premiums(t) - commissions(t) - premium_taxes(t)
- expenses(t) - claims(t) - conv_credits(t))
[docs]
def result_cf():
"""Result table of cashflows."""
ts = list(range(1, proj_len() + 1))
return pd.DataFrame(
{
"pols_if": [pols_if(t) for t in ts],
"premiums": [premiums(t) for t in ts],
"claims": [claims(t) for t in ts],
"commissions": [commissions(t) for t in ts],
"expenses": [expenses(t) for t in ts],
"premium_taxes": [premium_taxes(t) for t in ts],
"conv_credits": [conv_credits(t) for t in ts],
"net_cf": [net_cf(t) for t in ts],
},
index=pd.Index(ts, name="t"),
)
# ---------------------------------------------------------------------------
# References
data = ("Interface", ("..", "Data"), "auto")
point_id = 1
expiry_age = 95
premium_tax_rate = 0.02
expense_acq = 300.0
expense_maint = 30.0
inflation_rate = 0.02
conv_rate_base = 0.0
conv_rate_final = 0.0
pd = ("Module", "pandas")