Implementation Notes#
Status: Draft, 2026-09-03. Built from product-spec.md; the worked
example the model reproduces is in technical-notes.md, and every
source tag is resolved in sources.md.
This is a mechanics demonstration, not a pricing or reserving result. The contractual mechanics are sourced — the level whole-of-life 사망보험금 with no 만기보험금, the identity 해약환급금 = 계약자적립액 − 해약공제액, the 표준해약공제액 formula of 별표 14 and the seven-year 해약공제기간 cap, the fact that the suppression multiplies a 표준형 comparison twin priced with the lapse assumption switched off and not sold, the equality of the suppressed and 표준형 values from 납입완료, the policy-loan rate formula 예정이율 + 1.5%, and the 50% 장해지급률 premium waiver with premiums deemed paid. Every quantitative assumption is a std standardization. The 예정이율, the 적용위험률 and the 예정사업비율 live in the filed but unpublished 산출방법서 REG-R2; the 제10회 경험생명표 is not published in full REG-R33 REG-R34; and no carrier publishes an expense rate, a commission scale or a lapse curve by duration. Replace them with company data and a real 산출방법서 before drawing any conclusion from the numbers.
Run it#
Two ways, and they are the same model. From the repository root:
python products/whole_life/run.py # the anchor cell, point_id = 1
python products/whole_life/run.py 3 # the 무해지환급형 point
python products/whole_life/run.py 8 # the 단기납 유지보너스 point
The runner prints the basis it used, the cash flow statement around 납입완료, the surrender
values on the same rows — at the month-end d = t + 1 that closes each month — the
undiscounted totals and every check_*(). Its first and
last blocks on the anchor cell, abridged to the width of this page — the runner prints two
further header fields, the claims_reduction and claim_expenses columns, more rows and the
whole of result_val():
WholeLife_KR_S - jongsin boheom (whole life), monthly grid, boheom nai
model point 1: WL-KR-0001 - M40, cover KRW 100,000,000, 20-year premium term
form: jeohaeji hwangeuphyeong (low surrender value), k = 0.50
premium = KRW 231,345.00/month (2,776,140.00 p.a.) projection = 912 months (76 years)
basis: pricing rate 2.500% accrual rate 2.500% policy loan rate 4.000%
net level premium: monthly P = KRW 179,777.06 (yeonnap equivalent 2,106,700.54 p.a.)
pyojun haeyak gongjeaek = KRW 3,106,700.54
...
pols_if premiums claims_death claims_lapse expenses commissions net_cf
t
0 1.00 231345.00 7086.09 0.00 1096972.09 2019355.35 -2892089.79
238 0.60 137678.89 23066.76 1283.47 7088.49 4130.37 102040.60
239 0.59 137614.06 23055.90 2579.03 7085.15 4128.42 100696.39
240 0.59 0.00 25203.75 20722.80 4417.45 0.00 -50419.61
...
undiscounted totals per policy issued (KRW):
pols_if 338.87
premiums 37680384.40
claims_death 50620740.89
claims_lapse 9294574.00
net_cf -30061540.20
checks:
policy count roll forward True
decrements sum to one True
account roll forward True
account prospective form True
surrender charge under cap True
suppression and the cliff True
policy loan roll forward True
acquisition cost under cap True
net cash flow ledger True
run.py and everything it prints are pure ASCII, so the output survives a Windows console
under any code page: Korean is romanized and the currency is written KRW.
Three lines to the same thing, from Python:
import modelx as mx
model = mx.read_model("products/whole_life/WholeLife_KR_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 the 0-based month index t with one column
per cash flow
line, pols_if first and net_cf last; expenses there is acquisition plus maintenance,
with the claim handling expense in its own claim_expenses column. result_pols() and
result_val() publish the decrement run and the value run beside it — the second because on
this product the surrender value is the mechanic, and printing it only inside a cash flow
hides the one number the whole specification turns on.
The time index, and the second index the values carry#
t is 0-based and counts months: t = 0 is the first policy month, month t runs from
month-end t to month-end t + 1, the frame is range(proj_len()) and the contractual
policy year is the 1-based label t // 12 + 1, derived by policy_year(t) and never indexed
by. proj_len() is the number of months — the exclusive end of the frame — and
proj_years() the number of policy years behind it, so result_cf() has proj_len() rows
indexed 0 … proj_len() − 1, 912 of them on the anchor cell, twelve to a policy year.
A 계약해당일 is a multiple of twelve: month-end d = 12y closes policy year y.
The value cells carry a second index, the month-end d = 0 … proj_len() with d = 0 at
issue: pol_val_pp, surr_chg_pp, cv_std_pp, cv_pp, cv_susp_pp, cum_prem_pp,
refund_ratio, bonus_pp, cv_mult, sa_factor and loan_pp are values at a point in
time rather than flows of a month. The flows of month t read d = t as the opening
month-end and d = t + 1 as the closing one — the date a surrender in that month is paid at —
so result_val() publishes the value columns at t + 1 and loan_pp, the balance every
benefit is settled net of, at t. The finer index is the substantive gain of the monthly
grid on this product: a surrender value that an annual grid could only place in a policy year
now has a month, and the anchor’s payable value first becomes positive at d = 15, the
third month of policy year 2, rather than somewhere inside it.
No maturity, no tail states, and a horizon that is the table’s#
proj_years() = omega_age() − age_at_entry() + 1 policy years and proj_len() is twelve
times it, t = 0 … proj_len() − 1. A 종신 contract
has no expiry and pays nothing on survival [S1] [S2] [S3] [S4] [S6] [S8], so the horizon is
not the contract’s — it is the terminal age of the shipped mortality table, ω = 115 for
both sexes. Every remaining life dies in the last policy year, pols_if(proj_len()) is
zero, and nothing is paid at the horizon but the death benefit. That last year is the one
place the monthly conversion cannot be a compounding of an annual probability: q = 1 at ω,
1 − (1 − q)^(1/12) is 1 as well, and killing the whole surviving cohort in the first month
of the year would leave eleven empty rows. mort_rate_mth(t) therefore spreads the certain
death uniformly over the twelve months as 1 / (12 − t mod 12), which is the UDD
convention and closes the table exactly. On the anchor cell the split is
0.5062074089 deaths against 0.4937925911 lapses, which is what check_decrement_sum()
asserts: every policy issued leaves by a modelled decrement and there is no residual
population anywhere.
The second structural fact is the one a term model does not have: premiums stop at 납입완료
and nothing else does. premiums(t) is zero from t = prem_period_mths(), and so is
renewal commission; maintenance expense, death claims, surrender benefits and the account all
continue for life. On the anchor cell the premium runs for 240 of 912 months and carries
₩37.68m of income against ₩59.92m of claims and ₩7.83m of expense and commission, so a
projection truncated at 납입완료 would miss most of the liability and all of its sign.
Σ net_cf over t = 0 … 239 is +₩27,631,707.90 and over t = 240 … 911 is
−₩57,693,248.10.
Ages are 보험나이 (boheom nai, insurance age) throughout: the 만 나이 at the 계약일 with
a fraction under six months discarded and six months or more rounded up, incrementing on each
계약해당일 rather than on the birthday REG-R25 제21조. age(t) is
age_at_entry() + t // 12, which steps on exactly that date and nowhere else, so the monthly
grid ages the contract correctly by construction and holds one table rate for twelve rows —
the age basis is a property of the contract, not of the step. The public statistics the
shipped table is calibrated against are on 만나이 REG-R38, no public mapping between the
two bases exists, and no conversion is applied std — so the table is read for a life
about half a year younger than the one being projected, on every row. That is a
one-directional bias and it is not corrected.
One policy value, one multiplier#
The account is the 계약자적립액, a contractual quantity and not a 책임준비금. Under K-IFRS
제1117호 the insurer books no 보험료적립금 as a separate statutory reserve REG-R60 REG-R10,
which is the visible reason a 2024 상품요약서 writes 「계약자적립액에서 미상각신계약비를
공제한 금액」 where a pre-2023 one wrote 「순보험료식 책임준비금에서 해지공제액을 공제한
금액」 for the identical identity [S2] [S8]. pol_val_pp is that account and this model
computes no reserve at all.
It is solved forward on the net level recursion the specification states:
V(0) = 0
V(d) (1 − qᵐ) = ( V(d−1) + Pᵐ·1{d ≤ 12m} ) (1 + jacc) − qᵐ·SA
on the month-end index, with qᵐ = 1 − (1 − q(x + ⌊(d−1)/12⌋))^(1/12) — the monthly
conversion of the table rate of the policy year the month d − 1 falls in — jacc the
monthly equivalent of the accrual rate, and Pᵐ fixed at issue by
Pᵐ·ä^m(x, 12m) = SA·A^m(x) on the 예정이율.
V(T) is defined as zero: at the terminal age q = 1 and the recursion degenerates.
This is the conversion’s one substantive gain. 감독규정 제7-66조제1항제4호 requires the
account to accrue monthly until 납입완료 and daily after it; its text renders as an image
in the 고시 and did not extract, so a monthly step is still a std reading of it
REG-R19 — but it is the step the rule names. The annual grid this model replaced needed
제7-65조제2항’s separate permission to carry a monthly-premium product’s account on an annual
one — 「연납보험료를 기준으로 하여 산출할 수 있다」 REG-R18 — and no longer does. The
account is 1.2% higher at the anniversaries as a result, which is not noise: it is twelve
premium instalments earning interest from the month they are paid rather than one notional
annual premium earning it from the start of the year.
check_pol_val_prosp() asserts the substantive cross-check: the forward recursion and the
closed-form prospective value SA·A^m(x+d) − Pᵐ·ä^m(x+d, 12m−d) agree at every month-end,
which they do only if the net premium, the payment period and the discount basis are all
consistent.
On the anchor the largest disagreement over 912 months is ₩0.73, on a ₩100,000,000 sum
assured — and every month-end before the terminal policy year closes to a hundredth of a won.
That last year is where the check’s tolerance is widened by exactly twelve: the recursion
divides by 1 − qᵐ twelve times over a year in which q = 1, so whatever float noise the
account carries into it is multiplied by twelve and by nothing else.
The 연납순보험료 P survives beside Pᵐ, because 별표 14 names that quantity and not this
one: the 표준해약공제액 is written 「연납순보험료 × 5% × 해약공제계수」 REG-R20, so
prem_net_level_pp() stays annual and the surrender charge is unchanged by the conversion.
The two are not twelve times each other — 12 × Pᵐ is 2.4% above P, which is the ordinary
modal loading of paying monthly in advance — and keeping both under names that say which is
which is the point of the pair.
Everything else in the model is that one value times one factor, at the same month-end d:
SC(d) = 표준해약공제액 × max(0, 1 − d / 12n_sc), n_sc = min(m, 7) years
W(d) = max(0, V(d) − SC(d)) the 표준형 twin's 해약환급금
CV(d) = k·W(d) for d < 12m; W(d) for d ≥ 12m the amount actually payable
The factor multiplies the twin, not the sold product’s own account, and that is sourced
rather than assumed: every carrier selling a suppressed form names a comparison product in the
same sentence and says it is not sold — 「”표준형”의 경우는 … 동일한 보장내용으로
해지율을 적용하지 않고 … 계산된 상품이며 … 비교안내를 위한 종목으로 실제로 판매하지
않습니다」 [S1], with the same sentence at three more carriers [S2] [S3] [S4]. So there is
one account run in this model, never two, and CV(d) is independent of the sold form’s own
premium. That single fact is the whole of the 환급률 arithmetic the product is sold on: the
suppressed form’s post-완납 value is identical to the 표준형’s while its premiums are lower,
so its refund ratio is mechanically higher. On the shipped points the anchor’s 환급률 crosses
100% at the month-end d = 280 (1.001125, from 0.999503) — four months into policy year
24 — and its 표준형 twin’s at d = 347, eleven months into policy year 29. An annual
grid could only report the policy year each crossing lands in; the monthly one reports the
month, which is the unit a 환급률 table is actually read in.
The step at 납입완료 is a step. cv_pp(240) / cv_susp_pp(240) is exactly 2.0 on the
anchor, cv_pp going from ₩25,877,906.52 at the month-end d = 239 to ₩52,023,973.59 at
d = 240 — the row of month t = 239 in result_val(). The monthly grid is what makes that
claim checkable at all: the two month-ends either side of the cliff are one month apart, so
the doubling is visibly a single-row event and not an artefact of comparing two annual
balances a year apart. Anything
between is an interpolation the contract does not have. check_cv_cliff() asserts it,
together with the equality of the suppressed and 표준형 values from 납입완료 and the fact that
the payable value never exceeds the twin’s.
That is the value test and deliberately not the 환급률 test. 감독규정
제7-66조제4항제2호나목 conditions the deepest designs on their post-완납 refund ratio
exceeding the greater of 100% and the 표준형’s REG-R19, while the FSC’s own announcement of
the same amendment frames it the other way — 「전(全) 보험기간 동안 표준형 보험의 환급률
이내로」 REG-R28. On model point 3 the 무해지 form’s post-완납 환급률 is 1.081135 against
the 표준형’s 0.843286: legal on the 고시 reading, and outside the press release’s. Both
statements are recorded in product-spec.md as they stand and neither is asserted here.
The step is not a surrender-charge effect. 감독규정 제7-66조제1항제2호 caps the
해약공제기간 at seven years REG-R19, so on the anchor’s 20년납 contract surr_chg_pp is
already zero at the month-end d = 84 — thirteen years before the cliff. The charge now
runs off in 84 straight-line steps rather than seven, on max(0, 1 − d / 12 n_sc), so its
balance is right at the month a surrender is actually taken.
check_surr_chg_cap() asserts
both bounds: the charge stays under the 표준해약공제액 of 별표 14 and is gone at n_sc.
On a 전기납 point (prem_term = 0) prem_period() is proj_years(), the suppressed
period runs for life and the step never happens. Model point 5 is written that way —
cv_mult(d) is 0.50 at every month-end — and is the one configuration in which the product’s
signature mechanic is absent by construction.
The surrender charge is bounded by a published schedule, and that is the whole defence#
No Korean insurer publishes an expense rate. Both 상품요약서 in the source set define 계약체결비용 and 계약관리비용 and then give no number [S2] [S8], and the 산출방법서 that holds the 예정사업비율 is a filed but unpublished 기초서류 REG-R2. What is public is a cap, and it has no US or UK analogue at this level of prescription — 별표 14 REG-R20:
표준해약공제액 = 연납순보험료 × 5% × 해약공제계수 + 보험가입금액 × 10/1000
For a 보장성보험 the 해약공제계수 is the 보험기간 capped at 20 and the 연납순보험료 is
recomputed on a 20년납 footing where the 보험기간 is 20 years or more, which for a 종신
contract it always is. The formula therefore collapses to one year’s net premium plus one
per cent of the sum assured, and prem_net_20yr_pp() exists solely to carry that
normalisation — a 7년납 point’s cap is computed on the same footing as a 20년납 point’s. Both
it and prem_net_level_pp() are 연납 quantities and stay annual under the monthly grid,
because that is the quantity 별표 14 names; the monthly prem_net_level_mth_pp() the account
consumes is a different number and is never substituted here. On
the anchor the cap is ₩3,106,700.54, of which ₩1,000,000 is the 보험가입금액 term. Two
cross-checks, neither used to fit it: the FSC states the same cap as 「보장성보험 월 보험료의
13배 수준」 REG-R29, and 13 × ₩257,050 = ₩3,341,650, so the model sits 7.0% below the
rule of thumb; and 별표 15 takes the 보험가입금액 entering the formula before any 체증 or 체감
REG-R21.
The model then sets acq_cost_pp(), the 계약체결비용 actually incurred, at the cap exactly
std, which makes surr_chg_pp(d) literally the unamortised balance of it and closes the
loop between the expense the insurer incurs and the deduction the policyholder bears. Two
further published bounds are asserted by check_acq_cost_cap(): 계약체결비용 within 1.4 ×
the 표준해약공제액, the tolerance under which a whole-life death-benefit 보장성보험 need not
publish a 계약체결비용지수 REG-R22 제7-45조제11항; and first-year remuneration within the
first year’s expected premium REG-R22 제4-32조제5항, which binds on the long-payment-term
points where the premium is small against a cap computed on a 20년납 footing.
Only the shape between the two ends is standardized — a straight line to n_sc — the real
run-off living in the unpublished 산출방법서. That is the honest position: the cap is sourced
and exact, the level is set at the cap, and the shape is std. The visible consequence is the
calibration against the one published 표준형 grid at the identical cell: the model sits at
0.899–0.923 of DB생명’s printed 해지환급금 across durations 3 to 20 [S4], a level offset,
which is what setting 계약체결비용 at the statutory cap should produce against a real product
that presumably charges less. The monthly account improves that fit at every sourced
duration, by the same 1.2% it raises the account — which is a check on the conversion rather
than a coincidence, because the published grid is a real contract whose own 계약자적립액
accrues monthly.
Lapse is behavioural, and the vector is the argument#
No 자동대출납입 was found in any Korean document read for this library. jplib’s whole
life chassis turns on the 自動振替貸付, which advances the premium against the surrender value
at the end of grace so that lapse there is a funded event. In Korea, on the evidence
retrieved, there is no such test: a policyholder who misses a 14-day 납입최고기간 loses the
contract whatever its cash value, and on a 무해지 form receives nothing at all [S5 제25조]
REG-R25 제26조. That absence is unverified rather than established — the 생명보험
표준약관 is understood to contain such an article and the retrieved 별표 15 extract does not
carry it — and it is the single highest-value item for a later research pass, because finding
one would change this chassis in kind rather than in degree.
So lapse is a plain decrement, and which vector it runs on is a supervisory question.
lapse_table.csv holds two bases as three parameters each, rather than a rate per policy
year, because the convergence point is 납입완료 and that is a model point attribute:
basis |
first year |
at 납입완료 |
ultimate |
provenance |
|---|---|---|---|---|
|
10.0% |
0.1% |
0.8% |
the FSS 원칙모형 REG-R27 |
|
4.0% |
4.0% |
4.0% |
the level comparison basis std |
All three are annual rates and stay annual: lapse_rate(t) returns the rate of the policy
year month t falls in, exactly as the supervisor tabulates it, and lapse_rate_mth(t)
converts it by 1 − (1 − w)^(1/12) so that twelve of them compound back to precisely the
year’s rate. Dividing by twelve would not — and on this product the difference is visible,
because the vector spans two orders of magnitude.
The loglinear basis is the log-linear model the November 2024 계리가정 decision adopts as the
원칙모형, with its practical convergence point of 0.1% at 납입완료 and its 0.8% ultimate rate
REG-R27 R3. The 10% start is the top of the 연 1%~10% 적용해지율 envelope one carrier
discloses in its 상품요약서 [S2] and sits inside the 연 0%~13.4% one at another [S8]. The
shape between the two ends is std; no Korean lapse curve by duration is public, and the two
disclosed bases — a pricing 적용해지율 and a valuation assumption — serve different
purposes and cannot be reconciled from public data. This is the largest single assumption gap
on this product.
The two bases are shipped side by side because that comparison is the disclosure the guidance
obliges, not an afterthought. It is also what makes the cliff legible. The cliff moves less
cash than a reader expects on the 원칙모형, and that is the point: the payable value doubles
at the month-end d = 240, but the annual lapse rate of the policy year closing there is
0.1% and its monthly conversion 0.008337%, so almost nobody is there to be paid the
step —
basis |
|
|
|
|
|
|---|---|---|---|---|---|
|
₩1,283.47 |
₩2,579.03 |
₩100,696.39 |
₩50,620,740.89 |
−₩30,061,540.20 |
|
₩37,392.94 |
₩74,889.03 |
−₩1,267.84 |
₩17,702,650.52 |
−₩10,160,522.69 |
— while on the level basis the same step drives that month’s net cash flow through zero, a
101.3% collapse in a single row. The monthly grid is what makes the timing readable: the
surrender outgo on the 원칙모형 arrives in the very next month, when the rate jumps to the
0.8% ultimate against a value that has just doubled, and claims_lapse goes from ₩2,579.03 at
t = 239 to ₩20,722.80 at t = 240 — an eightfold step in one row that an annual grid
could only report as a year’s total. The flat run’s undiscounted total looks better, and
that is a trap rather than a finding: a 4% lapse rate empties the book before the expensive
years, so expected death claims fall by 65.0%, and the sign reverses once the 환급률 exceeds 1
— which is exactly why the K-ICS 대량해지위험 shock splits by whether surrender reduces or
increases net assets REG-R36 R7.
보험계약대출, and every payment floored at zero#
The loan is a modelled state on the month-end index, L(t+1) = (L(t) + D(t))·(1 + j_L) —
the roll from the month-end opening month t to the one closing it, on the monthly
equivalent j_L = (1 + i_L)^(1/12) − 1 of the annual loan rate, so twelve months compound
back to exactly 1 + i_L — compound, with interest
capitalised into principal and no repayment modelled std — repayment is permitted at any
time without fee and no Korean repayment statistic is public. The rate is
acc_int_rate() + 1.5%, which is 예정이율 + 1.5% on a 금리확정형 contract and 공시이율 + 1.5%
on a 금리연동형 one, stated at three carriers independently [S9] [S11] [S13]. It is a
vintage rate: because the base is the contract’s own 예정이율, a policy written in a
high-rate era carries a high loan rate for life, and one carrier’s live published range spans
연 3.5%~10.5% across its in-force book under a 최고 적용 대출이율 of 9.90% [S11] [S12].
The limit is 80% of the payable 해약환급금 — the observed range is 50%–85% at one carrier
and 50%–80% at another, and the composite takes 80% [S5 제34조] [S11] [S13]. That it is a
fraction of the payable value and not of W(d) is the whole demonstration. Model point 6
draws the contractual maximum at the start of policy year 10 of a 저해지 contract — the
month-end d = 108, the month t = 108 — and gets
₩8,265,310.25, half what the same election on the 표준형 twin would produce; model point 3
makes the identical election on a 무해지 contract and draws exactly nothing, because
during 납입기간 there is no value to lend against — the point the FSS made in terms in its 2019
소비자경보 and the 표준약관 repeats REG-R28 R4 REG-R25 제33조.
Korea has no equivalent of the Japanese loan-excess lapse notice: the deduction is automatic
and termination is driven by the demand period, not by the balance [S5 제34조]. So a balance
that outgrows the value does not terminate anything — it simply floors the payment at zero, and
on model point 6 it does: it crosses the ₩100,000,000 sum assured at the month-end
d = 871 — seven months into policy year 73, a date the annual grid could only place in a
year — and reaches ₩114,044,264.36 by d = 911, at which point both the death benefit and
the surrender benefit are zero. Every payment
in claims() is floored for that reason, and check_loan_roll_fwd() asserts the accumulation
in both directions — non-trivially on point 6, and as a statement that zero stays zero on
point 3.
보험료 납입면제 is a state, not a rate adjustment#
Korea puts no severe-disability acceleration on this chassis. The slot Japanese whole life fills with a 高度障害保険金 at the sum assured is filled here by the premium waiver, which stops the premiums and continues the contract [S2] [S3] [S6] [S8]. The trigger is a 50% 장해지급률 aggregated across body parts from one cause, accident or disease alike, on the 장해분류표 of 생명보험 표준약관 부표 3 REG-R25.
What makes it a modelling problem is the deemed-paid rule: 「보험료가 보험료 납입기간
종료일까지 … 정상적으로 납입된 것으로 하여 사망보험금 및 해지환급금을 계산합니다」 [S2] [S3]
[S8]. A waived policy therefore accrues surrender value on the full premium scale while paying
nothing — and on a suppressed form it is the only route to the cliff that the policyholder
does not have to fund, which makes the waiver a genuine option with value rather than a
protection feature. So it is a distinct in-force state with its own persistency: pols_waived
carries no premium income, full benefit outgo, full account accrual and the ordinary
decrements. Carrying the same lapse rate as the paying cohort is std — no Korean
statistic distinguishes them. Model point 7 runs it at 0.4% a year — converted to
1 − (1 − 0.004)^(1/12) a month, so twelve compound back to it — and reaches 0.044660 of
the surviving book waived by t = 240, the month 납입완료 falls in.
The wiring detail worth stating, because it is invisible in the base run: premiums(t) is
weighted by pols_pay_exp(t) and every other line by the whole in-force count. The two are
equal wherever waiver_rate is zero, so an implementation that weights premium by pols_if(t)
reproduces the worked example exactly and fails only once the module is switched on. The
monthly grid sharpens the module rather than changing it: the waiver now takes effect in the
month of the 장해 rather than at the next 계약해당일, which is what the deemed-paid rule
actually says.
Modules that are off in the base run#
Six constructions are implemented and switched off, so that the base run reproduces the worked example while the machinery stays visible and testable. Each is a model point column, so the base run and the module run are the same model and the suppressed form sits beside the ordinary one in one projection.
Module |
Switch |
Off value |
Exercised on |
Signature number |
|---|---|---|---|---|
보험계약대출 |
|
|
points 3, 6 |
|
보험료 납입면제 |
|
|
point 7 |
|
유지보너스 |
|
|
point 8 |
|
금리연동형 crediting |
|
|
point 9 |
|
감액 |
|
|
point 10 |
|
부활 |
|
|
point 10 |
a reinstated lapse is paid no surrender value |
Four of them need a sentence of their own.
유지보너스 is a 7년납 단기납 design crediting 13.8% of total premiums to the
계약자적립액 at 납입완료 [S7], which lifts the 환급률 at 완납 to 0.972448. Switching it on
switches lapse_spike() on with it: the supervisor requires an additional lapse of at least
30% at any bonus date REG-R27, so the point’s lapse_rate_mth(83) — the single month the
bonus is credited in — is 0.300083 against a base of
0.000083. The spike is the one decrement that is not converted, and deliberately: it is a
lump of elective exits on a date, not a rate spread over a year, so it is added to that one
month’s converted base rate and to no other. Turning the bonus on without the spike would
misstate the liability in the insurer’s
favour, which is exactly what the guidance exists to prevent, and the two are wired together so
they cannot be separated by accident.
금리연동형 crediting puts the account on a declared 공시이율 floored at a 최저보증이율
of 연복리 0.75% [S5] while the net premium stays on the 예정이율 fixed at issue, so the
account is genuinely path-dependent and runs 9.12% ahead of its prospective form at the
month-end d = 240.
check_pol_val_prosp() is defined as zero there rather than asserted, because the identity
is not a property of that contract. Asserting it unconditionally fails; discounting the account
on the pricing rate to make it pass would silently change the product.
감액 treats the reduced portion as surrendered and pays the corresponding 해약환급금 on
the basis applying at that duration [S5 제20조] — so a reduction made during 납입기간 pays at
k W(d) and pays nothing at all on a 무해지 contract. The election falls in the month
12 × reduce_year() − 1, on the 계약해당일 d = 12 × reduce_year() that closes it — the same
contractual date the annual grid used, now located to the month. The sum
assured, the premium, the
account and the surrender charge all restate pro rata through a single sa_factor(d), which is
exact here rather than an approximation because every one of them is proportional to the
보험가입금액. The reduction month is the one month check_pol_val_roll_fwd() cannot
close, because the value is re-based by the election rather than rolled forward — so the
residual is taken on pol_val_base_pp, the unreduced path, and the scaling is asserted
separately by the ledger.
부활 returns 20% of one year’s lapses to the paying cohort a year later std. The substantive effect is that a reinstated lapse is not paid a surrender value: 부활 requires that the 해약환급금 has not been drawn, and the 약관’s parenthesis expressly includes the case where there was none — 「해지환급금이 없는 경우를 포함」 — so a 무해지 contract is always reinstatable within three years [S5 제26조] REG-R25 제27조. The lag is twelve months on the monthly grid, which is the same year it always was; no arrears cash flow is modelled for the twelve instalments that fall inside it std, and that omission is now visible as twelve missing rows where the annual grid could hide it as “no instalment falls inside a one-year gap”.
mort_be_factor is the last lever, 1.00 on every point but 10. At 1.00 the base run is a
pricing-table run, not a best estimate: the shipped table is calibrated toward an insured
level and no retrieved source sizes the margin a Korean carrier’s 적용위험률 carries against
its own experience. Claims move proportionately with it; the terminal rate is held at 1
whatever it is set to, because omega_age is the horizon of the table and not an
experience assumption.
Two things are deliberately not modelled and are named so a reader does not assume them.
감액완납 and 연장정기보험 appear in no retrieved Korean document — neither in the only full
약관 in the set nor in any 상품요약서 [S5] — and are unverified rather than established;
a reader arriving from jplib, where 払済保険 and 延長定期保険 are both in the 約款, will
reach for them and should not. And the 해약환급금준비금, the IFRS 17 CSM and the K-ICS
요구자본 consume result_cf() rather than living inside it.
Inputs are external files#
Read once, in Data#
Three CSVs live in this directory, beside run.py, and the model folder holds nothing but
formulas — no _data/, no IOSpec, no embedded values. This follows annuallife.TradLife_A;
contrast basiclife.BasicTerm_S, which stores its inputs inside the model. The consequence
worth knowing is that the model is not portable on its own: copying WholeLife_KR_S/
without its parent’s CSVs produces a model that reads and then fails on first evaluation.
Every reader and every *_file Reference lives in Data, which takes no parameters, so each
file is read once per model rather than once per model point. Projection is parameterized
by point_id and every Projection[N] is a separate ItemSpace with its own cells cache; a
reader placed there would re-read every file for every policy.
Reference |
Cells |
File |
|---|---|---|
|
|
|
|
|
|
|
|
|
input_dir() returns _model.path.parent, resolved at run time from wherever the model was
read, so the model works from any checkout and from a copy made by lifelib.create(). Every
row of the two assumption files carries a provenance cell beginning with a citation tag,
which is a property of this library rather than a habit, and the conventions suite asserts it.
model_point_table.csv — ten points, and the anchor is sourced#
Twenty columns, indexed by point_id. Point 1 is 남자, 보험나이 40세, 보험가입금액 1억원,
종신, 20년납, 저해지환급형 k = 0.50 — the technical notes’ worked-example anchor cell, and
the cell Korean industry comparison disclosure itself uses (1억원 / 종신 / 20년납 / 월납)
[S17]. Its annual premium of ₩2,776,140 is 0.900 × the 표준형 twin’s ₩3,084,600 — a
std rounding of the 89.9% premium ratio one carrier publishes at a 50% suppression
[S1] — and ₩3,084,600 is 12 × the published ₩257,050 monthly rate for exactly that cell
[S4]. That twin is point_id = 2, and the two run side by side throughout the notes.
Points 3 and 6 are built the same way — prem_susp_ratio × the sourced ₩3,084,600, point 6
being the anchor with the loan module switched on — and the remaining six take their premiums
from prem_gross_calc_pp() rounded to the won.
The other nine cover both sexes, issue ages 30 to 65, sum assureds ₩10,000,000 to
₩1,000,000,000 (1,000만원 ~ 10억원), the four suppression factors 1.00 / 0.50 / 0.30 / 0.00,
payment terms of 7, 10, 20 and 30 years and 전기납, and each optional module. The premium
column stays the annual premium, because that is the unit a commission scale and a
comparison disclosure are written in, and premium_mth_pp() is one twelfth of it — which puts
the twin’s monthly premium back at exactly the published ₩257,050 [S4], the number the column
was built from in the first place. No modal loading is applied to that division std: no
carrier publishes an annual scale, so the annual figure carries whatever loading the monthly
rate already had.
None of the three time-like columns moved when the grid did, and that is the point of the
conversion. They are contractual terms quoted in policy years, and the contract did not
become monthly — only the grid underneath it did. prem_term is a count of policy years
(0 denoting 전기납) and the model reads it through prem_period(), whose months
prem_period_mths() supplies. loan_year and reduce_year are contractual, 1-based
policy-year labels — “the loan is drawn at the start of policy year 10”, “the 감액 is made on
the 계약해당일 closing policy year 15” — and the model multiplies them out rather than indexing
by them, exactly as the shipped lapse_table.csv endpoints are quoted against policy years. So
the file’s values are unchanged and the model does the conversion: loan_draw fires in the
month 12 × (loan_year() − 1) and the 감액 in the month 12 × reduce_year() − 1.
mort_table.csv is keyed by sex and attained 보험나이, and lapse_table.csv by
lapse_basis; neither carries a time index at all, so neither changed.
mort_table.csv — a std construction, and it must never be called the 경험생명표#
202 rows, sex × attained 보험나이 15 … 115, with mort_rate and provenance. The 제10회
경험생명표 is not published in full — what is released is the 평균수명 and the 기대여명, and
even those reached this library through a trade newspaper REG-R33 REG-R34 — so unlike
jplib, where the 生保標準生命表 is free to read and only its redistribution is restricted,
there is no published Korean insured rate to anchor a proxy on. The file is built in three
parts and every row says which it is:
ANCHOR rows at 보험나이 20, 40 and 60 take the mean of the only two Korean insured mortality rates in the public domain at those ages, the sample 적용위험률 grids that 하나생명 [S2] and KDB생명 [S8] print in their 상품요약서 —
q(40) = 0.00085is the mean of 0.000780 and 0.00092,q(60) = 0.005075the mean of 0.004550 and 0.00560. The two rates are sourced; taking their mean is the standardization. They differ by up to 24% — 18%–24% at five of the six published cells and not at all at 여 20세, where both print 0.00018 — so they bracket rather than fix a level.CONSTRUCTED rows below 60 are log-linear in
ln qbetween those anchors and extrapolated below age 20 on the same slope; above 60 they follow a Gompertz inln qwith a quadratic deceleration term whose two parameters are solved so that the table’s 65세 기대여명 is 23.7 years (male) and 27.1 (female) — the 국가데이터처 완전생명표 figures of 19.5 and 23.7 REG-R38 plus the gap to the reported 제10회 figures REG-R33 — and so thatqreaches 1 at ω = 115.The TERMINAL row sets
q = 1at ω = 115, which closes the table. The 제10회 terminal age is not public either.
Two checks on the construction were not used to fit it and are worth recording. The resulting
평균수명 at birth is 85.4 years (male) and 90.4 (female) against the 86.3 and 90.7
reported for the 제10회 REG-R33. And the model’s own 표준형 surrender values sit at a
consistent 0.899–0.923 of the fullest published grid at the same cell across durations 3 to
20 [S4] — a level offset rather than a shape error, reported duration by duration in
technical-notes.md. No row of this file is a 경험생명표 value. A user with a real
산출방법서 replaces mort_table.csv with a same-schema file and changes no formula.
lapse_table.csv — two bases, three parameters each, and no rate per duration#
Two rows, indexed by lapse_basis, with first_year_rate, completion_rate,
ultimate_rate and provenance. The columns are parameters rather than a rate per policy year
because the convergence point is 납입완료 and that differs by model point: a 7년납 contract
converges in policy year 7 — the months t = 72 … 83 — and a 30년납 one in policy year 30, on
the same two rows. All three parameters are annual rates and the interpolation between
them runs on the policy year, so the vector is the one the supervisor tabulates and the model
converts it a month at a time. The loglinear row’s
endpoints are the supervisor’s REG-R27 R3 and its first-year rate the top of a disclosed
pricing envelope [S2] [S8]; the shape between them is std. The flat row is std end to
end, level because the 표준형 comparison twin is priced with no lapse assumption at all [S1].
Expense, commission and interest levels are Projection References rather than a fourth table,
because each is a single scalar: prem_int_rate 2.50%, min_guar_rate 0.75%, prem_loading
1.4642, acq_cost_ratio 1.00, comm_init_share 0.65, comm_renewal_rate 3.0%,
expense_maint_pp ₩5,000 a month, expense_maint_prem_rate 2.0%, expense_claim_pp
₩300,000,
inflation_rate 2.0%, loan_spread 1.5%, loan_limit 0.80, lapse_bonus_spike 0.30, and the
five 별표 14 parameters surr_chg_prem_rate 5%, surr_chg_coef 20, surr_chg_sa_rate 1%,
surr_chg_prem_years 20 and surr_chg_max_years 7.
Sign convention#
net_cf is income positive — premiums less claims, expenses and commission — which is both
the specification’s own sign and the library-wide one, so there is no outgo-positive
liability_cf companion to publish: one stream, one sign, one name.
That the published statement adds up is check_net_cf(), with the per-t signed residual at
check_net_cf_resid(t) — the library-wide name for this check on every model. The identity
is net_cf(t) = premiums(t) − claims_death(t) − claims_lapse(t) − claims_reduction(t) − claim_expenses(t) − expenses(t) − commissions(t), that is, every non-pols_if column of
result_cf() on row t sums to that row’s net_cf. So a fourth benefit kind added to
claims() and left out of the statement shows up here rather than vanishing from it.
result_cf() publishes claims_death, claims_lapse and claims_reduction and no bare
claims column, which is what makes the columns sum with nothing to skip; the
claims(t, kind) cells stays, and claims(t) with kind omitted is their total.
On the anchor cell the undiscounted total is −₩30,061,540.20 per policy issued, which is the expected shape and not a defect: expected death claims of ₩50.62m plus surrender benefits of ₩9.29m come to ₩59.92m of benefit against ₩37.68m of premium, and everything that closes that gap — discounting, the investment return on the account, the CSM — belongs to a layer this model deliberately stops before.
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
rates, *_pp for per-policy amounts, claims(t, kind) with an uppercase kind string,
pols_if_at(t, timing) for the within-month in-force reads. This is a contractual account and
a surrender value, so it is pol_val_pp and cv_pp; there is no av_pp anywhere, and no
reserve_pp, because the model computes no reserve. lapse_rate is the annual rate and
lapse_rate_mth its monthly conversion, which is the library-wide convention on every monthly
model: the unsuffixed name always holds the rate the source tabulates, and the _mth companion
always holds 1 − (1 − rate)^(1/12). The same pairing runs through mort_rate/mort_rate_mth
and waiver_rate/waiver_rate_mth, and through the interest cells as
acc_int_rate/acc_int_rate_mth, prem_int_rate/prem_int_rate_mth and
loan_int_rate/loan_int_rate_mth.
The technical notes use compact actuarial symbols and the Projection Space docstring carries
the full symbol-to-cells map. Five cases needed care, and three of them were settled by the
cross-model review:
Notes |
Cells |
Why |
|---|---|---|
|
|
The first is the 해약공제액 and the second the 표준해약공제액 that bounds it. |
|
|
The bare |
|
|
The 예정이율 and the 공시이율. A romanized |
|
|
Three names for one account and one multiplier, all indexed by the month-end |
|
|
|
|
|
The 연납순보험료 별표 14 names, and the monthly net premium the account actually consumes. They differ by the 2.4% modal loading and each is used in exactly one place, which is why both names exist |
pol_val_base_pp is the fifth name a reader will ask about: it is the account on the
unreduced path, which exists only so that check_pol_val_roll_fwd() has something to roll
forward across a 감액 month-end, where pol_val_pp is re-based by the election.
Standardizations used#
Every one of these is a std choice, and each is stated in the cells docstring that uses it. Where the research pass established an observed range across insurers, it is given.
Parameter |
Value |
Basis for the choice |
Observed range |
|---|---|---|---|
예정이율 |
2.50% |
centre of the band read from six carrier documents; equals the 2026 평균공시이율 REG-R48 |
2.25%–2.75% [S1] [S2] [S5] [S6] [S7] [S8] |
최저보증이율 |
0.75% |
stated verbatim in the one full 약관 retrieved [S5] |
one observation |
Mortality table |
ω = 115, e(65) = 23.7 / 27.1 |
the two anchors differ by up to 24% |
|
Lapse |
10% → 0.1% at 납입완료, 0.8% after |
endpoints REG-R27; first year from a disclosed pricing envelope [S2] |
연 1%~10% [S2]; 연 0%~13.4% [S8] |
Lapse |
4.0% |
inside both disclosed envelopes |
as above |
Bonus-date lapse spike |
+30 pp |
「30% 이상」 required at a bonus date REG-R27 |
one instrument |
Premium loading |
1.4642 |
calibrated once so the 표준형 anchor reproduces 12 × ₩257,050 [S4] |
fits to 0.0010% |
계약체결비용 |
1.00 × 표준해약공제액 |
at the cap, inside the 1.4 × tolerance REG-R22 |
no carrier publishes one |
Commission share |
0.65 of 계약체결비용 |
capped at the first year’s premium REG-R22 제4-32조제5항 |
no scale is public |
Renewal commission |
3.0% of premium |
as above; paid only to 납입완료 |
no scale is public |
계약관리비용 |
₩5,000 a month + 2.0% of premium |
no Korean expense rate is public [S2] [S5] [S8] REG-R2 |
none |
Claim expense |
₩300,000 per claim |
as above, uninflated |
none |
Expense inflation |
2.0% p.a., stepping on the 계약해당일 |
the Bank of Korea target; compounds to 4.42 over 76 years |
none |
Policy loan limit |
80% of the payable 해약환급금 |
top of the narrower published range |
50%–85% [S11]; 50%–80% [S13] |
Policy loan repayment |
none modelled |
permitted at any time without fee; no statistic is public |
none |
Surrender-charge shape |
straight line to |
the cap and the period are sourced and exact; only the shape is std |
none |
Suppression factor |
model point column |
the market runs all four values |
0.00 / 0.30 / 0.50 / 1.00 [S1] [S4] [S6] [S7] [S8] |
Cliff date |
납입완료 |
three of five observed designs |
also 7년 [S2] and 납입기간+3년 [S3] |
Waiver persistency |
same lapse rate as the paying cohort |
no Korean statistic distinguishes them |
none |
부활 lag and rate |
twelve months, 20% |
no Korean reinstatement statistic is public |
none |
Premium mode |
the annual column ÷ 12 |
no carrier publishes an annual scale; the column was built as 12 × a published monthly rate, so the division returns that rate exactly |
none |
Monthly decrements |
|
the uniform-force conversion; twelve compound back to the tabulated annual rate |
none |
Terminal-year mortality |
UDD, |
|
none |
Age basis |
보험나이 read against a 만나이-calibrated table |
no public mapping exists between the two REG-R38 |
none |
Where the cliff falls is the standardization a reader is most likely to need to change. The composite hard-codes 납입완료, which three of the five observed designs use; a model reproducing [S2] or [S3] must expose that date as a parameter of its own, because on those contracts the step is at seven years and at 납입기간 + 3년 respectively, neither of which is 납입완료.
Tests#
tests/test_model_conventions_kr.py runs the house style over this model with every other in
the library: the two-Space layout and the model folder holding formulas only, input_dir()
resolving to the parent, every CSV beside the model actually read by a *_file Reference,
the provenance tag on every assumption row, a docstring on every Space and every single cells,
the model docstring’s house disclaimers, the Projection docstring’s symbol map and its
statement of the age basis, the Data docstring naming TradLife_A, lower_snake_case cells
names and the shared retired-name register, lapse_rate as the annual rate, the result_cf()
column vocabulary and its net_cf column, net_cf income-positive, pols_if as a
start-of-period count, check_net_cf() published and True, every model point in the shipped
table projecting without raising, the inputs read once per model rather than once per model
point, and a read → write → re-read round trip reproducing the same file set and the same
numbers.
tests/test_whole_life_kr.py asserts what is specific to this product. The notes’ worked
example is held hard-coded as a module-level table — the cash-flow rows, the
surrender-value rows, the derived scalars, the undiscounted totals and the decrement split — so
a reviewer can lay it beside technical-notes.md and compare by eye. The cash-flow goldens are
keyed by the 0-based month t and the surrender-value goldens by the month-end d, which
is how the notes’ two tables are keyed. Money is asserted to
two decimal places of the won, in-force to six decimals and the decrement totals to ten,
which is the precision the notes display.
Beyond the worked example, every entry in the notes’ Known modeling pitfalls list has a test named after it, because each is a way an implementation can look right and be wrong:
the cliff as a step and not a ramp,
cv_pp(240) / cv_susp_pp(240)exactly1 / k = 2.0, and its absence on the 전기납 point wherecv_mult(d)iskfor life;the surrender in the last paying month —
t = 12m − 1— paid on the full value at the month-endd = 12m, with both values published at the boundary;one account and one multiplier — points 1 and 2 reaching the identical
pol_val_ppfrom different premiums, and the 환급률 crossing 100% atd = 280againstd = 347;the step not being a surrender-charge effect:
surr_chg_pp(84) = 0, thirteen years early;PandP₂₀as different annuities, tested on the 7년납 and 10년납 points where they do not coincide, since a test run only on the anchor cannot see the difference;the monthly decrements compounding back to the annual ones, month by month, and the surviving book at every 계약해당일 reproducing the annual-step model this one replaced;
the surrender value becoming payable inside policy year 2, at
d = 15, which an annual grid cannot express at all;premiums stopping at
12mwhere nothing else does, withnet_cfswinging by ₩151,116.00 acrosst = 239 → 240and negative in all 672 remaining months;the nil surrender value of the 무해지 form through the whole of 납입기간 and the exactly zero policy loan that follows from it at
loan_util = 1.0;every payment floored at zero, on point 6 where the loan balance exceeds the sum assured;
the 30-point lapse spike at the 유지보너스 date, and that the bonus cannot be switched on without it;
the prospective identity withdrawn rather than forced on the 금리연동형 point;
pol_val_ppnever appearing innet_cfand never being read as a reserve;waived premiums counted as paid, and the waiver as a state rather than a rate adjustment;
lapse behavioural rather than funded — no APL machinery imported from
jplib;the pro-rata restate after a 감액, and the 부활 that is paid no surrender value.
Beyond those: all nine check_*() identities on all ten model points, the roll-forward and
decrement-sum identities rebuilt independently of the recursions, each optional module in
both positions, the CSVs’ encoding and the mortality table’s row-by-row provenance, an
input swapped by repointing a filename Reference, and the calibration band against the
published 표준형 grid [S4].
python -m pytest lifelib/libraries/krlib/tests/test_whole_life_kr.py -q
python -m pytest lifelib/libraries/krlib/tests/test_model_conventions_kr.py -q