Source code for uslib.products.registered_index_linked_annuity.RILA_US_S.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-contract projection of :mod:`~.RILA_US_S`.

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

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

The readers and the filename References live on the sibling
:mod:`~.RILA_US_S.Data` Space, reached here through the ``data``
Reference, so each file is read once per model rather than once per model point:

=========================  ==================================  ==========================
Reference (on Data)        Cells                               File
=========================  ==================================  ==========================
model_point_file           data.model_point_table()            model_point_table.csv
mort_table_file            data.mort_table()                   mort_table.csv
market_scenario_file       data.market_scenario()              market_scenario.csv
surr_charge_file           data.surr_charge_table()            surr_charge_table.csv
guar_min_rate_file         data.guar_min_rate_table()          guar_min_rate_table.csv
lapse_file                 data.lapse_table()                  lapse_table.csv
withdrawal_file            data.withdrawal_table()             withdrawal_table.csv
=========================  ==================================  ==========================

``market_scenario`` is the input class peculiar to this product: the *contractual* formula
consumes market data, so the index level, the Market Value Rate, the risk-free rate, the
dividend yield and the implied volatility are inputs on the same footing as the
withdrawal-charge schedule, not a valuation overlay.

.. rubric:: Projection basis

``t`` counts **policy months** and is **0-based**: ``t = 0`` is the first policy month,
month ``t`` runs from time ``t`` to time ``t + 1`` in policy months from the Issue Date,
and the frame is ``t = 0 … proj_len() - 1``, so ``len(result_cf()) == proj_len()``.
Following the notes, every within-month quantity is evaluated at the month **end**, time
``t + 1``: the market state, the interim value, the Account Value and the benefits on row
``t`` are the values there, while ``pols_if(t)`` is the count entering the month at time
``t``. The Issue Date opens month 0, so the purchase payment, the acquisition expense and
the opening state fall inside that first row rather than in a row of their own.

**A month-end valuation has two readings of "the contract year", and the model carries
both.** ``duration(t) = t // 12`` is the *interval* reading and lifelib's 0-based
duration: the complete contract years elapsed at the **start** of month ``t``, i.e. the
contract year the whole month lies inside. It is the right one for a rate that applies
across the month — the attained age behind ``q_m(t)`` (:func:`age`) and the expense
inflation step ``1.025^(y-1)`` (:func:`inflation_factor`). ``duration_eom(t) =
(t + 1) // 12`` is the *instant* reading, the notes' ``cy`` at the month end: how many
complete contract years have elapsed at time ``t + 1``, where the month's transactions
and decrements settle. It is the right one for the withdrawal charge a transaction bears
(:func:`surr_charge_rate`), the free-withdrawal base snapshotted at an anniversary
(:func:`free_wd_base`), and the surrender behaviour keyed to that charge
(:func:`policy_year`, :func:`lapse_rate_sc_mult`, :func:`lapse_rate_base`), which must see
the charge expire in the same month the contract does. The two differ **only in the month
that closes on an anniversary**: ``duration(11) = 0`` but ``duration_eom(11) = 1``, so
month 11 bears the age-``x`` mortality rate and the year-1 expense level while a surrender
settling at its end is already on the year-2 side of the charge schedule.

:mod:`.MYGA_US_S` needs only one reading, because its beginning-of-month transaction
convention makes the two coincide: there the anniversary month belongs to the year that is
opening, here the anniversary closes the month that has just run, and the two products'
``policy_year`` readings differ in exactly that one month a year. Each convention is the
right one for its own timing basis.

Within month ``t`` the notes' processing order is: refresh the **market state**; apply
**term-end crediting** if ``t`` is a Term End Date; apply the **renewal / transfer** roll
split and re-strike the option; compute **interim values**; accrue the **accounts** and
set the Account Value, snapshotting the free-withdrawal base on a Contract Anniversary;
take **contract-holder transactions**; and apply **decrements** at the end of the month —
death at ``q_m(t)``, then surrender at ``w_m(t)`` on survivors **[std order]**, plus the
discrete term-end surrender fraction ``phi`` at a Term End Date.

Two conventions carry that order through the cells. Every quantity of the option bucket is
**homogeneous of degree one in the Investment Amount** — the notes require it, and the
model imposes it by holding the per-unit ratio :func:`iv_ratio` and multiplying, so a
withdrawal reduces the interim value by exactly the cash removed. And each month's
valuation refers to the option **in force during that month**, which at a Term End Date is
the *expiring* one: :func:`tau` is 0 there, not ``T``, so the replicating portfolio
collapses to intrinsic value and reproduces the crediting rate exactly.

.. rubric:: Naming

Cells names follow lifelib's ``basiclife.BasicTerm_S`` and ``savings.CashValue_SE``, and
this library's deferred annuity chassis :mod:`.MYGA_US_S`, wherever those have
an analogue — ``pols_*`` for policy counts, ``av_*`` for account values, plural nouns for
cash flows, ``*_rate`` for rates, ``*_pp`` for per-contract amounts, ``*_at(t, timing)``
for a quantity read at a point inside the month. The technical notes use compact
actuarial symbols instead. The mapping is:

============================  ================================  ==============================
Notes symbol                  Cells                             Meaning
============================  ================================  ==============================
t                             duration_mth(t)                   Elapsed months at start of t
cy(t) = floor(t/12)           duration(t)                       Complete years during month t
cy_end(t) = floor((t+1)/12)   duration_eom(t)                   Complete years at t's month end
cy_end(t) + 1                 policy_year(t)                    Contract year in force there
x                             age_at_entry                      Issue age (ANB)
x + cy(t)                     age(t)                            Attained age (ANB) during t
(Maturity Date)               policy_term                       Years from issue to maturity
(none)                        proj_len                          Number of months projected
P                             premium_pp                        Single purchase payment
T                             term_years                        Index-linked term in years
(Term Start Date)             term_start_month(t)               First month of that term
(none)                        term_elapsed_mth(t)               Months elapsed in that term
(Term End Date)               is_term_end(t)                    True when t ends a term
tau_k(t)                      tau(t)                            Years remaining in the term
I(t)                          index_level(t)                    Index level
I_s                           index_at_term_start(t)            Index level at the Term Start
R_k(t)                        index_perf(t)                     I(t)/I_s - 1
r(t)                          mvr(t)                            Market Value Rate (CMT)
r_0                           mvr_at_term_start(t)              Market Value Rate at term start
(r, q, sigma)                 risk_free(t), div_yield(t),       Option-pricing market state
                              impl_vol(t)
N(.)                          norm_cdf(x)                       Standard normal c.d.f.
d1, d2                        bs_d1(...), bs_d2(...)            Black-Scholes arguments
C(I,K,tau)                    bs_call(...)                      European call
P(I,K,tau)                    bs_put(...)                       European put
BC(I,K,tau)                   bs_binary_call(...)               Cash-or-nothing binary call
b                             buffer                            Buffer
c, s, e (model point)         declared_cap, declared_step,      Rates declared on the model point
                              declared_edge
c                             cap_rate(t)                       Cap Rate in force, floored
s                             step_rate(t)                      Step Rate in force, floored
e                             edge_rate(t)                      Edge Rate in force, floored
PR                            participation                     Participation Rate
f                             floor_rate                        Floor (optional module)
guar_min_cap(T)               guar_min_rate(kind)               Contractual minimum by term
(one option leg)              opt_component(t, leg, ...)        Signed per-unit leg value
(the leg set)                 opt_legs()                        Legs of the replicating set
Pi(I, tau)                    opt_portfolio(t, spot, tau, mkt)  Per-unit portfolio value
(sum of \|legs\|)               opt_portfolio_abs(...)            Trading-cost assessment base
beta                          opt_budget(t)                     Initial option budget
y_e, spread                   earned_rate, nge_spread           Earned rate; target margin [std]
beta_target(T)                opt_budget_target()               NGE cap-solve target
(the cap solve)               declared_cap_solved(t)            Cap solving Pi = beta_target
g                             credit_rate_term(t)               Term-end crediting rate
AccruedCapRate                credit_rate_accrued(t)            Pre-AG 54 time-prorated rate
B_k(t)                        budget_amort_pp(t)                Amortized initial budget
F_k(t)                        fixed_proxy_pp(t)                 Fixed Income Asset Proxy
D_k(t)                        deriv_proxy_pp(t)                 Derivative Asset Proxy
TC_k(t)                       trading_cost_pp(t)                Trading cost provision
kappa                         trading_cost_rate                 Trading cost factor
[(1+r_0)/(1+r(t))]^tau        mva_factor(t)                     Interim-value rate adjustment
CCF_k(t)                      cap_calc_factor(t)                Cap Calculation Factor, family (b)
E_0                           iv_expense_rate                   CCF expense rate [std], family (b)
rate(t), family (b)           risk_free(t) + iv_credit_spread   Investment-grade discount rate
DailyAdjustment(t)            fixed_proxy_factor(t),            Family (c), the delta form
                              deriv_proxy_factor(t)
V_k(t)                        interim_value_pp_at(t, timing)    Interim Value
V_k(t)/IA_k(t)                iv_ratio(t)                       Interim Value per unit of base
(the decomposition base)      iv_notional_pp(t, timing)         Notional a component sits on
IA_k(t)                       inv_amt_pp(t)                     Investment Amount
IA_k(t) inside the month      inv_amt_pp_at(t, timing)          BEF_CREDIT/BEF_ROLL/BEF_WD/AFT_WD
FA(t)                         fixed_acct_pp(t)                  Fixed Account value
HA(t)                         holding_acct_pp(t)                Holding Account value
i_declared                    acct_rate()                       Declared rate on FA and HA
AV(t)                         av_pp(t), av_pp_at(t, timing)     Account Value
l(t) x AV(t)                  av_at(t, timing)                  In-force weighted Account Value
ROP(t)                        rop_pp(t)                         ROP base at the end of month t
DB(t)                         death_ben_pp(t)                   Death benefit
CSV(t)                        surr_value_pp(t)                  Cash surrender value
AV_anniv(y)                   free_wd_base(t)                   Account Value at the anniversary
0.10 x AV_anniv(y)            free_wd_allow(t)                  Free Withdrawal Amount
FW(t)                         free_wd_avail(t)                  Allowance before the withdrawal
FW(t) after the withdrawal    free_wd_remain(t)                 Allowance a surrender can shelter
FW_used(y)                    free_wd_avail/free_wd_remain      Consumed amount, as its complement
G_total                       wd_pp(t)                          Gross amount removed
G_k                           wd_alloc_pp(t, bucket)            Amount taken from one bucket
(free part of G)              wd_free_pp(t)                     Portion inside the free amount
chargeable                    wd_excess_pp(t)                   Amount exposed to the charge
wc(cy)                        surr_charge_rate(t)               Withdrawal charge rate
WC(t)                         wd_charge_pp(t)                   Withdrawal charge
net proceeds                  wd_payment_pp(t)                  Cash paid on the withdrawal
chargeable on a surrender     surr_excess_pp(t)                 AV(t) less the free amount
wc x chargeable               surr_charge_pp(t)                 Charge on a full surrender
q_m(t)                        mort_rate_mth(t)                  Monthly mortality rate
(annual q_x)                  mort_rate(t)                      Annual mortality rate
w_base(y)                     lapse_rate_base(t)                Un-shocked annual surrender rate
M_sc(y)                       lapse_rate_sc_mult(t),            Charge-expiry shock multiplier
                              lapse_shock_mult                  and its **[std]** size, 3.0
(the shock year)              lapse_shock_year()                First charge-free contract year
M_iv(t)                       lapse_iv_mult(t)                  Moneyness suppression
w_annual(y,t)                 lapse_rate(t)                     Total annual surrender rate
w_m(t)                        lapse_rate_mth(t)                 Monthly surrender rate
phi                           term_end_lapse_rate(t)            Term-end surrender concentration
l(t)                          pols_if(t)                        In-force at the start of month t
l(0)                          pols_if_init                      In-force at issue
l(t+1)                        pols_if_at(t, timing)             BEF_DECR/BEF_LAPSE/BEF_TERM_SURR/AFT_DECR
l(t) q_m                      pols_death(t)                     Deaths
l(t)(1-q_m) w_m + phi         pols_lapse(t)                     Full surrenders
(the phi part)                pols_lapse_term(t)                Term-end concentrated surrenders
(none)                        pols_maturity(t)                  Forced annuitizations at maturity
P in month 0                  premiums(t)                       Premium income
(premium credited)            prem_to_av_pp(t), prem_to_av(t)   Premium credited to the base
G_total - WC(t)               withdrawals(t)                    Withdrawal payments
(ledger benefit lines)        claims(t, kind)                   Benefit outgo by kind
                              claim_pp(t, kind)                 Benefit per contract by kind
                              claims_from_av(t, kind)           Account Value released
                              claims_over_av(t, kind)           Benefit paid above the AV
