Implementation Notes#
Status: Draft, 2026-08-20. Built from
products/fx_whole_life/technical-notes.md; the product it
implements is specified in product-spec.md. Both state deltas
against the savings chassis, 終身保険: its
product specification and its
technical notes carry the inherited mechanics, and its
model is WholeLife_JP_A.
This is a mechanics demonstration, not a pricing or reserving result. The contractual mechanics are sourced — the surrender-value formula and the base the surrender charge (kaiyaku kōjo, 解約控除) is applied to, the symmetry of the market value adjustment, MVA (shijō kakaku chōsei, 市場価格調整), the 増加死亡保険金額 (zōka shibō hokenkin-gaku, death-benefit uplift) defined against a 予定利率 (yotei riritsu, assumed interest rate) basis, the reduced surrender-value ratio (tei-kaiyaku-henreikin wariai, 低解約返戻金割合) ramp, the ±50銭 conversion spread and the one-year dead zone on the target test. The charge stack is not: every carrier in the source set refuses to quantify its mortality-and-expense charge in identical words [S2] [S7], so the three charge rates are back-solved from one carrier’s published guaranteed surrender-value run [S2] and carry the whole surrender-benefit stream. The mortality table is a std construction anchored to individual published rates, not a copy of a table whose publisher restricts redistribution REG-R21. Replace all of it with company data before drawing any conclusion from the numbers.
Run it#
python products/fx_whole_life/run.py # the anchor cell
python products/fx_whole_life/run.py 3 # the single-premium MVA cell
Three lines to the same thing:
import modelx as mx
model = mx.read_model("products/fx_whole_life/FXWholeLife_JP_S")
model.Projection[1].result_cf()
Projection takes a point_id; Projection[1] is the worked-example anchor cell.
result_cf() returns a DataFrame indexed by policy month t with one column per cash
flow line. result_pols() gives the decrement columns and result_av() the per-policy
account-value and surrender layers.
The model and its Projection Space both carry docstrings — model.doc describes the
product and the projection basis, and model.Projection.doc holds the full mapping
between the technical notes’ symbols and the cells names.
The policy currency is the model currency#
Every state variable, every assumption and every cash-flow column is in US dollars.
Yen enters only through fx_rate(t) and fx_spread(), and the exchange rate is a model
point column — an exchange rate buried in a recursion is an economic assumption
disguised as a product feature, and cannot be varied by the point that owns it.
The yen ledger is three translations, not one: premiums cross at e + s under the
yen-payment-in rider (en nyūkin tokuyaku, 円入金特約), benefits at e − s under the
yen-payment-out rider (en shiharai tokuyaku, 円支払特約), and expenses and commission
at the plain e, because they are the insurer’s own costs and never cross the
policyholder boundary. So
net_cf_jpy(t) != net_cf(t) x fx_rate(t)
identically. The gap is the insurer’s currency conversion fee (kawase tesūryō, 為替手数料)
spread income, and the model publishes it as its own column fx_spread_jpy rather than
letting it hide inside a translated net figure: ¥125.17 in month 0 on the anchor cell and
¥39,146 over the whole run.
check_fx_ledger() asserts net_cf_jpy = net_cf x fx_rate + fx_spread_jpy in every
month, on every model point.
Model point 8 settles in US dollars throughout — both yen riders off — and its
fx_spread_jpy column is zero while net_cf_jpy reduces to a single-rate translation.
That is the parameter position the difference is measured against.
Two shapes on one chassis#
shape is a model point column and it changes more than rates. The state variable both
shapes share is the account value (tsumitatekin, 積立金), credited at the declared
crediting rate (tsumitate riritsu, 積立利率) and floored at the contract’s own assumed
interest rate (yotei riritsu, 予定利率); the payable value is the surrender value
(kaiyaku-henreikin, 解約返戻金).
|
|
|
|---|---|---|
Premium |
level, guaranteed, monthly |
one 一時払保険料 at 契約日 |
積立利率 |
redeclared monthly, floored at the contract’s own 予定利率 |
fixed for a 15-year 積立利率適用期間 |
Death benefit |
|
|
市場価格調整 |
none |
on surrender inside the rate period |
In-force charges |
維持費 and 保障部分 deducted from the 積立金 |
none — they sit inside the declared rate [S10] |
自動振替貸付 |
present |
structurally absent — there is no premium to advance |
Target rider |
not offered |
optional |
The automatic premium loan (jidō furikae kashitsuke, 自動振替貸付) row and the target-rider
row are the point. The two shapes have different decrement sets, not merely different
rates, and the model rejects a SINGLE point carrying apl_on or a LEVEL point carrying
target_on or dyn_lapse by name rather than silently pricing a contract that does not
exist.
The “no in-force charge on SINGLE” row is not an assumption but a fit: reproducing that
shape’s published CV(36) run requires the fund to grow at the declared 積立利率 exactly,
which pins maint_rate and coi_charged to zero for it. Both live in
charge_table.csv with that reasoning in their provenance column.
Inputs are external files#
Five CSVs in products/fx_whole_life/, beside run.py, read at run time. The model
folder holds __init__.py and _system.json per Space and nothing else — no _data/,
no IOSpec, no embedded values — so a diff of the model shows logic changes only. This is
the annuallife/TradLife_A layout, not basiclife/BasicTerm_S’s embedded inputs. The
trade-off is real and worth stating: the model is not portable on its own. Copying
the FXWholeLife_JP_S folder without its parent’s CSVs produces a model that reads and
then fails on first evaluation.
File |
Contents |
Provenance |
|---|---|---|
|
8 model points, indexed by |
point 1 is the notes’ anchor cell, premium sourced [S2]; the rest std |
|
annual |
the library’s canonical std proxy, anchored row by row to published rates REG-R18; see below |
|
annual surrender rate by shape and policy year |
SINGLE calibrated to a published four-year exit statistic R5; LEVEL std with no public anchor |
|
the shape’s charge, surrender-charge, 低解約返戻金, MVA and 特別積立金 parameters |
charge stack back-solved [S2]; 解約控除 and 低解約返戻金 scales sourced [S3] [S2]; MVA constants fitted to the published table [S3] |
|
an optional TTM path by policy year |
month 1 is the published reference level [S11]; the rest std illustrative, read only where a point sets |
Every row of every assumption table carries a provenance column saying whether the
value is sourced or std, and which. It is the only place in the library outside
sources.md and _research/ where a source may be named at all, and it is not
rendered.
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 policy. They live in the unparameterized Data Space instead, where they are
evaluated once per model however many policies are projected, and Projection reaches
them through its data Reference. Data.input_dir() resolves to _model.path.parent
at run time, so the model works from any checkout location.
The mortality table is a construction, not a copy#
生保標準生命表2018(死亡保険用)is published free at a stable public URL by 日本アクチュアリー会 and
anyone can go and read a rate in it — the sharpest contrast in this library with
uklib, whose current CMI tables cannot be read at all without a subscription. But the
publisher’s site terms prohibit reproduction and transmission to third parties
REG-R21, so jplib must not ship a copy.
What it ships instead is the library’s one canonical std proxy, built once for all
nine products and restricted here to the attained ages this product’s model points can
reach. Every rate the nine products quote from the published table is an anchor row,
read from the IAJ table and carried under attribution — for the male life
q30 = 0.00068, q35 = 0.00077, q40 = 0.00118, q45 = 0.00177, q50 = 0.00285,
q55 = 0.00422, q60 = 0.00653, q65 = 0.01015, q90 = 0.15760 and the terminal
q109 = 1.00000, for the female life q30 = 0.00037, q60 = 0.00363 and the terminal
q113 = 1.00000 among them REG-R18 — and every other age is interpolated,
log-linear in ln q between its two neighbouring anchors and rounded to the published
table’s own five decimal places. There is no extrapolation anywhere: both sexes run from
an age-0 anchor to a terminal anchor. Each row’s provenance column says which of the
two it is, so the same attained age carries the same rate and the same provenance in
every product in the library. It is not the published table and no conclusion about
Japanese insured-lives mortality should be drawn from it.
omega_age() is read off the table — the first age at which q = 1 — rather than
hard-coded, so replacing the table moves the projection horizon with it. One consequence
is visible in run.py’s last rows: q = 1 at the terminal age is an annual rate, so
q_m = 1 and the whole terminal policy year is spent in its first month. The remaining
eleven rows are structurally empty and are carried rather than trimmed, because the
horizon is stated as 12 (ω − x + 1) months; the zeros are an emptied cohort, not lost
lives, and the roll-forward closes a year earlier. The table starts
at attained age 18, which is narrower than the 契約年齢 envelope the representative
product allows: a model point issued younger raises on the lookup rather than being
priced off a rate nobody published. That is a scope limit of the shipped file, not of the
model, and it is asserted as one in the test module.
The table is also a valuation table carrying an explicit margin sized to about 2σ
REG-R20, not best-estimate experience. The adjustment for that is mort_be_factor,
carried on the model point in a column named mort_adj, and it moves the
decrement only: coi_rate(t) reads the same table
unadjusted, because the cost-of-insurance basis is a pricing element the insurer sets in
its 算出方法書 REG-R2. Wiring one lever to both would make the model absorb its own
mortality sensitivity inside the account value. Model point 7 runs
mort_be_factor = 1.20 and the two rates part company there.
The charge stack is a fit, and the fit is the evidence#
prem_charge_early (38% of each premium over policy months 0–23), prem_charge_late
(13% thereafter) and maint_rate (0.50% p.a. of the 積立金) are three std
parameters back-solved from nine published dollar figures spanning forty-seven years
[S2]. They reproduce that whole run to within 1.75% at every duration and to
0.03% at duration 50, and the same three parameters reproduce the other published
table — the 低解約返戻金特則 form, a different premium and a different contract — to within
5.20% out to duration 20. A back-solve is re-solved whenever its inputs move: adopting
the library’s canonical mortality proxy changed the cost-of-insurance basis at every
attained age above 40, and prem_charge_late re-solved from 12% to 13% with the other
two unmoved. The model reproduces the notes’ own figures for both fits cell
for cell, and the test module carries both tables hard-coded — the model’s values and the
published ones side by side, so that the deviation is asserted rather than described.
Two independent consequences fall out of the fit rather than being imposed, and both are checks on it. Under the prospective definition of the uplift benchmark the fund crosses its 予定利率 benchmark by +US$8.73 at 払込満了 — a contract that is almost exactly self-funding at the guaranteed floor, which is what actuarial equivalence predicts. And the account value overtakes the sum assured at month 740, attained age 101, where the net amount at risk goes to zero and the cost-of-insurance charge stops.
One caveat the fit does not cover, and it shows in a shipped model point. The
reduced-surrender-value special condition (tei-kaiyaku-henreikin tokusoku, 低解約返戻金特則)
form’s lower premium of US$225.00 is not self-funding to the terminal age at this
charge stack: on model point 2 the 積立金 is exhausted in the sixth decade and the
guaranteed 終身 cover is thereafter carried by the insurer rather than by the fund, with a
nil surrender value. charge_coi() is capped at what the fund holds so that the 積立金 can
never run negative — it is an account, not a debt — and without that cap the shortfall
compounds into the net amount at risk and the projection diverges. The notes already flag
that published table’s durations 30, 40 and 50 as [unverified], and this is the same fact
seen from the model side.
Two mechanics that vanish at the guaranteed floor#
The base run credits at ic = i0, the contract’s own 予定利率 — the guaranteed column of
the only published surrender-value run, and the only crediting figure for the LEVEL
shape that is a contract term rather than an illustration [S2].
av0_pp(t), the uplift’s benchmark, is defined std as the account value the same
recursion produces with ic replaced by i0 and the benchmark benefit held at SA. It
follows that whenever ic = i0 the two funds coincide exactly, so idb_pp(t) is
identically zero and special_reserve_pp(t) with it. The published guaranteed column
shows 特別積立金 of (0) at both 10 and 20 years [S2], so that is what a correct
implementation must produce: a non-zero uplift or top-up on the base run is a bug, not
a refinement, and both are tests.
The special reserve top-up (tokubetsu tsumitatekin, 特別積立金) itself is not merely set to zero; it is a fitted mechanic that happens to be zero here. It is a share of the fund’s excess over its benchmark, 0.24 at ten years and 0.16 at twenty, fitted to the four published amounts — 147 and 527 on the 3.50% column, 302 and 1,120 on the 4.00% column — with a worst deviation of 3.7%. The fit is asserted in the test module against the anchor cell run at each of the two illustration rates, so the two shares are checked against the document rather than merely carried. Model point 5 credits 3.50% and both mechanics come alive there, on its own smaller cell.
Modules that are off in the base run#
Seven of the notes’ optional constructions are implemented and switched off on the anchor cell, so that the base run reproduces the worked example while the machinery stays visible and testable. Every one of them is asserted in both positions.
Module |
Switch |
Off value |
Exercised on |
What it does |
|---|---|---|---|---|
The prospective uplift basis |
|
|
point 7 |
Measures the uplift against |
The uplift ratchet |
|
|
point 7 (off) |
Holds |
The 低解約返戻金特則 |
|
|
points 2, 6 |
Suppresses the surrender value to |
The 自動振替貸付 |
|
|
point 6 |
Redirects |
Dynamic surrender on the FX rate |
|
|
point 4 |
Multiplies the annual surrender rate by |
An FX path |
|
|
point 7 |
Reads |
The target-value rider |
|
|
points 3 (convert), 4 (surrender) |
Converts or surrenders the contract at |
Two further levers are model point columns rather than modules, and both ship at their
neutral value. mort_be_factor is 1.00 on every point but 7, where it is 1.20 — at 1.00
the
base run is a valuation-table run, not a best estimate REG-R20. mva_delta is 0.0
everywhere but point 4, where it is −1.0% and the adjustment turns negative.
The two levers interact with the horizon in a way worth naming: because mort_be_factor
moves
the decrement, point 7’s cohort is exhausted at attained age 108 — where 1.20 × 0.90733
is capped at 1 — a year before the table’s terminal age, while coi_rate still reads
0.90733 there. Nothing is truncated; the load simply reaches 1 first.
The target-value rider, and what a deterministic run cannot say#
target_month() is the first month at or after the contractual one-year dead zone at
which the yen-converted surrender value reaches the 目標額. Two things about that test
are easy to get wrong and both are contractual [S9]: it runs on cv_pp, after FX and
after the MVA, not on av_pp; and the dead zone is real. On the base SINGLE cell the
model converts at month 52; testing the account value instead converts at month 39,
thirteen months early, and the two counterfactuals that drop one deduction at a time land
at month 41 (no 解約控除) and month 50 (no MVA). All four are in the test module.
target_action has no default. The 約款 converts the contract to a yen whole life
[S8] [S9]; at every focus-monitored distributor most ターゲット型 policies are instead
surrendered on the hit and the same product immediately re-sold to the same customer,
paying the front-loaded commission twice R5 R6. The contract and the evidence
disagree and neither is the modeller’s to assume silently, so the model point must
choose. Under "surrender" the value leaves through claims(t, "LAPSE"); under
"convert" it leaves through conversions(t), and the yen contract it becomes is out
of scope — this ledger is denominated in dollars and the converted liability is not.
That scope boundary is std, and it is why the two elections differ in where the
money is booked rather than in how much of it there is. Model point 3 converts, model
point 4 surrenders.
Only point 3 is the notes’ cell. Point 4 carries the negative 市場価格調整 and dynamic surrender as well, so its trigger lands at month 33 rather than 52 — the deliberate variant, not a discrepancy, and the test module pins both months so a reader does not read point 4 against the notes’ anchor.
What a deterministic run cannot do is value the option. On one path the rider either
converts at one determinate month or never converts, so its time value is zero by
construction — the same degeneracy uklib’s RPI ratchet suffers under a monotone index
path. A scenario set is the only instrument that can price it and this library does not
ship one.
The account-value charges are not cash flows#
charge_init, charge_maint and charge_coi are internal transfers from the 積立金 to
the insurer. They move av_pp and appear nowhere in net_cf; the insurer’s outgo is
the expense and commission stream instead. Booking a charge as revenue alongside the
premium that funded it double-counts the premium, and check_net_cf() rebuilds net_cf
from the premium decomposed into the part that reaches the fund plus the charge, so it
would catch exactly that.
The order inside the month is fixed, because it changes the answer at the third decimal
and the published run is reproduced to the dollar: premium in, then charge_init, then
charge_maint, then charge_coi on the net amount at risk measured after the
maintenance charge, then interest at (1 + ic)^(1/12) — the geometric twelfth root
std, not ic/12.
Sign convention#
The notes’ CF(t) is already income positive — premiums less claims, surrender
benefits, conversions, claim expense, acquisition and maintenance expense and commission —
which is the library-wide sign of
net_cf, so there is no outgo-positive liability_cf companion to publish: one stream,
one sign, one name.
Naming#
Cells names follow lifelib’s basiclife/BasicTerm_S and savings/CashValue_SE wherever
those models have an analogue: pols_* for policy counts, plural nouns for cash flows,
*_rate for annual rates with *_rate_mth for the monthly ones, *_pp for per-policy
amounts, claims(t, kind) with an uppercase kind, and av_pp_at(t, timing) /
pols_if_at(t, timing) for the within-month reads. The technical notes use compact
actuarial symbols; the full mapping lives in the Projection Space docstring. Six cases
needed care:
Notes |
Cells |
Why |
|---|---|---|
|
|
The savings chassis pays a cash surrender value and has no account value; this product has both, and they are not interchangeable — |
|
|
One symbol, two defensible definitions — the same recursion at |
|
|
One symbol for the decrement and for the cost-of-insurance basis, which are a different kind of assumption: |
|
|
Named as charges, not as cash flows, because they are internal transfers from the 積立金 and appear in no |
|
|
The yen ledger is two published quantities, not one: the three-rate translation and the spread income that separates it from |
(no symbol) |
|
A target conversion is deliberately not a |
prem_due_pp(t) is the premium due per policy in month t and premiums(t) the income
actually received, which differ wherever the 自動振替貸付 is advancing: the fund is credited,
the insurer is paid nothing. It is spelled prem_due_pp rather than premium_pp because
premium_pp is the library’s name for an annual per-policy premium, and this one is a
time-varying amount due in a month; the level monthly amount is premium_mth_pp().
pols_payer(t) and pols_if_apl(t) are the two cohorts whose sum is pols_if(t), and the
premium column is carried on the first alone.
claim_expenses(t) is its own cells and its own result_cf() column, and expenses(t) is
acquisition and maintenance only. The two are driven by different levers — the death
decrement and the in-force count — so folding one into the other would hide which of them
moved; this is the settled library meaning of expenses.
The in-force roll-forward check is check_pols_roll_fwd() / check_pols_roll_fwd_resid(t),
the settled name in both sister libraries, and the multiplier that turns the shipped
valuation table into the projection basis is mort_be_factor.
Standardizations used#
Everything quantitative that is not a contract term. The load-bearing ones:
Standardization |
Value |
Why |
|---|---|---|
Charge stack |
38% / 13% / 0.50% p.a. |
back-solved from the published guaranteed run [S2]; no carrier publishes the rates |
Monthly interest convention |
|
stated because the published run is reproduced to the dollar |
Mortality construction |
one canonical library table, log-linear in |
the published table may be cited and quoted but not redistributed REG-R21 |
|
1.00 |
the base run is a valuation-table run, so every figure is checkable |
MVA constants |
0.10% / 3.00% / 0.70 |
fitted to the published 15-year rate table [S3] |
特別積立金 rates |
0.24 / 0.16 |
fitted to four published amounts [S2] |
LEVEL surrender curve |
8 / 7 / 6 / 5 / 5 / 4 / 3% |
no public anchor of any kind exists |
SINGLE surrender curve |
28 / 23 / 18 / 14 / 8% |
calibrated to a 60.90% four-year exit R5 |
FX path |
flat at ¥159.43 |
this library models contractual cash flows, not an FX view [S11] |
Expenses and commission |
US$300 / US$60 p.a. / US$150; 90% and 3%, or 5.5% and 0.75% p.a. |
no carrier publishes an expense basis; only the upfront pattern is evidenced R5 [S13] |
|
0.30 |
no source splits the surrender decrement into voluntary and non-payment parts |
|
2.75% p.a. |
the savings chassis’s own level, taken unchanged — this product changes whether a shape has an APL, not what it costs |
The APL cohort’s loan |
one aggregate average, |
exact for the total and approximate per policy at exit; a full entry-year triangle would add a dimension the module’s evidence does not support |
|
the charge cannot exceed the fund |
the 積立金 is an account, not a debt; without the cap the 特則 point’s shortfall compounds into the net amount at risk and diverges |
|
|
not stated in the notes, but uniquely pinned by them: with |
Interpolated ages |
log-linear in |
no extrapolation anywhere: both sexes run from an age-0 anchor to a terminal anchor |
Dynamic surrender |
2.0 / 0.5 / 2.5 |
no retrieved source quantifies FX-driven surrender behaviour at all |
Premiums on points 5, 6 and 7 |
US$120.00 / US$560.00 / US$180.00 |
only the anchor cell’s US$239.60 and the 特則 form’s US$225.00 are published; these are chosen so the fund stays positive |
Target scope boundary |
the yen contract is out of scope |
this ledger is in dollars |
Tests#
tests/test_model_conventions_jp.py asserts the house style — the layout, the
Data/Projection split, the read-once property, the docstrings, the naming, the
check_* contract and the read-write-re-read round trip — parametrized over the model
registry.
tests/test_fx_whole_life_jp.py asserts this product, in 121 tests:
The notes’ worked example, hard-coded as module-level tables —
TRACEfor the month 0, 1 and 2 traces,FIRST_PERIODSfor the printed cash-flow table,CALIBRATIONfor the nine-duration fit andMVA_ROWfor the rate-move row — carrying the digits the notes display and no more, so a reviewer can lay the module beside the document and compare by eye. The tolerance on each trace line is derived from the digits printed rather than chosen. Also the 特則 cross-check to the cent, the whole-run dollar and yen totals, the ¥125.17 month-0 and ¥39,146 whole-run spread, the whole-run yen identity summed on the FX path rather than translated once at the issue-date rate, andΣ D + Σ S = 1to nine decimals. The 特別積立金 shares get the same treatment: the anchor cell is re-run at each illustration rate and the two top-ups are asserted against the published 147 / 527 and 302 / 1,120 within the 3.7% the fit claims, so the second fitted mechanic is checked against its document rather than only described.Every entry in the notes’ Known modeling pitfalls list, each named after its pitfall; a comment block at the head of the module pairs the two so that a pitfall without a test is visible. Among them: the dollar ledger not stirring when the exchange rate is moved, the charges staying out of
net_cf,av_ppnot beingcv_pp, the MVA being symmetric rather than floored and zero on a 積立利率計算基準日, the surrender charge’s base being the 積立金, the target test running on the surrender value with all three counterfactual months, the dead zone, the uplift and the top-up being identically zero at the floor,mort_be_factornot moving the charge, the APL’s absence on the SINGLE shape, the policy month rather than the calendar month, the geometric twelfth root, the 低解約返戻金 release as a step, the fund overtaking the sum assured at month 740 with the charge floored and the benefit not, the fund being exhausted on the 低解約返戻金特則 point withcharge_coicapped at what it holds, and a refused claim still paying the fund.Each optional module in both positions, off in the base run and switched on where a model point elects it, including the two target elections booking the same money in different columns.
The identities: the five
check_*cells on all eight model points, plus the in-force roll-forward rebuilt month by month independently of the recursion, on all eight.The structural product facts: no maturity and no tail states; the horizon read off the table, 109 male and 113 female, with the terminal year emptying in its first month because
q = 1is an annual rate; 終身払 resolved against the horizon; death before surrender as the processing order; the shipped mortality table’s row-by-row provenance and its sourced anchors; and an issue age the table cannot serve raising rather than being priced silently.The scope refusals: nine model point mutations that must be rejected by name — the yen-premium shape, a non-USD currency, an unknown uplift basis or target action, an invalid sex, the APL on the SINGLE shape, the target rider or dynamic surrender on the LEVEL shape, and the 低解約返戻金特則 on a 終身払 contract.
Both halves of the external-input bargain: an input swapped by repointing a filename Reference with no formula change, and a read → write → re-read round trip that reproduces the worked example once the CSVs travel with the model.
python -m pytest tests/test_fx_whole_life_jp.py -q