Implementation Notes#
Status: Draft, 2026-08-14. Built from
products/registered_index_linked_annuity/technical-notes.md;
the product it implements is specified in
product-spec.md.
This is a mechanics demonstration, not a pricing or reserving result. The contractual elements — the 10% buffer, the 1/3/6 year term menu, the guaranteed minimum Cap/Step/Edge rates, the 7-7-6-5-4-3-0 withdrawal charge above a 10% free amount, the return-of-premium death benefit, the Maturity Date rule and the AG 54 interim value algebra — are sourced from the composite specimen. Every declared rate and every piece of market data is a snapshot: no current rate sheet was retrievable (both insurer rate pages returned HTTP 403), and the risk-free rate, dividend yield and implied volatility are flat scalars. Every behavioural and expense assumption is a std standardization, and the shipped mortality table is illustrative, not the prescribed 2012 IAM Basic basis. Replace them with company data and a real market-data interface before drawing any conclusion from the numbers.
This model is built on the deferred annuity base chassis,
MYGA_US_S, and shares its cells
names for every concept the two have in common. Where the RILA notes restate a recursion
with product-specific parameters, this model follows the RILA notes rather than the
chassis — and in three places that means the chassis mechanic is absent, not merely
different. See What this model does not inherit from the chassis below.
Run it#
python products/registered_index_linked_annuity/run.py
python products/registered_index_linked_annuity/run.py 2 # Scenario B, with the withdrawal
Three lines to the same thing:
import modelx as mx
model = mx.read_model("products/registered_index_linked_annuity/RILA_US_S")
model.Projection[1].result_iv()
Projection takes a point_id; Projection[1] and Projection[2] are the worked
example’s anchor cell on the notes’ Scenario A and Scenario B. result_iv() is the
notes’ own table, one row per month: index level, index performance, tau, 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. result_cf() returns the
cash flow statement indexed by policy month, result_av() the account values and
benefits, and result_pols() the in-force movements.
The model and both of its Spaces carry docstrings. model.doc describes the product and
the projection basis, model.Projection.doc holds the full mapping between the technical
notes’ symbols and the cells names, and model.Data.doc the input arrangement.
An option pricer is a precondition, not a refinement#
A conforming RILA model requires an option-pricing routine and a market-data interface. Actuarial Guideline LIV makes the Interim Value — the value at which every mid-term withdrawal, surrender, death benefit, annuitization, transfer and fee deduction settles — the market value of a hypothetical replicating portfolio of European options plus a fixed income proxy R2, and the source prospectuses implement exactly that with Black-Scholes [S2] [S6]. No other product in this library has a contractual value that cannot be computed without a derivatives pricer.
The three components the notes insist on separating are separate here too:
Component |
Cells |
|---|---|
Crediting engine |
|
Interim-value engine |
|
Market-data provider |
|
Black-Scholes lives in norm_cdf, bs_d1, bs_d2, bs_call, bs_put and
bs_binary_call, written against the standard library’s math.erf — no scipy. The
rate arguments are annual effective and are converted to continuous compounding inside
the pricer, 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.
Monthly, t is 0-based, and the month is valued at its end#
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(). The contractual interim
value is a daily quantity; the model evaluates it at each month end std —
time t + 1 for row t — which resolves every contractual boundary because terms are
whole years, the withdrawal-charge schedule runs by complete contract years and the
free-withdrawal limit resets annually. A daily sub-grid is needed only for a
path-dependent Performance Lock election module, which is not implemented.
The consequence to hold on to is that a time point and a month index differ by one.
The first anniversary is time 12, which is the end of month 11; the 6-year Term End Date
is time 72, the end of month 71; the Maturity Date is the end of month proj_len() − 1.
Anything settled at an instant therefore lands on the row of the month that instant
closes.
A month-end valuation needs both readings of the contract year, and the model carries both#
duration(t) = t // 12 is lifelib’s 0-based duration: the complete contract years elapsed
at the start of month t, hence the contract year the whole month lies inside. It is
0 through the whole of contract year 1, months 0 to 11. That is the right reading for a
rate applied across the month — the monthly mortality rate
q_m(t) = 1 - (1 - q_x)^(1/12) is an exposure rate for the whole of month t, and the
maintenance expense is incurred over it — so age(t) = age_at_entry() + duration(t) and
inflation_factor(t) = 1.025^duration(t).
duration_eom(t) = (t + 1) // 12 is the complete contract years at the month end, the
notes’ cy at the instant the month’s transactions settle. That is the right reading for
anything settled there: the withdrawal charge a transaction bears (surr_charge_rate),
the free-withdrawal base snapshotted at the anniversary (free_wd_base), and — because it
must move in the same month as the charge it responds to — the charge-expiry lapse shock
(lapse_rate_sc_mult, keyed on policy_year(t) = duration_eom(t) + 1).
policy_year(t) takes the month-end reading deliberately, so it is duration_eom(t) + 1 rather than duration(t) + 1: it is the contract year a transaction settling at the
month end falls in, which is what the surrender table is keyed on. policy_year(11) = 2
while duration(11) = 0 — a surrender at the first anniversary is already on the year-2
side of the schedule, while the mortality and expense rates charged over month 11 are
still contract year 1’s.
The difference is one month and it appears only in the month that closes on an
anniversary, but it is not cosmetic: on duration_eom the attained age would step at
month 11, leaving eleven months of contract year 1 at q_x and charging the twelfth at
q_(x+1) (0.005495 against 0.006065 on the anchor cell), and the expense inflation step
would land a month early against the notes’ 60/12 x 1.025^(y-1). At the Maturity Date
the consequence is visible in the other direction: age(proj_len() − 1) is 89, the age
during the last month, and the owner attains 90 at its end — which is the Maturity Date
the contract’s own rule names.
MYGA_US_S needs only one reading, because its beginning-of-month transaction convention
makes the two coincide: that chassis takes elective transactions at the beginning of the
month, so its anniversary month belongs to the year that is opening; here the month end
is the anniversary, so the year that has just closed is the one a transaction sees. Each
convention is right for its own timing basis, and the two products’ policy_years differ
in exactly that one month a year.
proj_len() is contractual rather than chosen, and is the number of months projected:
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() = max(90 − age_at_entry(), 10) years —
360 months on the anchor cell, t = 0 … 359.
Inputs are external files#
The seven input CSVs live in this directory, beside run.py — not inside the model
folder. RILA_US_S/ holds nothing but formulas:
products/registered_index_linked_annuity/
model_point_table.csv <- inputs live here
mort_table.csv
market_scenario.csv
surr_charge_table.csv
guar_min_rate_table.csv
lapse_table.csv
withdrawal_table.csv
run.py
README.md
RILA_US_S/ <- formulas only
__init__.py (model docstring)
_system.json
Data/__init__.py (reads the CSVs, once per model)
Projection/__init__.py (the by-contract projection)
This follows lifelib’s annuallife/TradLife_A, which keeps input.xlsx beside the model
and reads it at run time. It is the opposite of basiclife/BasicTerm_S, which stores its
inputs inside the model through modelx’s IOSpec machinery — hence no _data/ directory
and no embedded values here at all.
Read once, in Data#
Projection is parameterized by point_id, so every Projection[N] is a separate
ItemSpace with its own cells cache. Readers placed there would re-read every file for every
model point. They live instead in an unparameterized Data Space, which Projection
references as data — so each file is read once per model no matter how many contracts are
projected, and Projection[1].data is Projection[2].data.
Data.input_dir() resolves the location from _model.path.parent when the model is read,
so it works wherever the repository is checked out. Each table has a filename Reference and
a reader Cells, both on Data:
Reference |
Cells |
File |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
The trade-off: the model is not portable on its own. Copy
RILA_US_S/ without the CSVs and it will read fine, then fail on
first evaluation. What you gain is that a diff of the model shows logic changes only, and
an input can be edited or swapped in place — point Data.mort_table_file at another
same-schema file and the projection follows, with no formula change.
File |
Contents |
Provenance |
|---|---|---|
|
Fifteen contracts. Points 1 and 2 are the worked example’s anchor cell — M60 / $100,000 / one 6-year Cap option / 10% buffer / 100% cap — on Scenario A and Scenario B, point 2 carrying the illustrative $8,000 withdrawal. Point 3 runs the notes’ base behavioural withdrawal rule. Points 4–14 carry Step, Edge and Floor crediting, interim value families (b) and (c), the pre-AG 54 engine, the updated-time-to-expiry amortization, a 110% participation rate on a 20% buffer, an uncapped option at issue age 81, the NGE cap-solve, and a charged excess withdrawal. Point 15 is the notes’ second labelled verification, the [S2] withdrawal-charge example — see below |
contract terms sourced [S1] [S2] [S4] [S5]; declared rates and every behavioural switch std |
|
Annual mortality by attained age 40–120 and sex, with a |
std illustrative annuitant curve. Not a published table. The prescribed basis is 2012 IAM Basic with generational Projection Scale G2 REG-R59, which may not be redistributed here — swap it in by repointing |
|
Four deterministic scenarios keyed by |
worked example std; the flat 4.00% / 2.00% / 20.00% market state is std |
|
The withdrawal charge by complete contract year: 7, 7, 6, 5, 4, 3, 0 per cent |
sourced [S1] [S2] |
|
Guaranteed minimum Cap / Step / Edge rates by term: 2% / 6% / 8% Cap at 1 / 3 / 6 years, 2% Step and Edge |
sourced [S1] [S2] |
|
The un-shocked annual surrender rate by contract year. The charge-expiry shock is not in this file: its size is the |
std reference shape; the RILA-specific tables in REG-R64 sit behind a paid data package |
|
Three scheduled programmes keyed by |
worked example [S2]; the variant std |
Every time-like column in the inputs, and what it means#
Four of the seven files carry a column that could be read as a time index. Each was decided by meaning, not by name:
File |
Column |
Decision |
Why |
|---|---|---|---|
|
|
A time point, values unchanged, column renamed from |
Each row is the market state holding from a date, and one of those dates is a Term Start Date that the option pricer reads directly. Times are what the contract names — the 6-year term ends at month end 72, the worked example’s rate move lands at month end 36 — so shifting the values would break the tie to the contract. It is not the frame’s |
|
|
Same, values unchanged, column renamed from |
The same clock, so that the worked example’s $8,000 and the scenario’s index move sit at the same number, 36. |
|
|
Unchanged |
Its values are 0–6: complete contract years, an elapsed count that is 0-based already. Read at |
|
|
Unchanged |
Its values are 1, 7, 8: the 1-based contractual label. Read through |
model_point_table.csv carries no time-like column at all — every point is new business
issued at the opening of month 0 — and mort_table.csv (age × sex) and
guar_min_rate_table.csv (term_years) carry none either.
Every model point projects to completion, and a test asserts it. Between them they exercise all four crediting types, all four interim-value families, both amortization conventions, the uncapped branch, the participation-rate branch, the NGE cap-solve, both death benefit bands and both readings of the withdrawal divergence — so no branch of the notes’ parameter set is dead code.
Naming#
Cells follow lifelib’s basiclife/BasicTerm_S and savings/CashValue_SE, and this
library’s 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, plus model_point, age_at_entry, sex, policy_term, proj_len,
duration_mth, duration, age, net_cf, result_cf, check_av_roll_fwd.
Four library-wide conventions are worth stating because each of them settles a name this model once spelled its own way:
pols_if(t)is the count at the start of montht, and is the weight applied to that same row’s cash flows — sopremiums(t) / premium_pp(),withdrawals(t) / wd_payment_pp(t)andexpenses(t)over the per-contract maintenance charge all return thepols_ifcolumn ofresult_cf().pols_if(0) = pols_if_init(), exactly as inTerm_US_S. The count at the month end, the notes’l(t+1), is not lost: it ispols_if_at(t, "AFT_DECR"), the last point of the decrement chain, and it is whatresult_pols()prints aspols_if_aft_decr.lapse_rate(t)is annual andlapse_rate_mth(t)is monthly, matching themort_rate/mort_rate_mthpair. The decrement chain reads the monthly one; the notes’w_annual(y,t)islapse_rateand theirw_m(t)islapse_rate_mth.lapse_rate_sc_mult(t)is the charge-expiry shock multiplier — the name the four universal-life models use for the notes’M_sc— andlapse_shock_multis the scalar Reference carrying its std size of 3.0.Every
check_*takes no argument and returns a bool over all projectedt, asCashValue_SE.check_av_roll_fwd()does, so one test can call the same check across the library. The signed per-month residual is still there undercheck_*_resid(t), which is what a failing check needs.
The technical notes use compact actuarial symbols instead; the full mapping lives in the
Projection Space docstring. Six cases needed care, and every one is a collision the notes
themselves carry — this product speaks three vocabularies at once (contract mechanics,
Black-Scholes, and the deferred annuity chassis):
Notes |
Cells |
Why |
|---|---|---|
|
|
|
|
|
The single purchase payment and the Black-Scholes put share a letter |
|
|
The call, the [S2] fixed-leg symbol for the term-start Market Value Rate, and the chassis’s surrender charge |
|
|
The amortized option budget and the buffer differ only by case |
|
|
Index performance and the Market Value Rate differ only by case |
|
|
On the chassis |
One shared name takes a different argument from the chassis, deliberately.
MYGA_US_S keys free_wd_base(y) and free_wd_allow(y) on the contract
year; here they take the policy month, because the free-withdrawal base is
snapshotted from the Account Value at the most recent anniversary before that month’s
transaction — av_pp_at(12 * duration_eom(t) − 1, "BEF_WD"), the anniversary at time
12 cy closing month 12 cy − 1 — and keying on t keeps one index type through the
whole file. The concept and the name are the same; only the argument
differs, and the Projection docstring’s symbol table shows it.
What this model does not inherit from the chassis#
Three chassis mechanics are deliberately absent, and their absence is the modelling statement:
No contract market value adjustment. The only MVA-like term in the prospectus is the
[(1+C)/(1+D)]^Efactor inside the interim value [S2], which is why it ismva_factor— a factor, in the interim value — and there is nomva_pp, nomva_rateand nomva_cap_ruleanywhere in this model. AG 54’s project history explains why carriers diverge here: specific MVA requirements were deliberately removed from the guideline because consensus was unreachable R2.No Model #805 minimum guaranteed surrender value. AG 54 displaces Model #805 outright when it is satisfied R2 REG-R42 REG-R44, and the nonforfeiture value is the interim value. There is no
mgsv_pp. (Were #805 to apply, note that the indexed nonforfeiture rate is floored at 15 basis points, not 1% REG-R42 — the same trap the chassis documents.)No book value. Between term start and term end the contract has no account value in the ordinary sense:
av_pp(t)is a derivative price, and it is depressed exactly when the return-of-premium guarantee is in the money.
The chassis mechanics that are inherited — the composition of a surrender benefit, the
free-withdrawal base and its non-cumulative annual reset, the withdrawal-charge schedule
keyed on complete contract years, the decrement ordering and the pols_maturity
bookkeeping — keep the chassis’s names.
tau = 0 belongs to the expiring option#
The single most load-bearing convention in the file. At a Term End Date two options exist
in the same month: the one that is closing and the one that is opening. term_elapsed_mth,
tau, index_at_term_start, mvr_at_term_start and every option leg refer to the
expiring one, so tau(71) = 0 rather than 6: the Term End Date is time 72, which closes month 71.
That is what makes the notes’ verification identity fall out of the algebra instead of
having to be imposed. At tau = 0 the Black-Scholes functions return intrinsic value, the
amortized budget is zero, the trading cost is switched off and the MVA factor is 1, so
Pi(I, 0) = max(R,0) − max(R−c,0) − max(−b−R,0) = g and V = IA(1 + g)
for the Cap design, and the corresponding identity holds for Step, Edge and Floor. A test
asserts check_term_end_identity() and, term end by term end,
check_term_end_identity_resid(t) == 0 for every crediting type. The
same convention makes the Transfer Period rule automatic: iv_ratio is exactly 1 there, so
the interim value equals the Investment Amount [S2] with no special case.
Homogeneity is imposed in one place, budget_amort_factor: B is defined against the
current IA, so every term of the interim value scales with the notional. That is why a
withdrawal reduces the interim value by exactly the cash removed — a test checks each
component scales by one factor and that V falls by precisely $8,000.
The worked example and the base withdrawal rule contradict each other#
The notes give a base behavioural withdrawal rule — “0% in contract year 1; thereafter 2% of Account Value per year, taken at contract anniversaries and capped at the Free Withdrawal Amount” — and a worked example whose Investment Amount is exactly $100,000 at the term midpoint, with the illustrative $8,000 as the only withdrawal. Both cannot hold: 2% taken at the first two anniversaries — the ends of months 11 and 23 — would leave less than $100,000 of notional at the term midpoint, and the whole worked example is built on that figure.
Rather than pick one, the model ships both, the way Term_US_S ships its M(1)
divergence. wd_rate_ann is a model point column, not a Reference: points 1 and 2 set
it to 0 and reproduce the worked example to the cent; point 3 is otherwise identical to
point 1 and runs the behavioural rule at 2%. A test pins the gap open in both directions.
Neither is “correct” — the worked example is an illustration of the interim-value
mechanics and the 2% rule is a std behavioural assumption.
The [S2] withdrawal-charge example needs a model point of its own#
The notes carry a second explicitly labelled verification beside the interim-value table:
“$100,000 payment, $80,000 Account Value at the start of contract year 6, full withdrawal
→ FW = $8,000, chargeable $72,000, wc(5) = 3%, charge $2,160, cash value
$77,840.” It is not reproducible on the anchor cell, whose Account Value at the fifth
anniversary is whatever Black-Scholes makes it, and re-deriving AV − wc × (AV − FW) from the model’s own
cells asserts nothing: that composition is surr_value_pp.
So model point 15 exists to put an Account Value of exactly $80,000 on the table at
time 60, the fifth anniversary — which is the end of month t = 59. It runs the
pre-AG 54 engine — the only interim-value family whose value is a closed-form function of
the index level — on a scenario whose index sits at 71.66666667 from month end 60, the
level at which the accrued crediting rate is exactly
min(0, −28.3333% + 10% × 5/6) = −20%. Everything else then falls out of the contract:
the free amount is 10% of the anniversary Account Value, so $8,000;
wc(duration_eom(59)) = wc(5) = 3%; the charge is $2,160 and the cash surrender value
$77,840, to a rounding error of three millionths of a dollar. The crediting engine is a device here and nothing
more — the example is about the charge, which is engine-independent.
The notes print the MVA factor as 0.971690#
A display slip worth recording, because a reader checking the arithmetic by hand will hit
it. The notes state the interest-rate adjustment as (1.04/1.05)^3 = 0.971690. The actual
value is 0.9716998, and every dollar figure in the table requires it: 94,968.40 × 0.9716998 = 92,280.78, while 94,968.40 × 0.971690 = 92,279.85, nearly a dollar adrift.
The model computes the factor and reproduces the dollars; the test asserts
mva_factor(35) == (1.04/1.05)**3 and pins 0.9716998 with a comment saying why.
The Issue Date is not a row: it opens month 0#
The notes’ cash flow ledger puts the single premium and the acquisition expense at the
Issue Date, which is an instant, not a projected month: nothing accrues there, no
decrement is taken and no maintenance expense is incurred. So it is not given a row of
its own. The Issue Date opens month 0, and month 0’s row carries those two flows
alongside its own maintenance expense, market movement and decrements. The opening state
lives in the timing cells that need it — inv_amt_basis_pp(0) is the purchase payment,
rop_pp and inv_income_pp open on it, and pols_if(0) = pols_if_init().
result_cf() therefore runs t = 0 … proj_len() − 1, exactly proj_len() rows, and on
the anchor cell net_cf(0) = +93,592.53: the $100,000 premium, less expenses(0) of
$6,205.00 — the $6,200 acquisition charge plus month 0’s own $5.00 of maintenance expense
— less the $45.95 of death benefits and $156.52 of surrender benefits that month’s
decrements trigger. The Issue Date figures are still recoverable one line at a time —
premiums(0) is the premium and the acquisition part of expenses(0) is
0.06 × premium + 200 — but they no longer occupy a row by themselves.
There is no commissions cells#
The notes’ cash flow ledger has one acquisition line — 0.06 × premium + 200 std —
and no separate commission. Distribution cost sits inside that 6%, which is a plausible
RILA commission level, but the notes call it an acquisition expense and this model does not
invent a line item the specification does not carry. expenses(t) holds both the
acquisition charge, which falls at the opening of month 0, and the inflating $60-a-year
maintenance expense, which month 0 carries like any other month.
premium_taxes(t) is present, because the notes do quantify premium tax — at 0%
[S2] — and the parameter is exposed rather than removed.
pols_maturity and the Maturity Date#
Unlike the deferred annuity chassis, whose horizon the notes leave open, this product’s
horizon is contractual: at the Maturity Date the contract force-annuitizes at the Account
Value [S2]. pols_maturity(t) is zero in every month but the last and 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) opens month t and pols_if(t+1)
opens the next, which past the Maturity Date is zero. The last month is
t = proj_len() − 1 and the Maturity Date is its end. Without it the block would appear to lose
lives with no cause. The name follows BasicTerm_S.pols_maturity and the construction
follows Term_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, and with
no refund forms at all [S2].
The 80 / 15 / 5 roll split is a split of value#
The notes’ term-end roll behaviour — 80% renew into the same option, 5% into a different
index-linked option (modelled as renewal at the same parameters) and 15% transfer to the
Fixed Account — is stated as a split of contracts, applied after the phi surrender. On
a probability-weighted single-contract model point it is equivalently a split of value
between the buckets, which is how roll_share implements it: the option keeps 85% of the
credited Investment Amount and the Fixed Account receives 15%, per contract, independently
of the surrender fraction. inv_amt_pp_at(t, "BEF_ROLL") is the credited amount before the
split — that is the column the worked example prints — and "BEF_WD" is after it.
The Holding Account is carried with a zero share: it receives maturing amounts only when
the option and the Fixed Account are both unavailable [S2], which never happens on the
base run, but AV = Σ V_k + FA + HA is then complete rather than approximate.
What is not implemented#
Named here so the gaps cannot be mistaken for oversights; the model docstring carries the same list.
Performance Lock. The notes give the election rule (
V/IA ≥ 1 + θ, θ = 0.15, once per term) and the dollar-for-dollar withdrawal consequence, but not the rate at which a locked bucket accrues to term end. The module is incomplete in the specification, so it is left out rather than guessed.Tiered participation rate. The notes give its replicating portfolio but no crediting formula
g.Dual-direction and absolute-return segments, annual-lock segments, rainbow segments, resetting locks, optional GMDB riders and living-benefit riders. Listed out of scope by the product spec, with no formula.
RMD-driven withdrawals. Named as a behavioural input; no amount formula.
Multi-option contracts. The notes define a model point as one contract holding one option, with the multi-option case a vector sharing one contract-level decrement and guarantee base. This model carries the single-option case; the pro-rata-to-interim-value allocation rule is implemented over the option, the Fixed Account and the Holding Account rather than over several options.
The payout phase. The Maturity Date benefit is emitted as an outgo at the Account Value; converting it to a payment stream is the immediate-annuity chassis.
A volatility surface. The notes call the flat-surface approximation the largest single simplification in the model, and this implementation makes it. Supplying a surface is a change to
market_scenario.csvplus a strike-and-maturity lookup, not to any formula.Stochastic scenarios. The index path, the CMT curve, the volatility and the dividend yield are read from a deterministic scenario table. The Academy’s regression grid R6 and the two published carrier interim-value grids [S3] [S6] would be run by supplying more scenarios.
The contractual minimum-account-value rule. A request that would leave less than $2,000 is treated as a full withdrawal [S1] [S2];
wd_ppmerely caps the request at the Account Value std.check_margin(). On this chassis the cap is the fee — no charge is deducted from index-linked value, and the margin appears as the spread between the earned rate and the option budget implied by the declared cap. There is no charge-versus-cost decomposition to check.check_av_roll_fwd()andcheck_pols_roll_fwd()are implemented and both close to floating point, as doescheck_term_end_identity(); each takes no argument and returns a bool over every projected month, withcheck_av_roll_fwd_resid(t),check_pols_roll_fwd_resid(t)andcheck_term_end_identity_resid(t)giving the signed residual when one fails.
Standardizations used#
Everything in this list is std: the monthly grid and the within-month processing
order; the decrement order death → surrender → term-end concentration; the declared Cap /
Step / Edge rates of 100% / 8% / 6% and the 3.00% Fixed Account rate (no current rate
sheet was retrievable); the whole market-data block — 4.00% risk-free, 2.00% dividend
yield, 20.00% flat implied volatility, a flat 4.00% Market Value Rate rising 100 bp at the
term midpoint; the 0.10% trading cost factor and its assessment on the sum of absolute
leg values; both free parameters of interim-value family (b) — the investment-grade
discount spread iv_credit_spread at 1.00%, which no source quantifies ([S4] says only
that the rate is above swap rates), and the Cap Calculation Factor’s expense rate
iv_expense_rate at 0.10% of notional, which [S4] gives only as a dollar illustration;
the choice of interim-value family (a) and straight-line amortization; the
1.7834% / 2.22% NGE spread and the cap-solve target; the 80/15/5 roll split; the base
surrender shape 2% / 2% / 6%, the 3.0 charge-expiry shock multiplier, the M_iv moneyness
suppression, the 50% annual surrender cap and the 10% / 3% term-end concentration phi;
the 2% behavioural withdrawal rule; the 100% mortality A/E and the illustrative mortality
table; the 6% + $200 acquisition expense, the $60-a-year maintenance expense inflating at
2.5%, and the 0% premium tax; the ANB reading of the Maturity Date rule; and the extension
of the pro-rata-to-interim-value withdrawal allocation to the general-account buckets.
The std 100% mortality A/E deserves a specific warning, because the notes give one: it is a placeholder, not a measurement. The payout chassis calibrates the same experience study to 108.4% of 2012 IAM Basic, but that factor is payout-annuitant-select and is deliberately not imported here; public deferred-period annuitant mortality is thin REG-R65 unverified. And do not run best-estimate mortality off the 2012 IAM Period table: it carries the valuation margin built in at construction REG-R60.
Tests#
tests/test_registered_index_linked_annuity_us.py asserts every cell of the notes’
six-row, fourteen-column worked example table, on both scenarios and on both sides of the
$8,000 withdrawal; the trace beneath it (beta = 10.0632%, the $10,063.19 budget, the
$89,936.81 opening fixed leg, the 1.7834% accretion yield and 2.22% implied spread, the
$5,031.60 midterm amortization, the $2,687.62 cost of the rate rise, the $119,171.01 and
$87,490.73 counterfactuals, the 9.4336% withdrawal ratio and the $1,433.62 excess of
notional lost over cash received); AG 54’s term-start boundary F + D = 100,000.00
exactly; and the term-end identity V = IA(1 + g) for Cap, Step, Edge, Floor,
participation-rate and uncapped designs alike.
The notes’ second labelled verification, the [S2] withdrawal-charge example, is
asserted on model point 15 end to end: $80,000 Account Value at the fifth anniversary —
the end of month 59 — $8,000 free, $72,000 chargeable, wc(5) = 3%, a $2,160 charge
and a $77,840 cash surrender value, every figure read out of the model’s own cells.
Beyond the goldens there is one test per entry in the notes’ “Known modeling pitfalls” list — price return versus total return, the cap applying to the whole-term return, homogeneity, the term-end identity, the discount-rate reference, the amortization convention, the un-floored interim value, the un-smoothed Step and Edge discontinuities, era mixing (the pre-AG 54 engine reproducing its own $52,500 worked example), and the trading cost as a free parameter on a stated base — plus both roll-forwards at every month, the result-table shapes, the two death benefit bands, the withdrawal-charge schedule and its non-gross-up, the Transfer Period, the roll split, and the fact that every model point projects.
Two tests pin the start-of-period in-force convention rather than the arithmetic:
pols_if(0) == pols_if_init() with pols_if_at(t, "AFT_DECR") == pols_if(t+1), and the
reconciliation the convention buys — premiums(t) / premium_pp(),
withdrawals(t) / wd_payment_pp(t) and expenses(t) over the per-contract maintenance
charge all returning the pols_if column of the row they sit on.
Three tests guard the documentation rather than the arithmetic, because on this product
the documentation is half the deliverable: that the two readings of the contract year land
where they should (twelve months at each attained age, the expense step after the
anniversary, the charge schedule on duration_eom(t)); that every timing literal the
Projection docstring prints is one the cells actually accepts, and that its symbol table
names only cells that exist and covers every notes symbol the model implements; and that
the two invented family (b) parameters are marked std in the cells docstring, the
model docstring and this README’s list above.
python -m pytest tests/test_registered_index_linked_annuity_us.py -q
Verifying this copy#
tests/test_registered_index_linked_annuity_us.py asserts this model against the worked example in
technical-notes.md, and it ships inside this library — so it runs
against the copy you are holding, including any changes you have made to it:
python -m pytest tests/test_registered_index_linked_annuity_us.py -q
The whole suite, all twelve models and the shared conventions, is python -m pytest tests -q.
If you change an assumption and a test goes red, the worked example in the notes and the
model have parted company — which is the question this library exists to let you ask.