(investment return)           inv_income_pp(t), inv_income(t)   Return credited to the AV
(acquisition; maintenance)    expenses(t)                       0.06 P + 200; 60/12 x 1.025^(y-1)
1.025^(y-1)                   inflation_factor(t)               Maintenance expense inflation
premium tax                   premium_taxes(t)                  Premium tax, 0% [std]
NetCF(t)                      net_cf(t)                         Net cash flow
============================  ================================  ==============================

.. rubric:: Names that needed care

The notes reuse single letters across three different vocabularies — contract mechanics,
Black-Scholes and the deferred annuity chassis — and the collisions are real.

``T`` is the term length in **years** while ``t`` is the policy **month**, so it becomes
:func:`term_years`; ``tau`` is likewise in years and stays :func:`tau`. ``P`` is both the
single purchase payment and the Black-Scholes put: :func:`premium_pp` against
:func:`bs_put`. ``C`` is the Black-Scholes call, the [S2] fixed-leg symbol for the Market
Value Rate at term start, *and* the surrender charge on the chassis: :func:`bs_call`,
:func:`mvr_at_term_start` and :func:`surr_charge_pp`. ``B`` is the amortized option budget
here and the buffer is ``b``: :func:`budget_amort_pp` against :func:`buffer`. ``R`` is
index performance and ``r`` is the Market Value Rate: :func:`index_perf` against
:func:`mvr`. ``e`` is the Edge Rate, not Euler's number: :func:`edge_rate`.

One collision is worth stating in prose because getting it wrong would be a modelling
error rather than a naming one. On the deferred annuity chassis ``M(t)`` is a **contract**
market value adjustment applied to the surrender benefit. **This product has no such
thing** — the only MVA-like term in the prospectus is the ``[(1+C)/(1+D)]^E`` factor
*inside* the interim value [S2], which is why it is named :func:`mva_factor` (a factor, in
the interim value) and there is no ``mva_pp``, no ``mva_rate`` and no ``mva_cap_rule``
anywhere in this model. The notes' ``M_sc`` and ``M_iv`` are lapse multipliers and are
named :func:`lapse_rate_sc_mult` and :func:`lapse_iv_mult` so they cannot be read as
adjustments either. Nor is there an ``mgsv_pp``: AG 54 displaces Model #805 outright when
it is satisfied [R2][REG-R42][REG-R44], and the nonforfeiture value *is* the interim
value.

.. rubric:: Timing arguments

Investment Amount, ``inv_amt_pp_at(t, timing)``:

``"BEF_CREDIT"``
    the notional entering month ``t``, which is what the month's valuation applies to. In
    month 0 it is the purchase payment allocated to the option — the opening state the
    Issue Date leaves behind.
``"BEF_ROLL"``
    after term-end crediting, ``IA(1 + g)`` at a Term End Date and unchanged otherwise.
    This is the worked example's **Investment Amount** column.
``"BEF_WD"``
    after the renewal / transfer roll split, so ``roll_index_share`` of ``"BEF_ROLL"`` at
    a Term End Date and unchanged otherwise.
``"AFT_WD"``
    after the proportional withdrawal reduction. Equal to :func:`inv_amt_pp`.

Interim Value, ``interim_value_pp_at(t, timing)``, takes the same three of those points —
``"BEF_ROLL"``, ``"BEF_WD"``, ``"AFT_WD"`` — and is in each case the Investment Amount at
that point multiplied by :func:`iv_ratio`. ``"BEF_ROLL"`` is the worked example's
**Interim value** column.

The currency decomposition of that value — :func:`fixed_proxy_pp`,
:func:`opt_component_pp`, :func:`deriv_proxy_pp`, :func:`trading_cost_pp` and
:func:`budget_amort_pp` — takes two of them, ``"BEF_ROLL"`` (the default) and
``"AFT_WD"``, through :func:`iv_notional_pp`. That pair is what the worked example's rows
B1 and B2 are: the same month, the same market state, the decomposition before and after
an $8,000 withdrawal, every component scaled by one factor.

Account Value, ``av_pp_at(t, timing)``, following ``CashValue_SE``'s ``av_pp_at``:

``"BEF_WD"``
    ``AV(t)`` as the notes' step 5 leaves it — after crediting, the roll split, the
    interim value and the account accruals, before any transaction. This is the value the
    free-withdrawal base is snapshotted from and the base the withdrawal is allocated
    across.
``"AFT_WD"``
    after the transaction; equal to :func:`av_pp`, and the value every decrement benefit
    is measured on.

Policy counts, ``pols_if_at(t, timing)``, following ``CashValue_SE``'s ``pols_if_at``:

``"BEF_DECR"``
    ``l(t)``, in force at the **start** of month ``t``; equal to :func:`pols_if`.
``"BEF_LAPSE"``
    after deaths, ``l(t)(1 - q_m(t))``.
``"BEF_TERM_SURR"``
    after the background monthly surrender, ``l(t)(1 - q_m)(1 - w_m)``.
``"AFT_DECR"``
    after the discrete term-end surrender fraction ``phi``. This is the count at the month
    **end**, the notes' ``l(t+1)``: :func:`pols_if` carries the start-of-month count, so
    the end-of-month one lives here and nowhere else.

Benefit ``kind`` arguments are ``"DEATH"``, ``"LAPSE"`` and ``"MATURITY"``. Withdrawal
allocation ``bucket`` arguments are ``"OPTION"``, ``"FIXED"`` and ``"HOLDING"``. Option
``leg`` arguments are ``"ATM_CALL"``, ``"OTM_CALL"``, ``"OTM_PUT"``, ``"ATM_PUT"``,
``"FLOOR_PUT"``, ``"STEP_BINARY"`` and ``"EDGE_BINARY"``. Market-state ``mkt`` arguments
are ``"CURRENT"`` and ``"TERM_START"``. Any other value of any of these raises
``ValueError``.

There is no ``"ANNUITIZATION"`` kind. The notes give no elective annuitization take-up
for this product; the only annuitization modelled is the **forced** one at the Maturity
Date, which is ``"MATURITY"``.

.. rubric:: pols_maturity and the Maturity Date

Unlike the deferred annuity chassis, this product's horizon is contractual rather than
chosen: the Maturity Date is the later of the anniversary after the oldest owner's 90th
birthday and ten years from issue [S2], so ``policy_term()`` is
``max(90 - age_at_entry(), 10)`` years and ``proj_len()`` is twelve times that — 360
months on the anchor cell, the last of them ``t = 359``. The Maturity Date is that
month's end, where the contract force-annuitizes at the Account Value, and
:func:`pols_maturity` carries the survivors out so that

    pols_if(t) - pols_if(t+1) = pols_death(t) + pols_lapse(t) + pols_maturity(t)

