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