closes for every ``t``, including the last — ``pols_if(t)`` opening month ``t`` and
``pols_if(t+1)`` opening the next, which past the Maturity Date is zero. The name follows
``BasicTerm_S.pols_maturity``
and the construction follows :mod:`.Term_US_S` and :mod:`.MYGA_US_S`. The
payout stream bought at that date is not derived here: it is the immediate-annuity
chassis, restricted to the two forms this contract offers [S2].
"""

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 policy_id(): """The contract identifier of the selected model point.""" return model_point()["policy_id"]
[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, M or F.""" return model_point()["sex"]
[docs] def premium_pp(): """P: the single purchase payment per contract; no subsequent payments [S1][S2].""" return float(model_point()["premium"])
[docs] def pols_if_init(): """l(0): in-force probability at issue, 1 for a single-contract model point.""" return float(model_point()["pols_if_init"])
[docs] def term_years(): """T: the index-linked term in years, from the 1 / 3 / 6 menu [S1][S2].""" return int(model_point()["term_years"])
[docs] def buffer(): """b: the buffer, guaranteed for the life of each term [S1][S2]. The buffer absorbs the first ``b`` of index loss and passes the excess through; 10% is the one level present in every retrieved insurer's menu [S1]-[S6]. """ return float(model_point()["buffer"])
[docs] def crediting_type(): """``CAP``, ``STEP``, ``EDGE`` or ``FLOOR``. The chassis offers Cap, Step and Edge crediting [S2]; ``FLOOR`` is the optional module that only one insurer in the sample offers and that needs a **four**-option replicating portfolio rather than three [S5]. """ return model_point()["crediting_type"]
[docs] def declared_cap(): """c: the declared Cap Rate for the initial term, or blank for an uncapped option. 100% at 6 years, 55% at 3 and 12% at 1 **[std]** — no current rate sheet was retrievable, both insurer rate pages returning HTTP 403. A blank means the option is uncapped, in which case the out-of-the-money call is valued at zero [S2]. """ return float(model_point()["declared_cap"])
[docs] def declared_step(): """s: the declared Step Rate, 8% **[std]**; observed illustrative 8% [S1].""" return float(model_point()["declared_step"])
[docs] def declared_edge(): """e: the declared Edge Rate, 6% **[std]**. No Edge value appears in any retrieved document; 6% is set below the 8% Step because the Edge design pays down to -b rather than to 0 and so buys a lower rate. """ return float(model_point()["declared_edge"])
[docs] def participation(): """PR: the Participation Rate, 100% [S4]. At ``PR = 1`` the notes' BUFFER + CAP + PR portfolio collapses **exactly** to the BUFFER + CAP one, so a single branch serves both; see :func:`opt_component`. """ return float(model_point()["participation"])
[docs] def floor_rate(): """f: the floor for the ``FLOOR`` crediting module, a positive fraction [S5].""" return float(model_point()["floor_rate"])
[docs] def iv_family(): """The interim value algebra: ``portfolio``, ``notional``, ``delta`` or ``legacy``. ``portfolio`` **[std]** is family (a), the AG 54-literal Hypothetical Portfolio form [S2][R2] and the baseline. ``notional`` is family (b), the full Segment Investment discounted at a current investment-grade rate plus an always-positive Cap Calculation Factor [S4][S6]. ``delta`` is family (c), a delta applied to the notional with **no interest-rate adjustment term at all** [S5]. ``legacy`` is the pre-AG 54 time-prorated design [S1], which uses no option pricing; it predates AG 54's July 1, 2024 effective date [R2] and is carried as a tractable regression contrast, not as a live design. """ return model_point()["iv_family"]
[docs] def amort_rule(): """``straight_line`` **[std]** [S2] or ``updated_expiry`` [S3]. How the initial option budget is amortized: ``beta x IA x tau/T`` straight-line to term end, or the initial-market-conditions portfolio value re-read at the **updated time to expiry**. One insurer needs both, split by state [S3], so this is a configuration flag rather than a modelling opinion. """ return model_point()["amort_rule"]
[docs] def nge_reset(): """Whether the declared cap is re-solved at each renewal Term Start Date **[std]**. False holds the snapshot scale level, which is the notes' base projection. True applies the ASOP No. 2 determination rule in :func:`declared_cap_solved`, floored at the contractual guaranteed minimum [S2][R5][REG-R26]. """ return bool(model_point()["nge_reset"])
[docs] def wd_rate_ann(): """The behavioural partial-withdrawal rate, 2% of Account Value a year **[std]**. Zero in contract year 1 because the free amount is zero there [S1][S2]; thereafter taken at each Contract Anniversary and capped at the Free Withdrawal Amount, so the base run incurs no withdrawal charge. It is a model point column rather than a Reference because the notes' worked example and the notes' behavioural rule are mutually inconsistent - see the README. """ return float(model_point()["wd_rate_ann"])
[docs] def scenario_id(): """The market scenario the model point runs on, a key into *market_scenario.csv*.""" return model_point()["scenario_id"]
[docs] def wd_schedule_id(): """The scheduled withdrawal programme, a key into *withdrawal_table.csv*.""" return model_point()["wd_schedule_id"]
[docs] def is_uncapped(): """True when the model point leaves ``declared_cap`` blank [S2].""" return bool(pd.isna(model_point()["declared_cap"])) # noqa: F821
[docs] def policy_term(): """Years from issue to the Maturity Date [S2]. The later of the anniversary after the oldest owner's 90th birthday and ten years from issue, read on the ANB attained age **[std]**: 30 years at issue age 60, 10 at 80 or above. """ return max(maturity_age - age_at_entry(), maturity_min_years) # noqa: F821
[docs] def proj_len(): """The number of policy months projected, ``12 * policy_term()``. The **exclusive** end of the frame: months run ``t = 0 ... proj_len() - 1``, so ``len(result_cf()) == proj_len()`` and the Maturity Date is the end of month ``proj_len() - 1``, twelve times ``policy_term()`` months after issue. """ return 12 * policy_term()
[docs] def duration_mth(t): """Policy months elapsed at the **start** of month t. The projection index itself.""" return t
[docs] def duration(t): """cy(t) = floor(t / 12): complete contract years elapsed at the start of month t. lifelib's 0-based duration, ``duration_mth(t) // 12``: it is ``0`` through the whole of contract year 1, months ``t = 0 .. 11``, and steps to 1 at month 12. It is the *interval* reading of the contract year - the year the whole of month ``t`` lies inside - and so is the right one for a rate applied across the month: the attained age behind ``q_m(t)`` (:func:`age`) and the expense inflation step (:func:`inflation_factor`). For a transaction settling at the month **end**, one instant later, use :func:`duration_eom`; the two differ only in the month that closes on an anniversary. """ return t // 12
[docs] def duration_eom(t): """Complete contract years at the month **end** that closes month t, ``(t+1) // 12``. The *instant* reading, and the notes' own ``cy`` at that instant. Month ``t`` ends at time ``t + 1``, so the month ending on the first anniversary is ``t = 11`` and ``duration_eom(11) = 1`` where ``duration(11) = 0``. Anything settled at that instant takes this reading - the withdrawal charge a transaction bears (:func:`surr_charge_rate`), the free-withdrawal base snapshotted at the anniversary (:func:`free_wd_base`), and the surrender behaviour keyed to that charge (:func:`policy_year`, :func:`lapse_rate_sc_mult`), which must see the charge expire in the same month the contract does. """ return (t + 1) // 12
[docs] def policy_year(t): """The contract year in force at the month end that closes month t, ``cy_end + 1``. A contractual, 1-based label, and the key the surrender table is read on. It takes the **month-end** reading ``duration_eom(t) + 1``, not the interval reading ``duration(t) + 1``, because the charge-expiry shock must land in the same month the charge a surrender settling there bears goes to zero, which is :func:`surr_charge_rate` on that same reading. So ``policy_year(11) = 2``: a surrender at the first anniversary already settles on the year-2 side of the schedule, while the mortality and expense rates charged **over** month 11 are still contract year 1's. """ return duration_eom(t) + 1
[docs] def age(t): """The attained age (ANB) during month t, ``x + duration(t)``. Keyed on :func:`duration`, not :func:`duration_eom`: ``q_m(t) = 1 - (1 - q_x)^(1/12)`` is an exposure rate for the whole of month ``t``, so all twelve months of contract year 1 - month 11, which ends on the anniversary, included - are charged at ``q_x`` and the attained age steps at the anniversary rather than one month before it. At the Maturity Date the attained age is therefore ``age(proj_len() - 1) + 1``, the age the contract's own rule names. """ return age_at_entry() + duration(t)
[docs] def is_anniv(t): """True when month t **ends** on a Contract Anniversary, ``(t + 1) % 12 == 0``. Month ``t`` ends at time ``t + 1``, so the first anniversary closes month 11. The Issue Date is not flagged: it opens month 0 rather than ending a month, and nothing the model does at an anniversary - the free-withdrawal reset, the behavioural withdrawal - happens there. """ return (t + 1) % 12 == 0
[docs] def term_elapsed_mth(t): """Months elapsed in the term of the option **in force during** month t. Counted at the month end, so ``1`` in the first month of a term and ``12T`` in the month that ends on a Term End Date - the expiring option, not the renewed one. Reading the boundary month as belonging to the term that is closing is what makes the term-end identity work: the replicating portfolio at ``tau = 0`` collapses to intrinsic value and reproduces ``g`` exactly. """ return (t % (12 * term_years())) + 1
[docs] def term_start_month(t): """The first month of the term in force during month t. Equivalently the Term Start Date itself as a time in policy months, since month ``m`` opens at time ``m``: ``0`` for the initial term, ``12T`` for the first renewal. That is the form the market-state lookups need - :func:`index_at_term_start`, :func:`mvr_at_term_start` and the ``"TERM_START"`` branch of :func:`opt_component` all read :func:`market_state` at this time. """ return t + 1 - term_elapsed_mth(t)
[docs] def tau(t): """tau_k(t): years remaining in the option's term at the end of month t. ``(12T - elapsed) / 12``. The notes define it as days remaining / 365 [S2]; on a monthly grid that is exact twelfths. ``tau(t) = 0`` at every Term End Date, and the term opens at ``tau = T`` - an instant, the opening of month :func:`term_start_month`, not a projected month. """ return (12 * term_years() - term_elapsed_mth(t)) / 12.0
[docs] def is_term_end(t): """True when month t ends on a Term End Date, i.e. ``tau(t) == 0``.""" return term_elapsed_mth(t) == 12 * term_years()
[docs] def market_state(k, name): """Step-function lookup of column ``name`` in the model point's market scenario. ``k`` is a **time** in policy months from the Issue Date, ``k = 0`` at issue, not the month index: the market state month ``t`` is valued at is ``market_state(t + 1, ...)`` and the state locked on a Term Start Date is ``market_state(term_start_month(t), ...)``. Each row of *market_scenario.csv* is keyed by that same ``month_end`` and states the market state that holds from it until the next row of the same scenario, so a flat path is one row. The index path is therefore piecewise constant between the scenario's own anchor months; that is a property of the deterministic scenario, not of the model. """ sub = data.market_scenario().loc[scenario_id()] # noqa: F821 months = [i for i in sub.index if i <= k] return float(sub.loc[max(months), name])
[docs] def index_level(t): """I(t): the index level month t is valued at - its month end - price return [S1][S2]. All representative indices are **price return**, which is why the dividend yield is a live pricing input: omitting it overprices every call in the portfolio. """ return market_state(t + 1, "index_level")
[docs] def index_at_term_start(t): """I_s: the index level at the Term Start Date of the term in force during month t.""" return market_state(term_start_month(t), "index_level")
[docs] def mvr(t): """r(t): the Market Value Rate at month t's month end, annual effective [S2]. The Constant Maturity Treasury yield at the term's maturity, linearly interpolated between adjacent CMT maturities [S2]; the model takes it as an exogenous scalar series **[std]** rather than carrying a curve. """ return market_state(t + 1, "mvr")
[docs] def mvr_at_term_start(t): """r_0: the Market Value Rate on the Term Start Date [S2].""" return market_state(term_start_month(t), "mvr")
[docs] def risk_free(t): """The risk-free rate at month t's month end, annual effective, 4.00% **[std]**.""" return market_state(t + 1, "risk_free")
[docs] def div_yield(t): """q: the dividend yield at month t's month end, annual effective, 2.00% **[std]**. A live pricing input because every representative index is price return [S1][S2]. """ return market_state(t + 1, "div_yield")
[docs] def impl_vol(t): """sigma: the implied volatility at month t's month end, 20.00% flat **[std]**. A flat surface is the largest single simplification in this model. AG 54 requires assumptions "consistent with the observable market prices of derivative assets over the Index Strategy Term, whenever possible" [R2], and a production implementation must supply a surface interpolated in both maturity and moneyness [S4]; that is a change to *market_scenario.csv* plus a lookup, not to any formula here. """ return market_state(t + 1, "impl_vol")
[docs] def index_perf(t): """R_k(t): index performance to date, ``I(t) / I_s - 1``. At a Term End Date this is the crediting input; the cap applies to the **whole-term** return, never annually on a 3 or 6 year term [S5]. """ return index_level(t) / index_at_term_start(t) - 1.0
[docs] def norm_cdf(x): """N(x): the standard normal cumulative distribution function.""" return 0.5 * (1.0 + math.erf(x / math.sqrt(2.0))) # noqa: F821
[docs] def bs_d1(spot, strike, tau_, rate, divy, vol): """Black-Scholes d1. ``[ln(I/K) + (r_cc - q_cc + sigma^2/2) tau] / (sigma sqrt(tau))``. ``rate`` and ``divy`` are **annual effective** and are converted to continuous compounding here, so the 4.00% and 2.00% market inputs enter as ``ln 1.04 = 3.9221%`` and ``ln 1.02 = 1.9803%`` exactly as the notes state. """ rate_cc = math.log(1.0 + rate) # noqa: F821 divy_cc = math.log(1.0 + divy) # noqa: F821 return ((math.log(spot / strike) + (rate_cc - divy_cc + vol ** 2 / 2.0) * tau_) # noqa: F821 / (vol * math.sqrt(tau_))) # noqa: F821
[docs] def bs_d2(spot, strike, tau_, rate, divy, vol): """Black-Scholes d2, ``d1 - sigma sqrt(tau)``.""" return (bs_d1(spot, strike, tau_, rate, divy, vol) - vol * math.sqrt(tau_)) # noqa: F821
[docs] def bs_call(spot, strike, tau_, rate, divy, vol): """C(I, K, tau): the European call, ``I e^(-q tau) N(d1) - K e^(-r tau) N(d2)``. At ``tau = 0`` it returns the intrinsic value ``max(I - K, 0)``, which is what makes the replicating portfolio collapse to the crediting rate at term end. """ if tau_ <= 0.0: return max(spot - strike, 0.0) rate_cc = math.log(1.0 + rate) # noqa: F821 divy_cc = math.log(1.0 + divy) # noqa: F821 d_1 = bs_d1(spot, strike, tau_, rate, divy, vol) d_2 = bs_d2(spot, strike, tau_, rate, divy, vol) return (spot * math.exp(-divy_cc * tau_) * norm_cdf(d_1) # noqa: F821 - strike * math.exp(-rate_cc * tau_) * norm_cdf(d_2)) # noqa: F821
[docs] def bs_put(spot, strike, tau_, rate, divy, vol): """P(I, K, tau): the European put, ``K e^(-r tau) N(-d2) - I e^(-q tau) N(-d1)``. At ``tau = 0`` it returns the intrinsic value ``max(K - I, 0)``. """ if tau_ <= 0.0: return max(strike - spot, 0.0) rate_cc = math.log(1.0 + rate) # noqa: F821 divy_cc = math.log(1.0 + divy) # noqa: F821 d_1 = bs_d1(spot, strike, tau_, rate, divy, vol) d_2 = bs_d2(spot, strike, tau_, rate, divy, vol) return (strike * math.exp(-rate_cc * tau_) * norm_cdf(-d_2) # noqa: F821 - spot * math.exp(-divy_cc * tau_) * norm_cdf(-d_1)) # noqa: F821
[docs] def bs_binary_call(spot, strike, tau_, rate, divy, vol): """BC(I, K, tau): the cash-or-nothing binary call paying 1, ``e^(-r tau) N(d2)``. At ``tau = 0`` it pays 1 when ``I >= K`` and 0 below. That discontinuity is contractual, not an artifact: a Step design pays the full step at ``R = 0.00%`` and zero at ``R = -0.01%`` [S4], and the binary option is what makes the interim value track it. Do not smooth it. """ if tau_ <= 0.0: return 1.0 if spot >= strike else 0.0 rate_cc = math.log(1.0 + rate) # noqa: F821 return (math.exp(-rate_cc * tau_) # noqa: F821 * norm_cdf(bs_d2(spot, strike, tau_, rate, divy, vol)))
[docs] def guar_min_rate(kind): """The contractual minimum declared rate for the model point's term [S1][S2]. 2% / 6% / 8% for the Cap at 1 / 3 / 6 years and 2% for the Step and the Edge. These are **guaranteed elements** in ASOP No. 2 terms, so every renewal-rate assumption is floored here [R5][REG-R26]; the floor mechanically raises the option budget and compresses the spread, which is why the table is a genuine tail exposure. """ row = data.guar_min_rate_table().loc[term_years()] # noqa: F821 if kind == "CAP": return float(row["guar_min_cap"]) elif kind == "STEP": return float(row["guar_min_step"]) elif kind == "EDGE": return float(row["guar_min_edge"]) else: raise ValueError("invalid kind")
[docs] def opt_budget_target(): """beta_target(T) = ``1 - (1 + y_e - spread)^(-T)``: the NGE cap-solve target **[std]**. ``y_e`` is the projected earned rate on supporting assets and ``spread`` the target margin, **[std]** 2.22% - the level implied by the snapshot 6-year cap, which opens its fixed leg at an equivalent accretion yield of 1.7834% against a 4.00% market rate. """ return 1.0 - (1.0 + earned_rate - nge_spread) ** (-term_years()) # noqa: F821
[docs] def opt_budget_at_cap(t, cap): """The per-unit replicating portfolio value at term start for a trial Cap Rate. The cap-solve needs the portfolio as a function of ``c`` while :func:`cap_rate` is what the rest of the model reads, so this cells recomputes the BUFFER + CAP + PR portfolio directly rather than calling :func:`opt_portfolio`, which would close a cycle. It is monotonically increasing in ``cap``. """ i_s = index_at_term_start(t) k = term_start_month(t) tau_ = float(term_years()) pr = participation() rate = market_state(k, "risk_free") divy = market_state(k, "div_yield") vol = market_state(k, "impl_vol") atm = pr * bs_call(i_s, i_s, tau_, rate, divy, vol) / i_s otm = pr * bs_call(i_s, i_s * (1.0 + cap / pr), tau_, rate, divy, vol) / i_s otp = bs_put(i_s, i_s * (1.0 - buffer()), tau_, rate, divy, vol) / i_s return atm - otm - otp
[docs] def declared_cap_solved(t): """The Cap Rate solving ``Pi(c; b, T, sigma, r, q) = beta_target(T)`` **[std]**. The ASOP No. 2 determination rule the notes state for redetermining the declared cap at each Term Start Date [R5]. Solved by bisection because the portfolio value is monotone in ``c`` and no root finder may be imported; ``nge_solve_iter`` bisections of ``[0, nge_cap_search_max]`` reach machine precision on a rate. The result is floored at the contractual minimum by :func:`cap_rate`, not here. """ target = opt_budget_target() lo, hi = 0.0, nge_cap_search_max # noqa: F821 for _ in range(nge_solve_iter): # noqa: F821 mid = 0.5 * (lo + hi) if opt_budget_at_cap(t, mid) < target: lo = mid else: hi = mid return 0.5 * (lo + hi)
[docs] def cap_rate(t): """c: the Cap Rate in force for the term containing month t, floored at the minimum. The snapshot declared cap on the initial term; on a renewal term either the same snapshot (the notes' base projection holds the scale level) or, with ``nge_reset``, the solved cap - in both cases floored at the guaranteed minimum [S2]. Returns ``math.inf`` for an uncapped option, which makes ``min(PR R, c)`` the uncapped payoff and switches the out-of-the-money call off in :func:`opt_component` [S2]. """ if is_uncapped(): return math.inf # noqa: F821 if nge_reset() and term_start_month(t) > 0: return max(guar_min_rate("CAP"), declared_cap_solved(t)) return max(guar_min_rate("CAP"), declared_cap())
[docs] def step_rate(t): """s: the Step Rate in force, floored at the 2% contractual minimum [S2].""" return max(guar_min_rate("STEP"), declared_step())
[docs] def edge_rate(t): """e: the Edge Rate in force, floored at the 2% contractual minimum [S2].""" return max(guar_min_rate("EDGE"), declared_edge())
[docs] def opt_legs(): """The legs of the replicating portfolio for the model point's crediting type. Cap: at-the-money call, out-of-the-money call, out-of-the-money put [S2][S3][S5][S6]. Step: step-scaled at-the-money binary call, out-of-the-money put [S2][S5]. Edge: the binary call is struck at ``I_s(1 - b)`` and is therefore **in the money**, because it pays when the index ratio is at or above ``1 - b`` [S2][S5]. Floor needs **four** options, not three - buy the at-the-money call spread, sell an at-the-money put, and buy back an out-of-the-money put struck at the floor so the short put exposure stops there [S5]. """ kind = crediting_type() if kind == "CAP": return ("ATM_CALL", "OTM_CALL", "OTM_PUT") elif kind == "STEP": return ("STEP_BINARY", "OTM_PUT") elif kind == "EDGE": return ("EDGE_BINARY", "OTM_PUT") elif kind == "FLOOR": return ("ATM_CALL", "OTM_CALL", "ATM_PUT", "FLOOR_PUT") else: raise ValueError("invalid crediting_type")
[docs] def opt_component(t, leg, spot, tau_, mkt): """The **signed** value of one replicating-portfolio leg, per unit of notional. Each option's notional is the Investment Amount [S4], and every strike is set from the Term Start Date index level ``I_s``, so dividing by ``I_s`` makes each leg dimensionless and the portfolio a fraction of notional. ``mkt`` selects the market state: ``"CURRENT"`` prices at month ``t``'s month end, time ``t + 1``, ``"TERM_START"`` prices under the initial market conditions of the term in force - the Term Start Date, time :func:`term_start_month` - which is what the option budget and the ``updated_expiry`` amortization need. The participation rate multiplies the call spread and re-strikes the short call at ``I_s(1 + c/PR)`` [S4]; at ``PR = 1`` that is exactly the plain Cap portfolio, so one branch serves both of the notes' first two rows. An uncapped option values the out-of-the-money call at zero [S2]. """ if mkt == "CURRENT": k = t + 1 elif mkt == "TERM_START": k = term_start_month(t) else: raise ValueError("invalid mkt") rate = market_state(k, "risk_free") divy = market_state(k, "div_yield") vol = market_state(k, "impl_vol") i_s = index_at_term_start(t) pr = participation() if crediting_type() == "CAP" else 1.0 if leg == "ATM_CALL": return pr * bs_call(spot, i_s, tau_, rate, divy, vol) / i_s elif leg == "OTM_CALL": cap = cap_rate(t) if math.isinf(cap): # noqa: F821 return 0.0 strike = i_s * (1.0 + cap / pr) return -pr * bs_call(spot, strike, tau_, rate, divy, vol) / i_s elif leg == "OTM_PUT": strike = i_s * (1.0 - buffer()) return -bs_put(spot, strike, tau_, rate, divy, vol) / i_s elif leg == "ATM_PUT": return -bs_put(spot, i_s, tau_, rate, divy, vol) / i_s elif leg == "FLOOR_PUT": strike = i_s * (1.0 - floor_rate()) return bs_put(spot, strike, tau_, rate, divy, vol) / i_s elif leg == "STEP_BINARY": return step_rate(t) * bs_binary_call(spot, i_s, tau_, rate, divy, vol) elif leg == "EDGE_BINARY": strike = i_s * (1.0 - buffer()) return edge_rate(t) * bs_binary_call(spot, strike, tau_, rate, divy, vol) else: raise ValueError("invalid leg")
[docs] def opt_portfolio(t, spot, tau_, mkt): """Pi(I, tau): the per-unit market value of the replicating option portfolio. The sum of the signed legs. **Verification identity**: at ``tau = 0`` this collapses to the crediting rate ``g`` for every crediting type - for the Cap design ``max(R,0) - max(R-c,0) - max(-b-R,0) = g``. If the interim value at term end does not reproduce ``IA(1 + g)`` exactly, the strike set or the notional convention is wrong; :func:`check_term_end_identity` tests it. """ return sum(opt_component(t, leg, spot, tau_, mkt) for leg in opt_legs())
[docs] def opt_portfolio_abs(t, spot, tau_, mkt): """The sum of the **absolute** leg values: the trading cost assessment base **[std]**. A wider base than the Academy's net derivative value [R6], deliberately, because no prospectus quantifies the trading cost provision; the base must be stated whenever the factor is recalibrated. """ return sum(abs(opt_component(t, leg, spot, tau_, mkt)) for leg in opt_legs())
[docs] def opt_budget(t): """beta: the initial option budget per unit of notional, ``Pi(I_s, T)``. Struck at each Term Start Date under the market conditions of that date. On the anchor cell ``beta = 10.0632%``, so the option budget is $10,063.19 of a $100,000 Investment Amount and the fixed leg opens at $89,936.81. """ return opt_portfolio(t, index_at_term_start(t), float(term_years()), "TERM_START")
[docs] def fixed_leg_yield(t): """The equivalent geometric accretion yield on the fixed leg, ``(1-beta)^(-1/T) - 1``. 1.7834% on the anchor cell: the rate at which $89,936.81 accretes to $100,000 over six years. The gap to the 4.00% market rate is :func:`nge_spread_implied`. """ return (1.0 - opt_budget(t)) ** (-1.0 / term_years()) - 1.0
[docs] def nge_spread_implied(t): """The spread implied by the declared cap: ``earned rate - fixed leg yield``. 2.22% on the anchor cell, and that is the **[std]** ``nge_spread`` input to the cap-solve rule - the model's own snapshot, read back out of the snapshot cap. """ return earned_rate - fixed_leg_yield(t) # noqa: F821
[docs] def credit_rate_at(t, accrual): """g: the crediting rate applied to R(t), with the declared rates scaled by ``accrual``. ``accrual = 1`` is the contractual term-end rule [S1][S2][S4][S5]:: BUFFER + CAP g = min(PR R, c) if R >= 0; g = min(0, R + b) if R < 0 BUFFER + STEP g = s if R >= 0; g = min(0, R + b) if R < 0 BUFFER + EDGE g = e if R >= -b; g = R + b if R < -b FLOOR + CAP g = min(max(R, -f), c) The ``min(0, .)`` in the buffer branch is load-bearing: the Performance Rate can never be greater than zero if the Index Performance is negative [S1]. The discontinuities - a Step design paying the full step at ``R = 0.00%`` and zero at ``R = -0.01%``, an Edge design flipping at the buffer edge - are contractual; do not smooth them [S2][S4]. ``accrual < 1`` is the **pre-AG 54** design [S1], which uses no option pricing at all: the buffer, Cap and Step Rates each accrue linearly over the term and the term-end rules are applied to the accrued rates. """ kind = crediting_type() perf = index_perf(t) buff = buffer() * accrual if kind == "CAP": cap = cap_rate(t) cap = cap if math.isinf(cap) else cap * accrual # noqa: F821 return min(participation() * perf, cap) if perf >= 0.0 else min(0.0, perf + buff) elif kind == "STEP": return step_rate(t) * accrual if perf >= 0.0 else min(0.0, perf + buff) elif kind == "EDGE": edge = edge_rate(t) * accrual return edge if perf >= -buff else perf + buff elif kind == "FLOOR": cap = cap_rate(t) cap = cap if math.isinf(cap) else cap * accrual # noqa: F821 return min(max(perf, -floor_rate() * accrual), cap) else: raise ValueError("invalid crediting_type")
[docs] def credit_rate_term(t): """g at a Term End Date, zero in every other month. Applied as ``IA_k(term end) = IA_k(term start, adjusted for withdrawals) x (1 + g)`` [S1][S2]. """ return credit_rate_at(t, 1.0) if is_term_end(t) else 0.0
[docs] def credit_rate_accrued(t): """The pre-AG 54 time-prorated crediting rate at month t [S1]. ``credit_rate_at(t, elapsed / total)``. The source worked example - $50,000, a 10% buffer, a 10% Cap on a one-year term, index 500 to 600 at day 183 - gives an accrued cap of 5%, a 5% Performance Rate and an interim value of $52,500; on a monthly grid the sixth month end of twelve is exactly half the term, and that is the end of month ``t = 5``, so the model reproduces it to the cent. """ return credit_rate_at(t, 1.0 - tau(t) / term_years())
[docs] def budget_amort_factor(t): """B_k(t) / IA_k(t): the amortized initial option budget, per unit of notional. ``straight_line`` **[std]** [S2] gives ``beta x tau/T``; ``updated_expiry`` [S3] gives the initial-market-conditions portfolio value re-read at the current time to expiry. Both are forced to zero at ``tau = 0``, which is AG 54's boundary condition and what makes ``V = IA(1 + g)`` exact at term end. **This is where homogeneity is imposed.** ``B`` is defined against the *current* ``IA``, so every term of the interim value scales with the notional and a withdrawal reduces the interim value by exactly the cash removed. Freezing ``B`` at the term-start notional instead would make the contract silently gain or lose value on every withdrawal. """ if tau(t) <= 0.0: return 0.0 rule = amort_rule() if rule == "straight_line": return opt_budget(t) * tau(t) / term_years() elif rule == "updated_expiry": return opt_portfolio(t, index_at_term_start(t), tau(t), "TERM_START") else: raise ValueError("invalid amort_rule")
[docs] def mva_factor(t): """``[(1 + r_0) / (1 + r(t))]^tau``: the interim value's interest-rate adjustment [S2]. The only market-value-adjustment-like term in the contract, and it lives **inside** the interim value rather than on the surrender benefit - this product has no contract MVA. At the worked-example parameters a 100 bp rise costs $2,687.62 of interim value at the term midpoint, two orders of magnitude more than the entire trading-cost provision. Family (c) has no such term at all [S5], so the choice of interim-value family is itself a rate-sensitivity assumption. """ return ((1.0 + mvr_at_term_start(t)) / (1.0 + mvr(t))) ** tau(t)
[docs] def fixed_proxy_factor(t): """F_k(t) / IA_k(t): the Fixed Income Asset Proxy, per unit of notional. ``portfolio`` **[std]**: ``(1 - B/IA) x mva_factor`` - AG 54's hypothetical bond, starting at base less the derivative proxy and accreting to the base at unchanged yield [R2][S2]. ``notional``: the **full** notional discounted at an investment-grade rate, risk-free plus ``iv_credit_spread`` = **[std]** 1.00%, with no subtraction of an option budget [S4][S6]. No source quantifies that spread - [S4] says only that the rate is investment-grade, above swap rates, and therefore "will result in a lower value for that component" - so the 1.00% is a standardization on the same footing as ``iv_expense_rate``, and family (b)'s interim value moves with it. ``delta``: ``1 - B/IA``, the notional plus the amortization term, with no rate adjustment [S5]. """ family = iv_family() if family == "portfolio": return (1.0 - budget_amort_factor(t)) * mva_factor(t) elif family == "notional": return (1.0 + risk_free(t) + iv_credit_spread) ** (-tau(t)) # noqa: F821 elif family == "delta": return 1.0 - budget_amort_factor(t) elif family == "legacy": return 0.0 else: raise ValueError("invalid iv_family")
[docs] def deriv_proxy_factor(t): """D_k(t) / IA_k(t): the Derivative Asset Proxy, ``Pi(I(t), tau(t))``. Valued at the current market state. The pre-AG 54 ``legacy`` engine has no derivative proxy at all - it uses no option pricing. """ if iv_family() == "legacy": return 0.0 return opt_portfolio(t, index_level(t), tau(t), "CURRENT")
[docs] def trading_cost_factor(t): """TC_k(t) / IA_k(t): the trading cost provision, ``kappa x sum |leg values|``. Strictly interior to the term: ``kappa_effective = kappa x 1{tau > 0}`` **[std]**. There are no options left to exit at term end, the Academy example likewise shows no trading cost there [R6], and leaving the provision switched on at ``tau = 0`` breaks the term-end verification identity by ``kappa x IA x |g|``. Only family (a) carries it; family (b) has an always-positive expense rebate instead [S4] and family (c) states none [S5]. """ if iv_family() != "portfolio" or tau(t) <= 0.0: return 0.0 return trading_cost_rate * opt_portfolio_abs( # noqa: F821 t, index_level(t), tau(t), "CURRENT")
[docs] def cap_calc_factor(t): """CCF_k(t) / IA_k(t): the Cap Calculation Factor of family (b) [S4]. ``E_0 x tau/T`` - "a return of estimated expenses for the portion of the Segment Duration that has not elapsed", always positive and declining to zero at term end; the source works it as $10 of estimated expenses on a one-year segment giving $6 with 219 days remaining. ``E_0`` is expressed here as a rate of notional, ``iv_expense_rate`` = **[std]** 0.10%, because no source quantifies it - the same standing as family (b)'s other free parameter, ``iv_credit_spread``. """ if iv_family() != "notional": return 0.0 return iv_expense_rate * tau(t) / term_years() # noqa: F821
[docs] def iv_factor(t): """V_k(t) / IA_k: the interim value per unit of the month's **opening** notional. ``F + D - TC + CCF`` for the three AG 54-era families, and ``1 + g_accrued`` for the pre-AG 54 one. Two boundaries fall out of the algebra rather than being imposed: at ``tau = T`` the fixed and derivative legs sum to exactly 1, which is AG 54's requirement that the Index Strategy Base equal the Strategy Value at term start [R2]; at ``tau = 0`` they sum to ``1 + g``, which is the term-end Strategy Value. ``tau = T`` is an instant - the opening of the term's first month - and not a projected month, so no row of the frame carries it; the interim value is **undefined** at term start and term end alike, those points being Strategy Values rather than Interim Values [R6]. At a Term End Date, which *is* a month end, :func:`iv_ratio` returns 1 for the Transfer Period [S2] rather than this factor. """ if iv_family() == "legacy": return 1.0 + credit_rate_accrued(t) return (fixed_proxy_factor(t) + deriv_proxy_factor(t) - trading_cost_factor(t) + cap_calc_factor(t))
[docs] def iv_ratio(t): """V_k(t) / IA_k(t): the interim value per unit of the **current** notional. 1 at a Term End Date, where the Investment Amount has just been credited and the interim value equals it for the Transfer Period [S2]; the interim value factor otherwise. Multiplying by this is how homogeneity is carried through the model: the same ratio serves before and after a withdrawal, so the interim value falls by exactly the cash removed. """ return 1.0 if is_term_end(t) else iv_factor(t)
[docs] def inv_amt_basis_pp(t): """IA_k: the notional the month-t valuation applies to, per contract. The Investment Amount **entering** month ``t``: the whole purchase payment in month 0 - the anchor cell allocates 100% to one 6-year option, and the payment falls at the opening of that month - and the prior month's Investment Amount thereafter. """ return premium_pp() if t <= 0 else inv_amt_pp(t - 1)
[docs] def iv_notional_pp(t, timing): """The notional an interim-value decomposition column is measured on. ``"BEF_ROLL"`` is the opening notional, the one in force during month ``t`` before the roll split - the notes' rows A1, B1, A2 and B3. ``"AFT_WD"`` is the notional after the proportional withdrawal reduction - the notes' row B2, in which **every** component of the interim value scales by the same factor, which is exactly why the interim value then falls by precisely the cash withdrawn. """ if timing == "BEF_ROLL": return inv_amt_basis_pp(t) elif timing == "AFT_WD": return inv_amt_pp(t) else: raise ValueError("invalid timing")
[docs] def opt_component_pp(t, leg, timing="BEF_ROLL"): """The currency value of one replicating-portfolio leg, per contract. The notional at ``timing`` times the per-unit leg value at the current market state. These are the worked example's ATM call, OTM call and OTM put columns. """ return iv_notional_pp(t, timing) * opt_component( t, leg, index_level(t), tau(t), "CURRENT")
[docs] def fixed_proxy_pp(t, timing="BEF_ROLL"): """F_k(t): the Fixed Income Asset Proxy in currency, per contract.""" return iv_notional_pp(t, timing) * fixed_proxy_factor(t)
[docs] def budget_amort_pp(t, timing="BEF_ROLL"): """B_k(t): the amortized initial option budget in currency, per contract.""" return iv_notional_pp(t, timing) * budget_amort_factor(t)
[docs] def deriv_proxy_pp(t, timing="BEF_ROLL"): """D_k(t): the Derivative Asset Proxy in currency, per contract.""" return iv_notional_pp(t, timing) * deriv_proxy_factor(t)
[docs] def trading_cost_pp(t, timing="BEF_ROLL"): """TC_k(t): the trading cost provision in currency, per contract.""" return iv_notional_pp(t, timing) * trading_cost_factor(t)
[docs] def inv_amt_pp_at(t, timing): """IA_k: the Investment Amount per contract, read at a point inside month t. ``"BEF_CREDIT"`` is the opening notional; ``"BEF_ROLL"`` applies term-end crediting; ``"BEF_WD"`` applies the renewal / transfer roll split; ``"AFT_WD"`` applies the proportional withdrawal reduction. Outside a Term End Date the first three coincide. """ if timing == "BEF_CREDIT": return inv_amt_basis_pp(t) elif timing == "BEF_ROLL": return inv_amt_basis_pp(t) * (1.0 + credit_rate_term(t)) elif timing == "BEF_WD": rolled = inv_amt_basis_pp(t) * (1.0 + credit_rate_term(t)) return rolled * roll_share("OPTION") if is_term_end(t) else rolled elif timing == "AFT_WD": return inv_amt_pp(t) else: raise ValueError("invalid timing")
[docs] def inv_amt_pp(t): """IA_k(t): the Investment Amount (AG 54 Index Strategy Base) at end of month t. Term-end crediting, then the roll split, then **the universal proportional rule**: IA_k(t+) = IA_k(t-) x (1 - G_k / V_k(t-)) Every insurer in the sample reduces the notional in proportion to the reduction in interim value, not dollar for dollar [S2][S3][S4][S6], and the reduction in notional therefore **exceeds the cash received** whenever the interim value is below the notional - by ``G (IA/V - 1)``, $1,433.62 on the worked example's $8,000 withdrawal. Modelling withdrawals as dollar-for-dollar reductions overstates the remaining notional in down markets and compounds through the rest of the term. The rule is undefined at a non-positive interim value, which this contract can produce [S2]; the model then leaves the notional unreduced **[std]**, the withdrawal having been allocated away from the option by :func:`wd_alloc_pp`. """ base = inv_amt_pp_at(t, "BEF_WD") value = interim_value_pp_at(t, "BEF_WD") if value <= 0.0: return base return base * (1.0 - wd_alloc_pp(t, "OPTION") / value)
[docs] def interim_value_pp_at(t, timing): """V_k: the Interim Value per contract, read at a point inside month t. ``"BEF_ROLL"`` is the worked example's Interim value column - the value at month ``t``'s month end of the option in force during it, after any term-end crediting and before the roll split. ``"BEF_WD"`` and ``"AFT_WD"`` are that value carried through the roll split and the withdrawal, each as the Investment Amount at that point times :func:`iv_ratio`. Every mid-term transaction settles here: withdrawal, surrender, death benefit, annuitization, transfer and any fee deduction [S1][S2][S4][S6]. It can be **negative even when the index is up** [S2]; flooring it at zero, or at the notional, is not implementing the contract. """ if timing == "BEF_ROLL": return inv_amt_basis_pp(t) * iv_factor(t) elif timing == "BEF_WD": return inv_amt_pp_at(t, "BEF_WD") * iv_ratio(t) elif timing == "AFT_WD": return inv_amt_pp(t) * iv_ratio(t) else: raise ValueError("invalid timing")
[docs] def roll_share(bucket): """The Term End Date roll split **[std]**: 80 / 5 / 15 / 0. 80% renew into the same option at the new declared rate - the contractual default [S1] - 5% transfer to a different index-linked option, modelled as renewal at the same parameters, and 15% transfer to the Fixed Account. The Holding Account share is zero on the base run: it receives maturing amounts only when the option **and** the Fixed Account are both unavailable [S2]. On a probability-weighted single-contract model point the split is a split of *value* between the buckets, which is why it can be applied per contract and independently of the ``phi`` surrender fraction the notes place before it. """ if bucket == "OPTION": return roll_renew_same + roll_transfer_other # noqa: F821 elif bucket == "FIXED": return roll_transfer_fixed # noqa: F821 elif bucket == "HOLDING": return roll_transfer_holding # noqa: F821 else: raise ValueError("invalid bucket")
[docs] def roll_to_acct_pp(t, bucket): """The amount transferred out of the option into a general-account bucket at term end.""" if not is_term_end(t): return 0.0 return inv_amt_pp_at(t, "BEF_ROLL") * roll_share(bucket)
[docs] def acct_rate(): """The declared Fixed and Holding Account rate, floored at the 1% minimum [S1][S2]. 3.00% **[std]** - only the contractual minimum is public. """ return max(declared_acct_rate, guar_min_acct_rate) # noqa: F821
[docs] def fixed_acct_pp_at(t, timing): """FA: the Fixed Account value per contract, read at a point inside month t. ``FA(t) = FA(t-1) x (1 + max(i_declared, 0.01))^(1/12)`` [S1][S2], plus any amount transferred in at a Term End Date, less its pro-rata share of the withdrawal. The transfer accrues for the month in which it arrives **[std]**. The bucket opens empty: the purchase payment is allocated wholly to the index-linked option, so the Fixed Account enters month 0 at zero and stays there until the first Term End Date. """ if timing == "BEF_WD": opening = (fixed_acct_pp(t - 1) if t > 0 else 0.0) + roll_to_acct_pp(t, "FIXED") return opening * (1.0 + acct_rate()) ** (1.0 / 12.0) elif timing == "AFT_WD": return fixed_acct_pp(t) else: raise ValueError("invalid timing")
[docs] def fixed_acct_pp(t): """FA(t): the Fixed Account value per contract at the end of month t.""" return fixed_acct_pp_at(t, "BEF_WD") - wd_alloc_pp(t, "FIXED")
[docs] def holding_acct_pp_at(t, timing): """HA: the Holding Account value per contract, read at a point inside month t. Same accrual and the same 1% guaranteed minimum as the Fixed Account [S1][S2]. It holds maturing amounts to the next Contract Anniversary when the option and the Fixed Account are both unavailable [S2]; ``roll_transfer_holding`` is 0 on the base run, so the bucket stays empty and is carried for completeness of ``AV = sum V_k + FA + HA``. It opens empty for the same reason the Fixed Account does. """ if timing == "BEF_WD": opening = (holding_acct_pp(t - 1) if t > 0 else 0.0) + roll_to_acct_pp( t, "HOLDING") return opening * (1.0 + acct_rate()) ** (1.0 / 12.0) elif timing == "AFT_WD": return holding_acct_pp(t) else: raise ValueError("invalid timing")
[docs] def holding_acct_pp(t): """HA(t): the Holding Account value per contract at the end of month t.""" return holding_acct_pp_at(t, "BEF_WD") - wd_alloc_pp(t, "HOLDING")
[docs] def av_pp_at(t, timing): """AV: the Account Value per contract, ``sum_k V_k(t) + FA(t) + HA(t)``. ``"BEF_WD"`` is the notes' step 5 value - after crediting, the roll split, the interim value and the account accruals, before any transaction. It is the base the withdrawal is allocated across, the base the free-withdrawal allowance is snapshotted from, and the base the return-of-premium reduction ratio uses. ``"AFT_WD"`` is :func:`av_pp`, the value every decrement benefit is measured on. Between term start and term end this contract has **no account value in the ordinary sense**: ``AV`` is a derivative price, depressed exactly when the option leg is out of the money - which is when the return-of-premium guarantee bites. There is no opening timing here: the Account Value entering month 0 is the purchase payment itself, which :func:`inv_amt_basis_pp` carries, and the general-account buckets open empty. """ if timing == "BEF_WD": return (interim_value_pp_at(t, "BEF_WD") + fixed_acct_pp_at(t, "BEF_WD") + holding_acct_pp_at(t, "BEF_WD")) elif timing == "AFT_WD": return av_pp(t) else: raise ValueError("invalid timing")
[docs] def av_pp(t): """AV(t): the Account Value per contract at the end of month t.""" return (interim_value_pp_at(t, "AFT_WD") + fixed_acct_pp(t) + holding_acct_pp(t))
[docs] def inv_income_pp(t): """The investment return credited to one contract's Account Value in month t. ``AV`` before the transaction less the value entering the month - the prior month end, or the purchase payment itself in month 0, which is where the payment falls. On this product it is not "interest credited": it is the month's movement in a derivative price plus the Fixed and Holding Accounts' accrual, and at a Term End Date it also carries the index credit. """ return av_pp_at(t, "BEF_WD") - (av_pp(t - 1) if t > 0 else premium_pp())
[docs] def free_wd_base(t): """AV_anniv(y): the Account Value at the most recent Contract Anniversary [S1][S2]. Read **before** that month's transaction, which is the notes' step 5 - the snapshot is taken when the anniversary is reached and the withdrawal follows at step 6 - so an anniversary withdrawal is measured against a base that already includes that month's crediting. The anniversary at ``12 cy`` closes month ``12 cy - 1``, so the snapshot is that month's ``"BEF_WD"`` value. Before the first anniversary there is no snapshot and the base is the purchase payment; :func:`free_wd_allow` is zero there in any case. """ cy = duration_eom(t) if cy < 1: return premium_pp() return av_pp_at(12 * cy - 1, "BEF_WD")
[docs] def free_wd_allow(t): """The Free Withdrawal Amount for the contract year month t's month end falls in. Zero in contract year 1; thereafter 10% of the Account Value at the prior Contract Anniversary, non-cumulative - nothing carries across an anniversary. """ if duration_eom(t) < 1: return 0.0 return free_wd_rate * free_wd_base(t) # noqa: F821
[docs] def free_wd_avail(t): """FW(t): the unused free withdrawal allowance at month t, before that month's G. Reset to the whole allowance in the month that ends on a Contract Anniversary and carried forward within the contract year, reduced by amounts already withdrawn in it [S1][S2]. Month 0 opens with nothing carried in. """ if is_anniv(t): return free_wd_allow(t) return free_wd_remain(t - 1) if t > 0 else 0.0
[docs] def wd_scheduled_pp(t): """The gross withdrawal scheduled for month t in *withdrawal_table.csv*, else zero. The file is keyed by the **month end** the withdrawal is taken at, so month ``t`` reads key ``t + 1``: the worked example's $8,000 at the 3-year point sits at month end 36 and is taken in month 35. """ table = data.withdrawal_table() # noqa: F821 key = (wd_schedule_id(), t + 1) return float(table.loc[key, "wd_amount"]) if key in table.index else 0.0
[docs] def wd_behavioral_pp(t): """The behavioural partial withdrawal at month t **[std]**. 0% in contract year 1, the free amount being zero there [S1][S2]; thereafter ``wd_rate_ann`` of the Account Value taken at each Contract Anniversary and capped at the Free Withdrawal Amount, so the base run incurs no withdrawal charge. The anniversary closes month ``12y - 1``, and the first one that can bear a withdrawal is the end of month 11, where :func:`free_wd_allow` first turns positive. RMD-driven withdrawals for qualified cells are named in the notes as a behavioural input but no amount formula is given, so no RMD module is implemented. """ if not is_anniv(t): return 0.0 return min(wd_rate_ann() * av_pp_at(t, "BEF_WD"), free_wd_allow(t))
[docs] def wd_pp(t): """G_total: the gross amount removed from the contract at month t, per contract. The scheduled programme plus the behavioural rule. Gross by construction: the withdrawal charge is deducted **from** the amount withdrawn and is not grossed up on this chassis [S1][S2], in contrast to [S4]. Capped at the Account Value **[std]** so a request larger than the contract cannot drive it negative; the contractual treatment of a request that would leave less than the $2,000 minimum - it becomes a full withdrawal [S1][S2] - is not implemented. """ total = wd_scheduled_pp(t) + wd_behavioral_pp(t) return min(total, max(0.0, av_pp_at(t, "BEF_WD")))
[docs] def wd_free_pp(t): """The portion of G_total inside the Free Withdrawal Amount, free of charge [S1][S2].""" return min(wd_pp(t), free_wd_avail(t))
[docs] def free_wd_remain(t): """The unused free withdrawal allowance after month t's withdrawal. This, not :func:`free_wd_avail`, is the allowance a full surrender at the end of month t can still shelter. """ return free_wd_avail(t) - wd_free_pp(t)
[docs] def wd_excess_pp(t): """chargeable: ``max(0, G_total - FW(t))``, the amount exposed to the charge [S1][S2].""" return max(0.0, wd_pp(t) - free_wd_avail(t))
[docs] def surr_charge_rate(t): """wc(cy): the withdrawal charge rate at month t's month end, by **complete** years. 7, 7, 6, 5, 4, 3, 0 per cent [S1][S2], zero once the schedule runs out. Keyed on :func:`duration_eom`, the complete contract years at the instant the transaction settles. Contract year 7 - the first with a zero charge - is also the first year following a 6-year Term End Date on this chassis, which is why the charge-expiry shock lapse is applied there. """ table = data.surr_charge_table() # noqa: F821 key = duration_eom(t) if key not in table.index: return 0.0 return float(table.loc[key, "surr_charge_rate"])
[docs] def wd_charge_pp(t): """WC(t): the withdrawal charge on month t's withdrawal, ``wc(cy) x chargeable``.""" return surr_charge_rate(t) * wd_excess_pp(t)
[docs] def wd_payment_pp(t): """The cash paid on month t's withdrawal, ``G_total - WC(t)``. Not grossed up: the charge comes out of the amount withdrawn [S1][S2]. """ return wd_pp(t) - wd_charge_pp(t)
[docs] def wd_bucket_value_pp(t, bucket): """The value of one bucket at the allocation point, floored at zero. The flooring matters because the interim value can be negative [S2], and a negative weight in a pro-rata allocation is meaningless; a bucket at or below zero simply takes no share **[std]**. """ if bucket == "OPTION": return max(0.0, interim_value_pp_at(t, "BEF_WD")) elif bucket == "FIXED": return max(0.0, fixed_acct_pp_at(t, "BEF_WD")) elif bucket == "HOLDING": return max(0.0, holding_acct_pp_at(t, "BEF_WD")) else: raise ValueError("invalid bucket")
[docs] def wd_alloc_pp(t, bucket): """G_k: the part of G_total taken from one bucket, pro rata to value **[std]**. The notes prescribe allocation across open options **pro rata to interim value**, the prospectuses not prescribing an allocation for an unspecified withdrawal; with a Fixed and a Holding Account present the model extends the same rule to all three buckets. On a single-option model point with empty general accounts the whole withdrawal falls on the option. """ if wd_pp(t) <= 0.0: return 0.0 total = sum(wd_bucket_value_pp(t, b) for b in ("OPTION", "FIXED", "HOLDING")) if total <= 0.0: return 0.0 return wd_pp(t) * wd_bucket_value_pp(t, bucket) / total
[docs] def surr_excess_pp(t): """The part of the Account Value exposed to the charge on a full surrender [S1][S2].""" return max(0.0, av_pp(t) - free_wd_remain(t))
[docs] def surr_charge_pp(t): """The withdrawal charge on a full surrender at the end of month t.""" return surr_charge_rate(t) * surr_excess_pp(t)
[docs] def surr_value_pp(t): """CSV(t): ``AV(t) - wc(cy) x max(0, AV(t) - FW(t))`` [S1][S2]. The prospectus example reproduces here: a $100,000 payment with an $80,000 Account Value at the start of contract year 6 gives a free amount of $8,000, a chargeable $72,000, a 3% charge of $2,160 and a cash value of $77,840. """ return av_pp(t) - surr_charge_pp(t)
[docs] def rop_pp(t): """ROP(t): the return-of-premium GMDB base at the **end** of month t, reduced **proportionally** [S1][S2]. ``ROP(t+) = ROP(t-) x (1 - G_total / AV(t-))``, the ratio taken on the **gross** amount removed from the contract - including any withdrawal charge [S1] - and on the Account Value before the transaction. The base entering month 0 is the purchase payment, which is where the guarantee starts. """ prior = premium_pp() if t <= 0 else rop_pp(t - 1) base = av_pp_at(t, "BEF_WD") if base <= 0.0 or wd_pp(t) <= 0.0: return prior return prior * (1.0 - wd_pp(t) / base)
[docs] def death_ben_pp(t): """DB(t): ``max(AV(t), ROP(t))`` for issue ages 80 and under, ``AV(t)`` for 81+ [S2]. The guarantee sits on top of a value that is itself a derivative price, so it is most in the money exactly when interim values are depressed: the guarantee and the account are not independent, and a deterministic run understates its cost. """ if age_at_entry() <= db_rop_max_issue_age: # noqa: F821 return max(av_pp(t), rop_pp(t)) return av_pp(t)
[docs] def mort_rate(t): """The annual mortality rate at the attained age and sex, times the A/E factor. The shipped table is an illustrative annuitant curve **[std]**, *not* a published basis. The notes prescribe the 2012 IAM **Basic** table (VM-M section 2.C) with generational Projection Scale G2 [REG-R59], which may not be redistributed here; swap it in by repointing ``Data.mort_table_file``. Do **not** run best-estimate mortality off the 2012 IAM *Period* table: it carries the valuation margin built in at construction, so a 100% A/E against it sets deferral-phase mortality roughly 10% below the unloaded basis [REG-R60]. ``mort_ae_factor`` is 100% **[std]** - a placeholder, not a measurement, because public deferred-period annuitant mortality is thin [REG-R65] and the payout chassis's 108.4% is annuitant-select and deliberately not imported. """ rate = float(data.mort_table().loc[(age(t), sex()), "mort_rate"]) # noqa: F821 return rate * mort_ae_factor # noqa: F821
[docs] def mort_rate_mth(t): """q_m(t): the monthly mortality rate, ``1 - (1 - q_x)^(1/12)``.""" return 1.0 - (1.0 - mort_rate(t)) ** (1.0 / 12.0)
[docs] def lapse_rate_base(t): """w_base(y): the un-shocked annual surrender rate for the contract year **[std]**. 2.0% through contract year 7 and 6.0% thereafter, read as a step function of the contract year. A shape placeholder: the VA/RILA behaviour study [REG-R64] reports aggregate counts only and its detailed tables sit behind a paid data package. The shock enters separately as :func:`lapse_rate_sc_mult`, so the two are multiplicative and neither is baked into the other. """ table = data.lapse_table() # noqa: F821 years = [i for i in table.index if i <= policy_year(t)] return float(table.loc[max(years), "lapse_rate_base"])
[docs] def lapse_shock_year(): """The contract year the withdrawal-charge-expiry surrender shock falls in. The first contract year with a zero withdrawal charge, derived from *surr_charge_table.csv* rather than hard-coded, so a different schedule moves the shock with it - the construction :mod:`.UL_US_S` uses. On the 7-7-6-5-4-3-0 schedule that is contract year 7, which on this 6-year chassis is also the first year following a Term End Date [S1][S2]. """ table = data.surr_charge_table() # noqa: F821 free = [cy for cy in table.index if float(table.loc[cy, "surr_charge_rate"]) <= 0.0] return (min(free) if free else max(table.index) + 1) + 1
[docs] def lapse_rate_sc_mult(t): """M_sc(y): the charge-expiry shock multiplier, ``lapse_shock_mult`` = 3.0 **[std]**. Applied in :func:`lapse_shock_year` only, and 1.0 in every other contract year. The size is the scalar Reference ``lapse_shock_mult`` and the year is derived from the charge schedule, matching the universal-life chassis's Reference / Cells split. Contract year 7 is the first year with a zero withdrawal charge [S1][S2] **and** the first following a 6-year Term End Date - on this chassis the two coincide. The order-of-magnitude anchors from adjacent products are ~33% without a living-benefit rider and ~10% with one for FIA [REG-R62][REG-R63][unverified]; the representative RILA carries no living-benefit rider, which argues for the un-suppressed end, but the term structure offsets it, so the **[std]** shock is set well below that anchor. """ return lapse_shock_mult if policy_year(t) == lapse_shock_year() else 1.0 # noqa: F821
[docs] def lapse_iv_mult(t): """M_iv(t): interim-value moneyness suppression, the RILA-specific effect **[std]**. ``min(1.0, max(0.25, V_k(t) / IA_k(t)))^2``, so a bucket at ``V/IA = 0.85`` carries a 0.72 multiplier and one at or above par carries 1.0. Surrendering mid-term crystallizes the interim value, which is punitive when the option leg is out of the money [S1][S2], and a rational holder defers. It is exactly 1 at a Term End Date, where the interim value equals the Investment Amount, which is what makes the term-end concentration :func:`term_end_lapse_rate` the whole story there. """ return min(1.0, max(iv_moneyness_floor, iv_ratio(t))) ** 2 # noqa: F821
[docs] def lapse_rate(t): """w_annual(y,t): the **annual** surrender rate, ``min(0.50, w_base x M_sc x M_iv)``. The 50% ceiling is a **[std] cap**. ``lapse_rate`` is annual and :func:`lapse_rate_mth` is monthly, matching the ``mort_rate`` / ``mort_rate_mth`` pair; the decrement chain reads the monthly one. """ return min(lapse_rate_max, # noqa: F821 lapse_rate_base(t) * lapse_rate_sc_mult(t) * lapse_iv_mult(t))
[docs] def lapse_rate_mth(t): """w_m(t): the monthly background surrender rate, ``1 - (1 - w_annual)^(1/12)``.""" return 1.0 - (1.0 - lapse_rate(t)) ** (1.0 / 12.0)
[docs] def term_end_lapse_rate(t): """phi: the discrete term-end surrender fraction **[std]**. 10% when the withdrawal charge is zero and 3% otherwise, applied at each Term End Date **in addition** to the background monthly rate. During the Transfer Period the interim value equals the Investment Amount - no option adjustment [S2] - so the economic penalty for exiting vanishes for five days each term and surrenders concentrate there. A model that spreads surrenders uniformly across the term systematically over-collects the negative interim value adjustment. """ if not is_term_end(t): return 0.0 return (term_end_lapse_free if surr_charge_rate(t) <= 0.0 # noqa: F821 else term_end_lapse_charged) # noqa: F821
[docs] def pols_if_at(t, timing): """In-force at month t read at the point given by ``timing``. The notes' order is death at ``q_m(t)``, then surrender at ``w_m(t)`` on survivors **[std order]**, plus the discrete term-end fraction ``phi``:: l(t+1) = l(t) (1 - q_m(t)) (1 - w_m(t)) (1 - phi(t)) ``"BEF_DECR"`` is ``l(t)``, the start-of-month count, and is :func:`pols_if` itself. ``"AFT_DECR"`` is the count at the month **end**, the notes' ``l(t+1)``, which lives here because ``pols_if`` carries the start-of-month count library-wide. """ pols = pols_if(t) if timing == "BEF_DECR": return pols pols = pols * (1.0 - mort_rate_mth(t)) if timing == "BEF_LAPSE": return pols pols = pols * (1.0 - lapse_rate_mth(t)) if timing == "BEF_TERM_SURR": return pols pols = pols * (1.0 - term_end_lapse_rate(t)) if timing == "AFT_DECR": return pols raise ValueError("invalid timing")
[docs] def pols_if(t): """l(t): the in-force probability at the **start** of policy month t. The library-wide convention, following :mod:`.Term_US_S` and ``CashValue_SE``: ``pols_if(t)`` counts the contracts entering month ``t`` and is the weight applied to that same month's cash flows, so the ``pols_if`` column of :func:`result_cf` reconciles against the row it sits on - ``premiums(t) / premium_pp()``, ``withdrawals(t) / wd_payment_pp(t)`` and ``expenses(t)`` over the per-contract maintenance charge all return it. ``pols_if(0)`` is therefore ``pols_if_init()``. The count at the month **end** is ``pols_if_at(t, "AFT_DECR")``, the notes' ``l(t+1)``; it is *not* this cells. Zero at ``proj_len()`` and beyond, where the survivors have been force-annuitized out through :func:`pols_maturity`. """ if t <= 0: return pols_if_init() if t >= proj_len(): return 0.0 return pols_if_at(t - 1, "AFT_DECR")
[docs] def pols_death(t): """Deaths in month t, weighted ``l(t) q_m(t)``.""" return pols_if_at(t, "BEF_DECR") * mort_rate_mth(t)
[docs] def pols_lapse_term(t): """The discrete term-end concentrated surrenders, non-zero only at a Term End Date.""" return pols_if_at(t, "BEF_TERM_SURR") * term_end_lapse_rate(t)
[docs] def pols_lapse(t): """Full surrenders in month t: the background rate on survivors, plus ``phi``. Weighted ``l(t)(1 - q_m(t)) w_m(t)`` **[std timing]** for the background part. Both parts settle at the same cash surrender value, so they are one benefit line; the term-end part is broken out as :func:`pols_lapse_term` for diagnosis. """ return pols_if_at(t, "BEF_LAPSE") * lapse_rate_mth(t) + pols_lapse_term(t)
[docs] def pols_maturity(t): """Forced annuitizations at the Maturity Date, non-zero only in the last month. The Maturity Date is the end of month ``proj_len() - 1``, the last month of the frame. Not a decrement - the contract reaches its Maturity Date and must annuitize [S2] - but needed for the in-force roll-forward to close; see the Space docstring. """ return pols_if_at(t, "AFT_DECR") if t == proj_len() - 1 else 0.0
[docs] def pols_decr(t, kind): """The number of contracts leaving in month t by benefit ``kind``.""" if kind == "DEATH": return pols_death(t) elif kind == "LAPSE": return pols_lapse(t) elif kind == "MATURITY": return pols_maturity(t) else: raise ValueError("invalid kind")
[docs] def claim_pp(t, kind): """The benefit paid per contract in month t by ``kind``. ``"DEATH"`` is :func:`death_ben_pp`, ``max(AV, ROP)`` in the return-of-premium band [S2]. ``"LAPSE"`` is the cash surrender value :func:`surr_value_pp`. ``"MATURITY"`` is the Account Value, force-annuitized at the Maturity Date [S2]; converting it to a payment stream is the immediate-annuity chassis, restricted to the two forms this contract offers - and note that this contract offers **no refund forms**, so that branch of the chassis is unused. """ if kind == "DEATH": return death_ben_pp(t) elif kind == "LAPSE": return surr_value_pp(t) elif kind == "MATURITY": return av_pp(t) else: raise ValueError("invalid kind")
[docs] def claim_from_av_pp(t, kind): """The Account Value released per contract by a claim of ``kind``. ``AV(t)`` for all three: decrements act at the end of the month on the post-transaction Account Value. It differs from :func:`claim_pp` by the withdrawal charge retained on a surrender and by any return-of-premium excess on a death claim; that difference is :func:`claims_over_av`. """ if kind in ("DEATH", "LAPSE", "MATURITY"): return av_pp(t) else: raise ValueError("invalid kind")
[docs] def premiums(t): """Premium income: the single purchase payment, at the opening of month 0 [S1][S2]. Weighted by :func:`pols_if`, the count entering month t, so ``premiums(0) / premium_pp() == pols_if(0)``. """ return premium_pp() * pols_if(t) if t == 0 else 0.0
[docs] def prem_to_av_pp(t): """Premium credited to the Investment Amount per contract; the whole payment in month 0. There is no front-end load and no explicit asset-based charge on this chassis: the cap *is* the fee, and the margin appears as the spread between the earned rate and the option budget implied by the declared cap [S3][S4][S6]. """ return premium_pp() if t == 0 else 0.0
[docs] def prem_to_av(t): """Premium credited to the block's Account Value.""" return prem_to_av_pp(t) * pols_if(t) if t == 0 else 0.0
[docs] def withdrawals(t): """Withdrawal payments in month t: ``(G_total - WC(t))`` weighted by :func:`pols_if`. The weight is the count entering month t - transactions are taken before the month's decrements - so ``withdrawals(t) / wd_payment_pp(t) == pols_if(t)``. The withdrawal charge is retained by the insurer, so it is not a cash flow of its own: it is the difference between the gross amount removed from the contract and the cash paid out. """ return wd_payment_pp(t) * pols_if(t)
[docs] def claims(t, kind=None): """Benefit outgo in month t, for one ``kind`` or, with ``kind=None``, all three.""" if kind is None: return (claims(t, "DEATH") + claims(t, "LAPSE") + claims(t, "MATURITY")) return claim_pp(t, kind) * pols_decr(t, kind)
[docs] def claims_from_av(t, kind): """The Account Value released by a claim of ``kind``, in-force weighted.""" return claim_from_av_pp(t, kind) * pols_decr(t, kind)
[docs] def claims_over_av(t, kind=None): """Benefit paid less the Account Value released, ``claims - claims_from_av``. Signed. Negative on a surrender inside the charge period, where the contract pays out less than the Account Value it releases; positive on a death claim where the return-of-premium guarantee bites. A reconciliation quantity, not a cash flow of its own. """ if kind is None: return (claims_over_av(t, "DEATH") + claims_over_av(t, "LAPSE") + claims_over_av(t, "MATURITY")) return claims(t, kind) - claims_from_av(t, kind)
[docs] def inflation_factor(t): """``1.025^(y-1)``: the expense inflation factor for the contract year month t is in. Keyed on :func:`duration`, so ``y = duration(t) + 1`` and the step falls on the month *after* an anniversary: the maintenance expense is incurred over month ``t``, and all twelve months of contract year 1 - month 11, which ends on the anniversary, included - are charged at the issue level. """ return (1.0 + inflation_rate) ** duration(t) # noqa: F821
[docs] def expenses(t): """Insurer expenses: acquisition at issue and inflating maintenance monthly **[std]**. ``0.06 x premium + 200`` at the opening of month 0, where the purchase payment falls, and ``60/12 x 1.025^(y-1)`` per contract in every month including that one, weighted by :func:`pols_if` - the count entering the month, since the expense is incurred over it - with ``y = duration(t) + 1`` the contract year the month lies inside (:func:`inflation_factor`). Month 0 therefore carries both lines. The notes' ledger carries no separate commission line - distribution cost sits inside the acquisition expense - so this model has no ``commissions`` cells. """ if t < 0 or t >= proj_len(): return 0.0 acq = (expense_acq_rate * premium_pp() # noqa: F821 + expense_acq_fixed) if t == 0 else 0.0 # noqa: F821 return (acq + (expense_maint / 12.0) # noqa: F821 * inflation_factor(t)) * pols_if(t)
[docs] def premium_taxes(t): """Premium tax on the purchase payment, 0% **[std]** [S2]. State-specific and not quantified in any retrieved document; the parameter is exposed rather than removed. """ return premium_tax_rate * premiums(t) # noqa: F821
[docs] def net_cf(t): """Net cash flow in policy month t. Term-end index credits, the movement of the interim value, the withdrawal charge and the roll split between the option and the general accounts are **internal accounting entries**: they drive the Investment Amount, the Account Value and the benefit *amount*, but are never ledger lines. The option budget is not a liability cash flow at all - it is an asset-side flow, and the liability model sees it only through the declared cap. Only amounts paid to or received from the contract holder, and the insurer's own expenses, appear here. """ return (premiums(t) - withdrawals(t) - claims(t) - expenses(t) - premium_taxes(t))
[docs] def av_at(t, timing): """The in-force weighted Account Value at month t; ``timing`` as in :func:`av_pp_at`. ``"BEF_WD"`` carries :func:`pols_if`, the count entering month ``t``. ``"AFT_WD"`` carries the count *leaving* it, which under the start-of-period convention is ``pols_if(t + 1)`` - zero in the last month, where :func:`pols_maturity` has force-annuitized the survivors out and the block's Account Value is released in full. """ if timing == "BEF_WD": return av_pp_at(t, "BEF_WD") * pols_if(t) elif timing == "AFT_WD": return av_pp(t) * pols_if(t + 1) else: raise ValueError("invalid timing")
[docs] def inv_income(t): """The investment return credited to the whole block in month t.""" return inv_income_pp(t) * pols_if(t)
[docs] def wd_from_av(t): """The Account Value released by month t's withdrawals, gross of the charge.""" return wd_pp(t) * pols_if(t)
[docs] def av_change(t): """The change in the block's Account Value over month t. The block opens month 0 with nothing: the purchase payment arrives inside it, as :func:`prem_to_av`. """ return av_at(t, "AFT_WD") - (av_at(t - 1, "AFT_WD") if t > 0 else 0.0)
[docs] def roll_fwd_tol(): """The absolute tolerance the ``check_*`` booleans hold a currency residual to. One part in a billion of the purchase payment. The roll-forward identities are differences of Account Values of that size, and the option pricer's own rounding sits well inside it; a genuine wiring error is orders of magnitude larger. """ return 1e-9 * max(1.0, premium_pp())
[docs] def check_av_roll_fwd_resid(t): """Account value roll-forward residual; zero to floating point in every month. ``AV(t) - AV(t-1) = premium in - withdrawals out + investment return - the Account Value released by each of the three claim kinds``. The *cash* paid can differ from the Account Value released - by the withdrawal charge retained on a surrender and by the return-of-premium excess on a death claim - and that difference is :func:`claims_over_av`, not part of this identity. Month 0 is included: the block opens at nothing, the purchase payment arrives as ``prem_to_av(0)``, and the identity closes on it like any other month. The signed per-month residual, which is what a failing :func:`check_av_roll_fwd` needs for diagnosis; the boolean is implemented in terms of it. """ expected = (prem_to_av(t) - wd_from_av(t) + inv_income(t) - claims_from_av(t, "DEATH") - claims_from_av(t, "LAPSE") - claims_from_av(t, "MATURITY")) return av_change(t) - expected
[docs] def check_av_roll_fwd(): """True when the account value roll-forward closes in **every** projected month. No argument and a bool, following ``CashValue_SE.check_av_roll_fwd`` and the rest of this library, so one test can call the same check across every model; :func:`check_av_roll_fwd_resid` gives the signed residual of a month that fails. """ return all(abs(check_av_roll_fwd_resid(t)) <= roll_fwd_tol() for t in range(proj_len()))
[docs] def check_pols_roll_fwd_resid(t): """In-force roll-forward residual; zero to floating point in every month. ``pols_if(t) - pols_if(t+1) = deaths + surrenders + forced annuitizations at the Maturity Date``. Both counts are start-of-month, so the month's movements sit between the month that is opening and the one that follows it. """ 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 month. No argument and a bool; :func:`check_pols_roll_fwd_resid` gives the signed residual. The counts are probabilities of order 1, so the tolerance is absolute rather than the currency-scaled :func:`roll_fwd_tol`. """ return all(abs(check_pols_roll_fwd_resid(t)) <= 1e-12 for t in range(proj_len()))
[docs] def check_term_end_identity_resid(t): """The notes' term-end verification identity, ``V_k = IA_k(1 + g)``; zero everywhere. At ``tau = 0`` the replicating portfolio collapses to intrinsic value and must reproduce the crediting rate exactly. If this is not zero to the cent, the strike set, the notional convention, the buffer sign - or a trading cost provision left switched on at ``tau = 0`` - is wrong. Zero by construction outside a Term End Date, where the identity does not apply. """ if not is_term_end(t): return 0.0 return (interim_value_pp_at(t, "BEF_ROLL") - inv_amt_basis_pp(t) * (1.0 + credit_rate_term(t)))
[docs] def check_term_end_identity(): """True when ``V_k = IA_k(1 + g)`` holds at **every** Term End Date. No argument and a bool, like the two roll-forward checks; :func:`check_term_end_identity_resid` gives the signed residual of a term end that fails. This is the check the notes ask for by name. """ return all(abs(check_term_end_identity_resid(t)) <= roll_fwd_tol() for t in range(proj_len()))
[docs] def result_cf(): """Result table of cashflows, indexed by policy month ``t = 0 ... proj_len() - 1``. Month 0 carries the purchase payment and the acquisition expense - they fall at its opening, which is the Issue Date - as well as that month's own maintenance expense and decrements. The surrender column is ``claims_lapse``, matching the ``"LAPSE"`` ``kind`` that produces it, and partial withdrawals sit in their own ``withdrawals`` column rather than among the claims - a withdrawal is a payment on the owner's election, not a claim. The cash flow columns sum to ``net_cf`` under the income-positive sign convention: ``premiums`` less every other flow. ``pols_if`` is the count in force at the **start** of each month, so it is the weight the cash flows on that same row carry - the column and the row reconcile. """ ts = list(range(proj_len())) return pd.DataFrame( # noqa: F821 { "pols_if": [pols_if(t) for t in ts], "premiums": [premiums(t) for t in ts], "withdrawals": [withdrawals(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], "expenses": [expenses(t) for t in ts], "premium_taxes": [premium_taxes(t) for t in ts], "net_cf": [net_cf(t) for t in ts], }, index=pd.Index(ts, name="t"), # noqa: F821 )
[docs] def result_pols(): """Result table of in-force movements, indexed by policy month t. ``pols_if`` opens the month, the four movement columns are what leaves it, and ``pols_if_aft_decr`` is the count at the month end, the notes' ``l(t+1)`` - after death, surrender and the term-end concentration, but before the Maturity Date annuitization that ``pols_maturity`` carries out. """ ts = list(range(proj_len())) 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_lapse_term": [pols_lapse_term(t) for t in ts], "pols_maturity": [pols_maturity(t) for t in ts], "pols_if_aft_decr": [pols_if_at(t, "AFT_DECR") for t in ts], }, index=pd.Index(ts, name="t"), # noqa: F821 )
[docs] def result_av(): """Result table of per-contract values, indexed by policy month t. The Investment Amount and Interim Value either side of the month's transaction, the Account Value, the general accounts, the return-of-premium base and the surrender and death benefits. """ ts = list(range(proj_len())) return pd.DataFrame( # noqa: F821 { "inv_amt_pp": [inv_amt_pp(t) for t in ts], "interim_value_pp": [interim_value_pp_at(t, "AFT_WD") for t in ts], "fixed_acct_pp": [fixed_acct_pp(t) for t in ts], "holding_acct_pp": [holding_acct_pp(t) for t in ts], "av_pp_bef_wd": [av_pp_at(t, "BEF_WD") for t in ts], "wd_pp": [wd_pp(t) for t in ts], "av_pp": [av_pp(t) for t in ts], "rop_pp": [rop_pp(t) for t in ts], "surr_charge_pp": [surr_charge_pp(t) for t in ts], "surr_value_pp": [surr_value_pp(t) for t in ts], "death_ben_pp": [death_ben_pp(t) for t in ts], }, index=pd.Index(ts, name="t"), # noqa: F821 )
[docs] def result_iv(): """The interim value decomposition, one row per month - the worked example's table. Index level, index performance, the fixed income asset proxy, one column per replicating-portfolio leg, the derivative asset proxy, the trading cost, the interim value and the Investment Amount, each measured before the roll split so the columns line up with the notes. """ ts = list(range(proj_len())) out = { "index_level": [index_level(t) for t in ts], "index_perf": [index_perf(t) for t in ts], "tau": [tau(t) for t in ts], "fixed_proxy_pp": [fixed_proxy_pp(t) for t in ts], } for leg in opt_legs(): out[leg.lower()] = [opt_component_pp(t, leg) for t in ts] out["deriv_proxy_pp"] = [deriv_proxy_pp(t) for t in ts] out["trading_cost_pp"] = [trading_cost_pp(t) for t in ts] out["interim_value_pp"] = [interim_value_pp_at(t, "BEF_ROLL") for t in ts] out["inv_amt_pp"] = [inv_amt_pp_at(t, "BEF_ROLL") for t in ts] return pd.DataFrame(out, index=pd.Index(ts, name="t")) # noqa: F821
# --------------------------------------------------------------------------- # References data = ("Interface", ("..", "Data"), "auto") point_id = 1 maturity_age = 90 maturity_min_years = 10 free_wd_rate = 0.1 trading_cost_rate = 0.001 iv_credit_spread = 0.01 iv_expense_rate = 0.001 earned_rate = 0.04 nge_spread = 0.0222 nge_cap_search_max = 5.0 nge_solve_iter = 80 declared_acct_rate = 0.03 guar_min_acct_rate = 0.01 roll_renew_same = 0.8 roll_transfer_other = 0.05 roll_transfer_fixed = 0.15 roll_transfer_holding = 0.0 db_rop_max_issue_age = 80 mort_ae_factor = 1.0 iv_moneyness_floor = 0.25 lapse_shock_mult = 3.0 lapse_rate_max = 0.5 term_end_lapse_free = 0.1 term_end_lapse_charged = 0.03 expense_acq_rate = 0.06 expense_acq_fixed = 200.0 expense_maint = 60.0 inflation_rate = 0.025 premium_tax_rate = 0.0 math = ("Module", "math") pd = ("Module", "pandas")