# modelx: pseudo-python
# This file is part of a modelx model.
# It can be imported as a Python module, but functions defined herein
# are model formulas and may not be executable as standard Python.
"""The by-contract projection of the :mod:`~.Immediate_KR_S` model.
The Space is parameterized by ``point_id``, so ``Projection[1]`` is an ItemSpace projecting
model point 1::
>>> Projection[1].result_cf() # the worked-example anchor
>>> Projection.point_id = 6 # the 상속연금형, retention as designed
>>> Projection.point_id = 7 # the same contract, retention as ordered
``t`` counts **completed policy months from inception and is 0-based**: ``t = 0`` is the
first policy month, and the contractual policy year — the 1-based label the 약관 speaks in —
is the derived ``policy_year(t) = t // 12 + 1``. Period ``t`` runs from time ``t`` to time
``t + 1``; row ``t`` of :func:`result_cf` carries the cash flows of month ``t``; the
single premium falls at time 0 on row 0; and the annuity is payable **in arrears**, so the
payment shown on row ``t`` falls at the end of month ``t``, which is the 연금지급일 —
「미지급된 연금월액을 **매월** 연금지급일에 드립니다」. ``proj_len()`` is the **number of
projected months**, the frame's exclusive end and ``12 x proj_years()``, so the frame is
``range(proj_len())`` and the last row — the one carrying the last scheduled payment — is
``t = proj_len() - 1``.
The contract terms stay in **years**, which is how the 약관 states them, and the assumptions
stay **annual**, which is how they are filed and published: ``mort_rate(t)``,
``lapse_rate(t)``, ``decl_rate()``, ``min_guar_rate(t)`` and ``crediting_rate(t)`` are the
yearly figures, and ``mort_rate_mth``, ``lapse_rate_mth`` and ``crediting_rate_mth`` are
their uniform-force monthly companions, level inside a policy year and stepping on each
계약해당일. Twelve of each compound back to the year's figure exactly.
.. rubric:: Age basis
Every age in this model is **보험나이** (*boheom nai*, insurance age) — 만나이 at the
계약일 with a remainder under six months discarded and six months or more rounded up to a
year, incrementing on each 계약해당일. It is the age the 가입나이 band is stated in, the age
the shipped 개인연금사망률 table is indexed by, and the age the model point table carries.
It is **not** 만나이 (age last birthday), which is what the public 완전생명표 and every
Korean population statistic are published on; the six-month rule makes the two differ for
half of all issue dates, and reading a 만나이 model point against a 보험나이 table
understates the rate by about half a year of ageing on every row without raising anything.
.. rubric:: Input data
Inputs are **external files**: plain CSVs living in the model folder's parent directory,
``products/immediate_annuity/``, read at run time rather than stored inside the model. Each
table has a filename Reference and a reader Cells, both on
:mod:`~.Immediate_KR_S.Data`, reached here through the ``data`` Reference:
====================== ==================================== ========================
Reference Cells File
====================== ==================================== ========================
model_point_file data.model_point_table() model_point_table.csv
mort_table_file data.mort_table() mort_table.csv
charge_table_file data.charge_table() charge_table.csv
crediting_table_file data.crediting_table() crediting_table.csv
====================== ==================================== ========================
.. rubric:: Naming
Cells names follow lifelib's ``basiclife.BasicTerm_S``, ``savings.CashValue_SE`` and
``annuallife.TradLife_A`` wherever those models have an analogue, and follow the sister
libraries' payout models wherever the products share machinery. The technical notes use
compact actuarial symbols instead. The mapping is:
========================= ============================== ==========================
Notes symbol Cells Meaning
========================= ============================== ==========================
t (the cells argument) 0-based period index
(model point) model_point() The selected row as a Series
(shape) shape() life / inheritance / certain
x age_at_entry() 가입나이, 보험나이
x + t age(t) Attained 보험나이
(sex) sex() M or F
P prem_pp() 일시납보험료, the single premium
n, g annuity_term() 보험기간 / 연금지급기간, or the
보증지급기간 on the life shape
(switch) retention_basis() as_designed or as_ordered
(basis id) crediting_basis() Row set of crediting_table
w lapse_rate(t) Annual surrender rate **[std]**
w^m lapse_rate_mth(t) Its monthly conversion
i_d decl_rate() 공시이율
i_g(t) min_guar_rate(t) 최저보증이율, duration-stepped
i(t) crediting_rate(t) Max[공시이율, 최저보증이율]
j(t) crediting_rate_mth(t) Its monthly equivalent
q(x + t) mort_rate(t) 개인연금사망률 at age(t)
q^m(t) mort_rate_mth(t) Its monthly conversion
(load) acq_charge_rate() 계약체결비용
(load) admin_charge_rate() 계약관리비용
c expense_load_rate() The two, summed
b risk_prem_rate() 위험보험료
(commission) comm_rate() 모집수수료율
(expense) acq_expense_rate() Load less commission **[std]**
(charge) annuity_charge_rate() 0.80% of the 연금연액
(benefit) db_rate() 사망보험금 as a share of P
B risk_prem_pp() 보장계약 보험료
V(0) av_pp_init() Opening 계약자적립액
V(t) av_pp(t) 계약자적립액 at time t
CV(t) cv_pp(t) 해약환급금
(deduction) surr_chg_pp(t) 해약공제액, nil at every t
M maturity_benefit() 만기보험금
a(m, i) annuity_factor_certain(m, i) Annuity-certain in arrears
s(m, i) accum_factor(m, i) Accumulation of 1 a period
ae(x, g, j) annuity_factor() 종신연금형 factor at inception
A(t) annuity_pp(t) 연금월액 payable at t + 1
12 A(t) annuity_pp_annual(t) The 연금연액 it makes up
R(t) retention_pp(t) 만기보험금 지급재원
v(t) disc_factor(t) PV factor on the crediting path
(dispute) retention_shortfall_pp() PV cost of the 주문 liability
l(t) lives_if(t) Annuitant alive at time t
(persistency) surr_if(t) Not surrendered at time t
IF(t) pols_if(t) Any payment obligation open
(none) pols_if_init() Obligations in force at t = 0
d(t) pols_death(t) Deaths in period t
(surrenders) pols_lapse(t) Surrenders in period t
(exits) pols_exit(t) Obligations ending in period t
F(t) payment_factor(t) Weight on the payment at t + 1
(pricing) pricing_factor(t) The same on the pricing basis
N proj_len() Number of projected months
N / 12 proj_years() The same horizon in years
E[PREM(t)] premiums(t) Single premium income
E[ANN(t)] annuity_payments(t) 생존연금 outgo
E[DTH(t)] claims(t, "DEATH") 사망보험금
E[SUR(t)] claims(t, "LAPSE") 해약환급금 on surrender
E[MAT(t)] claims(t, "MATURITY") 만기보험금
E[COM(t)] commissions(t) 모집수수료
E[EXP(t)] expenses(t) Acquisition and annuity charges
CF(t) liability_cf(t) Total gross liability outgo
(none) net_cf(t) -liability_cf(t), insurer sign
========================= ============================== ==========================
Four names needed care.
``pols_if`` is **not a policy count**. It is the notes' ``IF(t)``, the probability that a
**payment obligation remains** at time ``t``, which on this product is not the same thing as
the probability that the annuitant is alive. Within the 보증지급기간 the instalments are due
whether or not the annuitant lives; on the 확정기간연금형 they are due irrespective of
survival for the whole term; and on the 상속연금형 death itself triggers a payment. The name
is kept because it is what the rest of the library weights maintenance expense by and what
``result_cf()`` publishes first. :func:`lives_if` is the survival probability proper, and
the two differ on every shape.
``annuity_term`` carries three contractual quantities under one name because the arithmetic
treats them identically: the **보증지급기간** on the life shape, the **보험기간** on the
inheritance shape and the **연금지급기간** on the certain shape. What differs is what the
projection does after it, and that is the shape's business, not the term's.
``lapse_rate`` is the **annual** rate, as everywhere in this library, and
``lapse_rate_mth`` is the monthly conversion the projection applies — the same pairing the
other monthly models in ``krlib`` use, and for the same reason: the assumption is stated and
argued annually and only the grid beneath it is monthly. It is nil on the life shape as a
matter of contract, not of assumption.
``decl_rate`` is the 공시이율. The romanized name was rejected in the library's naming
review: 공시이율 is the declared crediting rate under the same definition ``delib`` settled
on for the laufende Verzinsung, and it is not the 예정이율 (pricing interest rate), which is
``prem_int_rate`` and does not appear in this model at all.
There is **no** ``prem_pp_mth``, ``pols_maturity``, ``cv_floor_ratio``, ``surr_chg_cap_pp``
or ``renewal_decline_rate`` of any kind, and the absence of each is a product fact.
.. rubric:: There is no premium term, so there is no lapse machinery of the usual kind
A single premium leaves nothing to miss, so 표준약관 제26조's 납입최고 and 제27조's 부활
cannot operate and neither is modelled. The only decrements are mortality and — on the two
shapes that permit it — voluntary surrender. On the **종신연금형 surrender is contractually
impossible**: 「종신연금이 지급개시된 이후에는 해지할 수 없습니다」, and on an immediate
annuity the annuity begins a month after inception, so the contract is irreversible from
month one. :func:`check_surr_value` asserts that the shipped life-shape model points carry a
nil rate and a nil surrender value, rather than leaving it to the table.
**No retrieved source gives a surrender rate for 즉시연금 by duration or by shape.** The
assumption on the other two shapes is therefore entirely unsourced, is **[std]**, and is
carried as a per-model-point scalar so that its effect can be isolated.
.. rubric:: The guarantee is a floor on the obligation, not a second stream
``payment_factor(t) = max(l(t + 1), 1{t + 1 <= 12g})`` on the life shape. Within the
보증지급기간 the full instalment is payable whether or not the annuitant lives, and an
additive construction — the survival probability *plus* the guarantee — would pay
``1 + l(t + 1)`` for the whole guaranteed term. :func:`check_payment_factor` asserts the
``max``, and :func:`check_guarantee_certain` asserts that the weight is exactly one for
every payment inside the guarantee.
The commutation right — 선지급, the unpaid guaranteed instalments taken as a lump sum
discounted at the 공시이율, available on death and on request once a year in whole years —
is **recorded and not exercised**. The projection pays the guaranteed instalments on their
contractual dates. That is a **[std]** simplification and it is value-neutral only because
the discount rate is the same rate that sets the annuity.
.. rubric:: The retention, and why it is a switch
On the 상속연금형 만기형 the maturity benefit is the **gross** single premium while the fund
opens at the premium net of the load, so part of each year's interest must be retained to
rebuild it. Writing ``M`` for the maturity benefit and ``s(m, i)`` for the accumulation of
₩1 a year in arrears over the remaining term,
``A(t) = V(t) j(t) - (M - V(t)) / s(m, j(t))``
with ``j`` the monthly crediting rate and ``m`` the remaining term in months, decomposes the
annuity exactly into interest on the fund less the **만기보험금 지급재원**.
Both terms move against the policyholder when the rate falls: the interest falls with ``i``
and the retention *rises*, because ``s`` shrinks. That retention was set out in the
산출방법서 and not in the 약관, and 금융분쟁조정위원회 조정결정 제2017-17호 held on
2017-11-14 that it could not be asserted against the policyholder; the Supreme Court
restored it for the contracts before it on 2025-10-16, and the current market states the
deduction on the face of the 약관.
Neither reading is "the" right one, so the model carries both.
``retention_basis = "as_designed"`` runs the identity above;
``retention_basis = "as_ordered"`` sets ``R(t) = 0``, so the annuity is interest on the fund
alone and the maturity benefit is met from the insurer's own resources.
:func:`retention_shortfall_pp` is what the second costs, discounted to inception on the
crediting path, and model points 6 and 7 are the same contract on the two bases.
A specification that buries the retention inside an annuity factor cannot express the
question the whole litigation was about, which is why it is an explicit term here.
.. rubric:: Two of the three shapes use no mortality in the annuity
「옵션 중 사망(생존) 위험률이 적용되는 것은 종신형에 한정된다 … 확정형과 상속형은
사망률을 사용하지 않는다」. Only :func:`annuity_factor` reads the table for pricing, and it
is defined on the life shape alone and raises on the others. Mortality still enters the
**projection** of the other two shapes, because both pay a death benefit; the distinction
between a decrement and a pricing basis is exactly the distinction the two uses draw.
.. rubric:: The 계약자적립액 on the life shape has no contractual role
:func:`av_pp` runs the 약관's own recursion — 「연금개시후에는 생존연금 발생분을 차감한
금액」 — on all three shapes. On the inheritance shape it climbs to ``M`` at maturity and on
the certain shape it exhausts to zero, and on both it is the base of the 해약환급금. On the
**life shape it is neither**: the annuity is a life annuity, the fund is not the reserve,
and the recursion runs negative at about the point where the annuitant has outlived the
factor the fund bought. Surrender is prohibited there, so :func:`cv_pp` is nil and nothing
downstream reads the negative value; :func:`check_av_terminal` accordingly asserts a
terminal value on the two shapes that have one and says so on the third.
.. rubric:: Sign convention
The notes define ``CF(t)`` as total gross liability **outgo**, which is
:func:`liability_cf`. :func:`net_cf` is its exact negative, the library-wide
income-positive convention, so a ``result_cf()["net_cf"]`` column can be summed or compared
across every model in the library. Both are published as columns. The single premium is
genuine income at ``t = 0`` and is projected as such; there is no premium income after it.
"""
from modelx.serialize.jsonvalues import *
_formula = lambda point_id: None
_bases = []
_allow_none = None
_spaces = []
# ---------------------------------------------------------------------------
# Cells
[docs]
def model_point():
"""The selected model point as a Series.
One row of ``data.model_point_table()``, indexed by ``point_id``. Model point 1 is the
technical notes' worked-example anchor: 남자, 보험나이 60, 일시납 ₩100,000,000 (1억원),
종신연금형 with a ten-year 보증지급기간 on the representative crediting basis.
"""
return data.model_point_table().loc[point_id] # noqa: F821
[docs]
def shape():
"""The payout shape elected at inception: ``life``, ``inheritance`` or ``certain``.
``life`` is 종신연금형, the annuity for life with a 보증지급기간; ``inheritance`` is
상속연금형 만기형, interest only with the capital returned at maturity or on death; and
``certain`` is 확정기간연금형, the annuity-certain paid irrespective of survival. The
election is irrevocable, is priced into the basis and is a model point column rather
than a rider, because the three are three different liabilities.
"""
v = str(model_point()["shape"])
if v not in ("life", "inheritance", "certain"):
raise ValueError("unknown payout shape: %s" % v)
return v
[docs]
def sex():
"""The annuitant's sex, ``M`` or ``F``, the first key of the mortality table.
Male and female are rated separately on the life shape, which is the only shape whose
annuity reads a mortality table at all. The other two shapes still carry the sex,
because their death benefit is projected on the same table.
"""
v = str(model_point()["sex"])
if v not in ("M", "F"):
raise ValueError("unknown sex: %s" % v)
return v
[docs]
def age_at_entry():
"""x: the 가입나이 at inception, in **보험나이**.
The representative issue-age band is 45 to 80. There is no deferral on an 즉시연금, so
the 연금개시나이 equals the 가입나이 and no separate attribute is carried for it.
"""
return int(model_point()["age_at_entry"])
[docs]
def age(t):
"""x + t // 12: the attained **보험나이** in month t, the key into the mortality table.
보험나이 increments on each 계약해당일, which on a monthly grid is every twelfth period
boundary, so the attained age is the entry age plus the completed policy years and needs
no rounding rule and no fractional-age interpolation of its own.
"""
return age_at_entry() + t // 12
[docs]
def policy_year(t):
"""The contractual policy year containing month t, 1-based.
``t // 12 + 1``: the 약관 speaks in policy years and the grid runs in months, so this is
the label that translates between them. 「제1보험년도」 is ``t = 0 … 11``.
"""
return t // 12 + 1
[docs]
def annuity_term_mths():
"""The contractual term of :func:`annuity_term` expressed in months.
``12 x annuity_term()``. The contract states the 보증지급기간, the 보험기간 and the
연금지급기간 in whole years and the grid counts months, so every comparison against the
term goes through this cells rather than through a bare ``12 *`` at each site.
"""
return 12 * annuity_term()
[docs]
def prem_pp():
"""P: the 일시납보험료, the single premium, per policy.
Paid once at inception and never again: there is no premium term, no renewal premium
and no 추가납입 on the 즉시형. It is the base of the expense load, of the 위험보험료,
of the commission and — on the inheritance shape — of the 만기보험금, so a change to it
moves every quantity in the model.
"""
return float(model_point()["prem_pp"])
[docs]
def annuity_term():
"""n or g: the contractual term in whole years, in the sense the shape gives it.
On the life shape it is the **보증지급기간**, the period over which instalments are due
whether or not the annuitant lives; on the inheritance shape the **보험기간**, at the
end of which the 만기보험금 falls due; and on the certain shape the **연금지급기간**,
over which the fund is divided. Ten years is the representative term on all three
shapes, and it is what 97.3% of life-shape buyers and 77.6% of certain-shape buyers
actually chose.
"""
v = int(model_point()["annuity_term"])
if v < 1:
raise ValueError("annuity_term must be at least one year")
return v
[docs]
def retention_basis():
"""The 만기보험금 지급재원 switch: ``as_designed`` or ``as_ordered``.
``as_designed`` runs the retention the 산출방법서 specified, so the annuity is interest
on the fund less the amount needed to rebuild it to the 만기보험금. ``as_ordered``
sets the retention to zero, which is the liability 금융분쟁조정위원회 조정결정
제2017-17호 ordered and the 금융감독원 extended to the industry on 2018-03-15: interest
on the fund with no deduction, the maturity benefit met from the insurer's own
resources. The switch is meaningful on the inheritance shape only and is ignored
elsewhere.
"""
v = str(model_point()["retention_basis"])
if v not in ("as_designed", "as_ordered"):
raise ValueError("unknown retention basis: %s" % v)
return v
[docs]
def crediting_basis():
"""The ``basis_id`` selecting this contract's rows of ``data.crediting_table()``.
``decl_2017`` is the representative basis, a 공시이율 of 2.50% over a 최저보증이율
stepping 1.25% / 1.00% / 0.75% at five and ten years. ``min_guar`` sets the declared
rate to zero so that Max[공시이율, 최저보증이율] resolves to the floor at every
duration; it is not a product a carrier sells, but it is the basis on which the anchor
carrier publishes its 해약환급금 run, and it is what exercises the duration stepping.
"""
return str(model_point()["crediting_basis"])
[docs]
def lapse_rate(t):
"""w: the **annual** voluntary surrender rate in period t. **[std]**
Nil on the life shape at every duration, because a 종신연금형 cannot be surrendered once
the annuity is in payment and on an immediate annuity that means from month one. On
the other two shapes **no retrieved source gives a rate of any kind**, so the
assumption is entirely unsourced and is carried per model point rather than in a table,
so that its effect can be isolated.
It is nil in the final **policy year** as well, on every shape: a contract in its last
year runs to the 만기보험금 or to the last instalment rather than being surrendered a
moment before either, and a lapse decrement there would divert the maturity benefit into
a surrender value of the same amount for no reason a contract states. Holding it nil
over the whole final year rather than the final month is what makes the monthly grid's
in-force reproduce the annual-step model's at every 계약해당일.
"""
if shape() == "life":
return 0.0
if t >= proj_len() - 12:
return 0.0
return float(model_point()["lapse_rate"])
[docs]
def lapse_rate_mth(t):
"""w^m: the monthly surrender rate actually applied in month t. **[std]**
``1 - (1 - w)^(1/12)`` on the annual rate of :func:`lapse_rate`, the uniform-force
conversion: twelve monthly exits compound to exactly the year's annual rate, so the
annual assumption is refined onto the grid rather than restated. Note that it is
**larger** than ``w / 12``, which is the convexity a naive division would give away.
"""
w = lapse_rate(t)
if w <= 0.0:
return 0.0
return 1.0 - (1.0 - w) ** (1.0 / 12.0)
[docs]
def pols_if_init():
"""The obligations in force at t = 0, one per model point.
Every shipped model point stands for a single contract, so this is 1.0 and every
monetary cells is per policy. It is carried explicitly so that a model point standing
for a cohort can be scaled without touching a formula.
"""
return float(model_point()["pols_if_init"])
[docs]
def charge_basis():
"""This shape's row of ``data.charge_table()`` as a Series.
The load, the 위험보험료, the commission and the annuity-period charge are published by
shape, because the shapes buy different things: the life shape pays no death benefit
once the annuity has begun and therefore carries no risk premium at all, while the two
that keep a 10%-of-premium death benefit carry one.
"""
return data.charge_table().loc[shape()] # noqa: F821
[docs]
def acq_charge_rate():
"""The 계약체결비용 (*gyeyak chegyeol biyong*) as a share of the single premium.
Deducted **once**, at inception, on every retrieved carrier. 2.20% on the
representative basis. Because it is taken in full at t = 0 there is no unamortised
acquisition cost, which is why the 해약공제액 can be nil at every duration without the
insurer giving anything away.
"""
return float(charge_basis()["acq_charge_rate"])
[docs]
def admin_charge_rate():
"""The 계약관리비용 (*gyeyak gwalli biyong*) as a share of the single premium.
1.30% on the representative basis, and — like the acquisition charge — deducted once at
inception rather than levied over the life of the contract.
"""
return float(charge_basis()["admin_charge_rate"])
[docs]
def expense_load_rate():
"""c: the whole one-off expense load, 계약체결비용 plus 계약관리비용.
3.50% on the representative basis. A single number is carried across all three shapes
rather than the anchor carrier's 0.42-point allocation difference between them, because
a second and entirely independent carrier's published 확정기간연금형 figures are
reproduced within 1.4% across four terms on exactly this total.
"""
return acq_charge_rate() + admin_charge_rate()
[docs]
def risk_prem_rate():
"""b: the 위험보험료 (*wiheom boheomnyo*) as a share of the single premium.
Nil on the life shape, which pays no death benefit once the annuity has begun, and
1.47% on the two shapes that keep one. Deducted once at inception with the load, so it
reduces the opening 계약자적립액 and never appears as a projected cash flow: what it
buys appears instead as :func:`claims` on the ``DEATH`` kind.
"""
return float(charge_basis()["risk_prem_rate"])
[docs]
def comm_rate():
"""The 모집수수료율, the first-year commission as a share of the single premium.
2.00% at t = 0 and nil thereafter. The rate that matters structurally is not its level
but that it sits **below** the 계약체결비용: the acquisition charge taken from the fund
at inception covers the commission paid out of it at the same moment, so this model has
no acquisition strain and no deferred acquisition cost to amortise.
"""
return float(charge_basis()["comm_rate"])
[docs]
def acq_expense_rate():
"""The insurer's own acquisition and administration expense at t = 0, less commission.
**[std]** and derived: the 3.50% load less the 2.00% commission. Setting the expense
equal to the charge is the composite's treatment, not a carrier's disclosure, and it is
what makes :func:`check_premium_split` close: the single premium divides exactly into
commission, expense, 위험보험료 and the opening 계약자적립액, with nothing left over in
either direction.
"""
return float(charge_basis()["acq_expense_rate"])
[docs]
def annuity_charge_rate():
"""The 연금수령기간 중 비용: 0.80% of the 연금연액 each year in payment.
Disclosed in the cost table rather than the benefit table, so the composite models it
as an **insurer expense measured on the annuity** and does *not* net it off the
policyholder's payment. Whether a carrier's own 산출방법서 builds it into the annuity
factor instead is unverified: no filed basis document for an 즉시연금 discloses the
annuity formula.
"""
return float(charge_basis()["annuity_charge_rate"])
[docs]
def db_rate():
"""The 사망보험금 as a share of the single premium: nil, or 10%.
Nil on the life shape after annuitisation, where the unpaid guaranteed instalments are
the only thing that survives the annuitant. 10% of the single premium on the other two
shapes — the near-universal Korean design — payable **in addition to** the 계약자적립액
on the inheritance shape, and alone on the certain shape, whose remaining instalments
continue on their own dates.
"""
return float(charge_basis()["db_rate"])
[docs]
def decl_rate():
"""i_d: the 공시이율 (*gongsi iyul*), the declared crediting rate.
2.50% a year on the representative basis, reset on the first of each month and fixed
for that month. It is the **annual** rate, which is how a carrier declares it and how
every retrieved illustration quotes it; :func:`crediting_rate_mth` is the monthly
equivalent the fund actually rolls on. It is a **scalar** and not a derived quantity:
감독규정 제7-65조제3항
makes it the product of a 공시기준이율 and a 조정률, with the 공시기준이율 a weighted
average of an external index and the insurer's own 운용자산이익률 whose weighting the
two carriers that publish one publish differently. Any model that claims to derive a
Korean declared rate is not defensible; this one exposes it.
"""
tbl = data.crediting_table() # noqa: F821
rows = tbl[tbl["basis_id"] == crediting_basis()]
if len(rows) == 0:
raise ValueError("no crediting basis %s" % crediting_basis())
values = set(round(float(v), 12) for v in rows["decl_rate"])
if len(values) != 1:
raise ValueError("declared rate is not uniform within a basis")
return float(rows["decl_rate"].iloc[0])
[docs]
def min_guar_rate(t):
"""i_g(t): the 최저보증이율 (*choejeo bojeung iyul*) applying in period t.
Duration-stepped, 1.25% in policy years 1 to 5, 1.00% to year 10 and 0.75% thereafter
on the representative schedule. Each row of the table gives a half-open band
``[dur_from, dur_to)`` in completed **policy years**, and the table is left in years
because that is how the schedule is published: month ``t`` falls in the band containing
``t // 12``, so the floor steps on the 계약해당일 and is level across the twelve months
of a policy year. That a floor exists at all is not a commercial courtesy: 감독규정
제7-60조제10호 requires a 금리연동형보험 to set one. Note what it is **not**: a rate on
the fund, never a floor on the annuity, which is the substance of the whole 과소지급
dispute.
"""
tbl = data.crediting_table() # noqa: F821
y = t // 12
for _, row in tbl.iterrows():
if (str(row["basis_id"]) == crediting_basis()
and int(row["dur_from"]) <= y < int(row["dur_to"])):
return float(row["min_guar_rate"])
raise ValueError("no 최저보증이율 band for basis %s at t = %s"
% (crediting_basis(), t))
[docs]
def crediting_rate(t):
"""i(t) = Max[공시이율, 최저보증이율]: the rate credited to the fund in period t.
The 약관's own rule, and the one the supervisor restated when it explained the disputed
product: 「보험료에 일정한 이율을 곱하여 산출한 금액 … Max [공시이율, 최저보증이율]」.
On the representative basis the declared rate is above the floor at every duration, so
the rate is level and the floor is inert; on the ``min_guar`` basis it is the floor that
binds, and it steps down at five and ten years — months 60 and 120 on this grid. It is
the **annual** rate; the fund rolls on :func:`crediting_rate_mth`.
"""
return max(decl_rate(), min_guar_rate(t))
[docs]
def crediting_rate_mth(t):
"""j(t) = (1 + i(t))^(1/12) - 1: the monthly rate the fund is actually credited with.
The uniform-force conversion of the annual credited rate, so twelve months compound back
to exactly the year's figure and the fund at every 계약해당일 is the annual-step model's.
The conversion is the model's, not the contract's: a Korean carrier declares an annual
공시이율 and accrues 「일자계산」 beneath it, and this is the monthly reading of that
annual rate.
"""
return (1.0 + crediting_rate(t)) ** (1.0 / 12.0) - 1.0
[docs]
def mort_rate(t):
"""q(x + t): the 개인연금사망률 at the attained 보험나이 in period t.
Read from the shipped **[std]** annuitant table by ``(sex, age)``. It is used two ways
and the difference matters: by :func:`annuity_factor` as the **pricing basis** of the
종신연금형, which is the only shape whose annuity uses mortality at all, and by
:func:`lives_if` as the **decrement** on every shape, because the inheritance and
certain shapes both pay a death benefit even though neither prices one into its
annuity.
It is the **annual** rate the table publishes, level across the twelve months of a
policy year and stepping on each 계약해당일; :func:`mort_rate_mth` is what the monthly
grid applies.
"""
return float(data.mort_table().loc[(sex(), age(t)), "mort_rate"]) # noqa: F821
[docs]
def mort_rate_mth(t):
"""q^m: the monthly mortality rate applied in month t.
``1 - (1 - q)^(1/12)`` on the annual rate at the attained 보험나이, the uniform-force
conversion: twelve monthly decrements compound to exactly the year's ``q``, so the
annual table is refined onto the grid rather than restated on a monthly base no Korean
basis publishes. The monthly force is **larger** than ``q / 12``, which is the
convexity a naive division would give away.
At the limiting age the table's ``q`` is 1, which cannot be converted that way: the
certain death is instead spread uniformly over the twelve months of that policy year,
``1 / (12 - t mod 12)``, so the last of the in-force leaves in the final month of the
frame rather than all at once on the 계약해당일.
"""
q = mort_rate(t)
if q >= 1.0:
return 1.0 / (12 - t % 12)
return 1.0 - (1.0 - q) ** (1.0 / 12.0)
[docs]
def risk_prem_pp():
"""B: the 보장계약 보험료, the part of the single premium buying the death benefit.
Deducted once at inception. It is the ``B`` of the determination's own division of the
premium — 단일 보험료 A = 보장계약 보험료 B + 사업비 C + 연금계약 순보험료 D — and it
never becomes a projected cash flow; the benefit it buys does.
"""
return prem_pp() * risk_prem_rate()
[docs]
def av_pp_init():
"""V(0): the opening 계약자적립액, the residue D of the premium split.
``P x (1 - c - b)``: 96.50% of the single premium on the life shape, which carries no
risk premium, and 95.03% on the two shapes that keep a death benefit. The 약관 states
the identity in words — 「연금계약적립액이란 … 연금계약순보험료(사망보장이 있는 경우
납입하신 보험료중 보장을 위한 보험료 및 예정사업비를 차감한 금액)를 공시이율로 … 적립한
금액」 — and :func:`check_premium_split` asserts it against the cash flows.
"""
return prem_pp() * (1.0 - expense_load_rate() - risk_prem_rate())
[docs]
def maturity_benefit():
"""M: the 만기보험금, payable on survival to the end of the 보험기간.
**The gross single premium**, on the inheritance shape alone — 「만기보험금 : 납입
보험료 총액」. Nil on the other two shapes: the life shape has no maturity at all, and
the certain shape's fund is exhausted by its own instalments. That ``M`` exceeds
``V(0)`` by the whole first-day deduction is the entire mechanic of the retention, and
of the dispute about it.
"""
return prem_pp() if shape() == "inheritance" else 0.0
[docs]
def annuity_factor_certain(m, i):
"""a(m, i): the present value of ₩1 a period in arrears for m periods at rate i.
The 확정기간연금형's whole pricing basis, and the annuity part of the inheritance
shape's prospective value. On the monthly grid every caller passes a **month** count
and a **monthly** rate, so the factor is a monthly annuity-certain and the instalment it
produces is the 연금월액. Returns 0 for a non-positive term, which is what makes the
fund close to zero at the end of the certain shape's last period.
"""
if m <= 0:
return 0.0
if i == 0.0:
return float(m)
return (1.0 - (1.0 + i) ** (-m)) / i
[docs]
def accum_factor(m, i):
"""s(m, i): the accumulated value of ₩1 a period in arrears over m periods at rate i.
The denominator of the 만기보험금 지급재원, called with a **month** count and a
**monthly** rate. Because ``s`` **shrinks** when the rate falls, the retention *rises*
as the interest it is deducted from falls — which is why an annuity on the inheritance
shape can halve while the guaranteed floor never moves.
"""
if m <= 0:
return 0.0
if i == 0.0:
return float(m)
return ((1.0 + i) ** m - 1.0) / i
[docs]
def annuity_factor():
"""ae(x, g, i): the 종신연금형 annuity factor, struck once at commencement.
The present value of ₩1 a **month** in arrears payable while the annuitant aged x lives
**or the 보증지급기간 of g years runs, whichever is longer**, discounted at the monthly
crediting rate at inception and decremented on the monthly conversion of the
개인연금사망률. It is a ``max`` over the two and not a sum: within the guarantee the
instalment is due whether or not the annuitant lives, and an additive construction would
pay for both. Because the grid is monthly the factor is a monthly annuity-due-in-arrears
of about twelve times the annual one, and the instalment it produces is the 연금월액 the
contract actually pays — no ``(f − 1)/(2f)`` frequency correction is needed, because the
frequency is the grid.
No carrier publishes a factor and no filed 산출방법서 for an 즉시연금 was retrieved, so
every annuity factor in this library is computed by the model from a **[std]** table.
The sum runs over exactly the periods the projection carries, so the pricing and the
projection cannot drift apart; :func:`check_annuity_basis` asserts that they have not.
Defined on the life shape alone, and raises elsewhere: 「확정형과 상속형은 사망률을
사용하지 않는다」.
"""
if shape() != "life":
raise ValueError("only 종신연금형 converts the fund through an annuity factor")
j = crediting_rate_mth(0)
v = 1.0 / (1.0 + j)
return sum(v ** (t + 1) * pricing_factor(t)
for t in range(proj_len()))
[docs]
def retention_pp(t):
"""R(t): the 만기보험금 지급재원 retained out of period t's interest.
``(M - V(t)) / s(m, j(t))`` on the inheritance shape under ``as_designed``, with
``m = 12n - t`` the remaining term **in months** and ``j`` the monthly crediting rate,
recomputed each month so that the fund still reaches the 만기보험금 exactly at maturity
however the rate has moved. Zero under ``as_ordered``, and zero on the other two
shapes, neither of which has a maturity benefit to fund.
This is the term that was written into the 산출방법서 and not into the 약관, and the
whole of 조정결정 제2017-17호 is about whether it is part of the contract.
"""
if shape() != "inheritance" or retention_basis() == "as_ordered":
return 0.0
return ((maturity_benefit() - av_pp(t))
/ accum_factor(annuity_term_mths() - t, crediting_rate_mth(t)))
[docs]
def annuity_pp(t):
"""A(t): the 연금월액 payable at the end of month t, per policy and before decrement.
Three shapes, three constructions, all of them on the monthly grid:
* **life** — ``V(0) / ae(x, g, j)``, struck once at commencement and level thereafter.
The 약관 bases it on 「연금개시시의 계약자적립액」, the fund *at commencement*, and the
annuitant-mortality ratchet is inert on an immediate annuity because there is no
interval between issue and annuitisation for a table revision to land in.
* **inheritance** — ``V(t) j(t) - R(t)``, a month's interest on the fund less the
retention, recomputed every month because the annuity moves whenever the declared
rate does.
* **certain** — ``V(t) / a(m, j(t))``, the fund divided over the remaining term in
months, again recomputed as the rate moves.
This is the **연금월액**, which is what the contract actually pays — 「미지급된 연금월액을
매월 연금지급일에 드립니다」. The 연금연액 a Korean illustration quotes is
:func:`annuity_pp_annual`, twelve times this figure; the monthly grid pays the monthly
amount and needs no sub-annual correction to value it.
"""
if shape() == "life":
return av_pp_init() / annuity_factor()
j = crediting_rate_mth(t)
if shape() == "certain":
return av_pp(t) / annuity_factor_certain(annuity_term_mths() - t, j)
return av_pp(t) * j - retention_pp(t)
[docs]
def annuity_pp_annual(t):
"""The 연금연액: twelve times the 연금월액 payable in month t.
Published because every Korean illustration quotes the annual figure and because the
0.80% 연금수령기간 중 비용 is disclosed on it, but it is **not** a cash flow: the grid
pays :func:`annuity_pp` a row, and twelve of those make this.
"""
return 12.0 * annuity_pp(t)
[docs]
def av_pp(t):
"""V(t): the 계약자적립액 at time t, before period t's crediting.
The 약관's own recursion a month at a time, ``V(t + 1) = V(t) (1 + j(t)) - A(t)`` —
「연금개시후에는 생존연금 발생분을 차감한 금액」 — opening at :func:`av_pp_init`.
It reaches ``M`` exactly at maturity on the inheritance shape under ``as_designed``,
stands still at ``V(0)`` under ``as_ordered``, and exhausts to zero at the end of the
term on the certain shape. **On the life shape it is none of those things**: a life
annuity's fund is not its reserve, and the recursion runs negative at about the point
where the annuitant has outlived the factor the fund bought. Surrender is prohibited
there, :func:`cv_pp` is nil, and nothing downstream of this cells reads the negative
value; it is published because the recursion is the contract's, and suppressing it
would hide what a 종신연금형 actually does with the money.
"""
if t == 0:
return av_pp_init()
return av_pp(t - 1) * (1.0 + crediting_rate_mth(t - 1)) - annuity_pp(t - 1)
[docs]
def surr_chg_pp(t):
"""The 해약공제액 (*haeyak gongjeaek*), the surrender deduction: **nil at every t**.
Published as a complete run of zeros by the anchor carrier — 「해지공제금액(만원) 0 … 0
/ 해지공제비율 0.0% … 0.0%」 — and independently confirmed by the same carrier's rate
disclosure. The reason is structural rather than generous: a single-premium annuity has
no unamortised acquisition cost to recover, the cost having been taken in full at
inception. The statutory cap of 별표 14's 표준해약공제액 therefore binds nothing here,
and the cells exists to say that the zero was observed rather than assumed.
"""
return 0.0
[docs]
def cv_pp(t):
"""CV(t): the 해약환급금, the 계약자적립액 less the 해약공제액, floored at zero.
Nil at every duration on the life shape, where surrender is contractually impossible
once the annuity is in payment and on an immediate annuity that is from month one.
Equal to the fund on the other two shapes, because the deduction is nil. The
위법계약의 해지 route returns the 계약자적립액 rather than the surrender value, and on a
product with no deduction the two coincide, so no separate cells is carried for it.
"""
if shape() == "life":
return 0.0
return max(av_pp(t) - surr_chg_pp(t), 0.0)
[docs]
def lives_if(t):
"""l(t): the probability the annuitant is alive at time t.
Mortality alone, on the 개인연금사망률, opening at :func:`pols_if_init`. It is **not**
the payment weight and it is not :func:`pols_if`: within the 보증지급기간 the
instalments are due whether or not the annuitant lives, and on the certain shape they
are due for the whole term. It drives the death benefit on the two shapes that carry
one and the tail of the life annuity on the shape that does not.
"""
if t == 0:
return pols_if_init()
return lives_if(t - 1) * (1.0 - mort_rate_mth(t - 1))
[docs]
def surr_if(t):
"""The probability the contract has not been surrendered by time t.
Identically one on the life shape, where surrender is impossible. On the other two it
is the running product of ``(1 - w^m)``, with the rate nil in the final policy year so
that a contract in its last year runs to its maturity benefit or its last instalment.
"""
if t == 0:
return pols_if_init()
return surr_if(t - 1) * (1.0 - lapse_rate_mth(t - 1))
[docs]
def pols_if(t):
"""IF(t): the probability that a **payment obligation remains** at time t.
This is *not* a policy count and it is not a survival probability, and the difference is
a product fact rather than a modelling choice. On the **life** shape the instalments
inside the 보증지급기간 are due whether or not the annuitant lives — 「보증지급기간안에
사망시에는 잔여보증지급기간 동안, 미지급된 연금월액을 … 드립니다」 — so the obligation
is the *greater* of the survival probability and the indicator that the guarantee is
still running. On the **certain** shape it is one until the term ends or the contract
is surrendered, death not accelerating it. On the **inheritance** shape it is survival
and persistency together, because death itself triggers a payment and ends the contract.
The name is lifelib's and is kept because it is what the rest of the library weights
expense by and what ``result_cf()`` publishes first; the meaning is the technical notes'
``IF(t)``.
"""
if shape() == "life":
guaranteed = pols_if_init() if t < annuity_term_mths() else 0.0
return max(lives_if(t), guaranteed)
if shape() == "certain":
return surr_if(t)
return lives_if(t) * surr_if(t)
[docs]
def pols_death(t):
"""d(t): deaths during period t, among contracts still in force at time t.
Deaths are placed at the **end** of the policy month, after the month's crediting and
after the annuity due to the survivors, so the 사망보험금 is paid on the fund carried
forward. That is a **[std]** convention, and on a monthly grid a much cheaper one than
it was on an annual grid: the unmodelled lag between the date of death and the period
end is now at most a month, not at most a year.
"""
return lives_if(t) * surr_if(t) * mort_rate_mth(t)
[docs]
def pols_lapse(t):
"""Surrenders during period t, taken at the end of it and after the deaths.
Nil on the life shape, where surrender is contractually impossible. On the certain
shape the contract survives the annuitant, so the decrement bites on the persistency
measure alone; on the inheritance shape only a contract that has not become a death
claim can be surrendered, so the deaths of the period come off first.
"""
if shape() == "life":
return 0.0
if shape() == "certain":
return surr_if(t) * lapse_rate_mth(t)
return (lives_if(t) * (1.0 - mort_rate_mth(t)) * surr_if(t)
* lapse_rate_mth(t))
[docs]
def pols_exit(t):
"""The obligations ending during period t, however they end.
Built independently of :func:`pols_if` so that the two can be compared, which is what
:func:`check_pols_roll_fwd` does. On the certain and inheritance shapes it is the
decrements themselves. On the **life** shape it is neither, and the difference is the
guarantee: a death inside the 보증지급기간 does not end the obligation, so nothing exits
at all until the guarantee expires, and then everyone who died inside it exits at once.
That step is the shape's characteristic feature and an implementation that decremented
the obligation on death would smooth it away.
"""
if shape() == "life":
g = annuity_term_mths()
if t < g - 1:
return 0.0
if t == g - 1:
return pols_if_init() - lives_if(g)
return pols_death(t)
if shape() == "certain":
return pols_lapse(t)
return pols_death(t) + pols_lapse(t)
[docs]
def payment_factor(t):
"""F(t): the weight on the 생존연금 payable at the end of period t.
On the **life** shape ``max(l(t + 1), 1{t + 1 <= g})``: the instalment is paid if the
annuitant is alive at the payment date **or** the guarantee is still running. The
``max`` is what makes the 보증지급기간 a floor on the obligation rather than a second
stream; an additive form would pay ``1 + l(t + 1)`` for the whole guaranteed term.
On the **certain** shape it is the persistency measure alone, survival being irrelevant
— 「가입자의 생존여부에 관계없이 연금급여를 지급한다」. On the **inheritance** shape it
is in-force survival to the payment date, the 생존연금 being payable 「살아있을 때」,
with the death benefit taking the place of the payment for those who die.
"""
if shape() == "life":
guaranteed = pols_if_init() if t + 1 <= annuity_term_mths() else 0.0
return max(lives_if(t + 1), guaranteed)
if shape() == "certain":
return surr_if(t)
return pols_if(t) * (1.0 - mort_rate_mth(t))
[docs]
def pricing_factor(t):
"""The weight the **pricing** basis puts on the payment at the end of period t.
The same construction as :func:`payment_factor` on the life shape, because the pricing
basis and the projection run on one mortality table here and there is no lapse
assumption to separate them; and one on the other two shapes, whose annuities carry no
mortality at all. The two are written separately rather than one calling the other, so
that :func:`check_annuity_basis` compares two constructions instead of one with itself.
"""
if shape() == "life":
guaranteed = 1.0 if t + 1 <= annuity_term_mths() else 0.0
return max(lives_if(t + 1) / pols_if_init(), guaranteed)
return 1.0
[docs]
def disc_factor(t):
"""The present value at inception of ₩1 at time t, on the crediting-rate path.
Discounting at Max[공시이율, 최저보증이율] period by period, so that a stepping floor is
handled without assuming a level rate. This is **not** a valuation rate and this model
computes no discounted result: it exists for the pricing identity
:func:`check_annuity_basis` and for :func:`retention_shortfall_pp`, both of which are
statements about the contract's own basis rather than about value. Every
``technical-notes.md`` in this library specifies *gross* liability cash flows and leaves
discounting, the 책임준비금, the IFRS 17 CSM and the K-ICS 요구자본 to a layer that
consumes them.
"""
if t <= 0:
return 1.0
return disc_factor(t - 1) / (1.0 + crediting_rate_mth(t - 1))
[docs]
def retention_shortfall_pp():
"""What the 조정결정's liability costs the insurer, at inception, per policy.
``(M - V(0)) x v(n)`` on the inheritance shape under ``as_ordered``, and zero
everywhere else. Under ``as_designed`` the contract funds its own 만기보험금 out of the
retention and the shortfall is nil; under ``as_ordered`` the annuity is interest on the
fund alone, the fund stands still at ``V(0)``, and the whole first-day deduction has to
be found again at maturity from the insurer's own resources.
Model points 6 and 7 are the same contract on the two bases, so the difference between
their cash flow statements is the quantity that was litigated from 2017 to 2025.
"""
if shape() == "inheritance" and retention_basis() == "as_ordered":
return ((maturity_benefit() - av_pp_init())
* disc_factor(annuity_term_mths()))
return 0.0
[docs]
def proj_years():
"""The projection horizon in **whole policy years**.
On the inheritance and certain shapes the contract ends at a stated term, so the horizon
is that term. On the life shape the projection runs to the limiting age of the shipped
table, at which ``qx`` is 1, so the obligation is exhausted rather than truncated:
``ω - x + 1`` years. Where the 보증지급기간 outlives the annuitant's limiting age —
which it cannot on any shipped model point but can at a high enough issue age — the
guarantee sets the horizon instead.
"""
if shape() == "life":
return max(annuity_term(), omega_age - age_at_entry() + 1) # noqa: F821
return annuity_term()
[docs]
def proj_len():
"""N: the **number of projected months**, the frame's exclusive end.
``12 x proj_years()``. ``result_cf()`` runs ``t = 0 .. proj_len() - 1`` and the frame is
``range(proj_len())``, so the last projected month is ``proj_len() - 1`` and
``len(result_cf())`` is ``proj_len()`` itself — 612 rows, ``t = 0`` to ``t = 611``, on
the worked-example anchor.
On the inheritance and certain shapes the last row carries the payment falling at time
``12n`` with the 만기보험금 beside it where there is one. On the life shape the last row
is the final month of the limiting age's policy year, where the monthly conversion of
``qx = 1`` pays out the last of the in-force.
"""
return 12 * proj_years()
[docs]
def premiums(t):
"""E[PREM(t)]: the 일시납보험료, income at t = 0 and nothing thereafter.
The single premium is genuine income to the insurer and is projected as such, which is
what makes the absence of acquisition strain visible in the statement rather than only
in prose: at t = 0 the premium less the commission and the acquisition expense is
exactly the opening 계약자적립액 plus the 위험보험료 retained against the death benefit.
There is no renewal premium, no 추가납입 and no premium term.
"""
return prem_pp() * pols_if_init() if t == 0 else 0.0
[docs]
def annuity_payments(t):
"""E[ANN(t)]: the expected 생존연금 outgo at the end of period t.
The 연금연액 weighted by :func:`payment_factor`. On the life shape the weight is one
for every payment inside the 보증지급기간 whether or not the annuitant lives, which is
why the first ten rows of the anchor model point carry the full annuity.
"""
return annuity_pp(t) * payment_factor(t)
[docs]
def claims(t, kind):
"""E[CLAIM(t, kind)]: the expected benefit outgo of period t of one kind.
``"DEATH"``
The 사망보험금. Nil on the life shape, which pays none once the annuity has begun —
the unpaid guaranteed instalments are what survives the annuitant, and they are in
:func:`annuity_payments`. On the inheritance shape it is 10% of the single premium
**plus the 계약자적립액 at death**, the fund being the one carried forward at the end
of the period. On the certain shape it is the 10% alone: the remaining instalments
fall due on their own dates and are already in the annuity stream.
``"LAPSE"``
The 해약환급금 paid on voluntary surrender, at the fund carried forward because the
deduction is nil. Nil on the life shape, where surrender is impossible.
``"MATURITY"``
The 만기보험금, on the inheritance shape at the end of its last period, weighted by
the probability of reaching it alive and in force.
The cells stays and the ``claims`` **column** does not: a cash flow statement must not
publish its own subtotal beside its parts, so ``result_cf()`` carries the three kinds
split out and they sum, with the annuity and the outgo above, to ``net_cf``.
"""
if kind == "DEATH":
if shape() == "life":
return 0.0
if shape() == "inheritance":
return pols_death(t) * (db_rate() * prem_pp() + av_pp(t + 1))
return pols_death(t) * db_rate() * prem_pp()
if kind == "LAPSE":
return pols_lapse(t) * cv_pp(t + 1)
if kind == "MATURITY":
if shape() == "inheritance" and t == proj_len() - 1:
return pols_if(t + 1) * maturity_benefit()
return 0.0
raise ValueError("unknown claim kind: %s" % kind)
[docs]
def claims_death(t):
"""E[DTH(t)]: :func:`claims` on the ``DEATH`` kind, as a statement column."""
return claims(t, "DEATH")
[docs]
def claims_lapse(t):
"""E[SUR(t)]: :func:`claims` on the ``LAPSE`` kind, as a statement column.
Named for the decrement rather than for the 해약환급금, because ``claims_surr`` was
retired across the library in favour of the name matching the ``kind`` argument that
produces it.
"""
return claims(t, "LAPSE")
[docs]
def claims_maturity(t):
"""E[MAT(t)]: :func:`claims` on the ``MATURITY`` kind, as a statement column."""
return claims(t, "MATURITY")
[docs]
def commissions(t):
"""E[COM(t)]: the 모집수수료 paid at inception and nil in every later year.
2.00% of the single premium at t = 0. Every retrieved figure for this product is a
first-year-only rate on a bancassurance sale, which is what the very low level is
consistent with and with nothing else in Korean retail life insurance.
"""
return prem_pp() * comm_rate() * pols_if_init() if t == 0 else 0.0
[docs]
def expenses(t):
"""E[EXP(t)]: the insurer's own expense outgo in period t.
Two components and they do not overlap. At t = 0 the acquisition and administration
expense, taken as the load less the commission so that the charge deducted from the
fund exactly meets the outgo. In every month including the first, the
연금수령기간 중 비용 of 0.80% of the 연금연액, incurred when a payment is made and
therefore carried at the payment's own weight — on the monthly grid that is 0.80% of the
**연금월액** each month, which over twelve months is exactly the 0.80% of the 연금연액
the cost table discloses.
There is no maintenance expense per policy and no expense inflation in this model. The
one recurring charge any retrieved 즉시연금 document publishes is measured on the
annuity, not per policy and not per 만원 of fund, and inventing a per-policy expense
beside it would be a number with no source at all.
"""
charge = annuity_charge_rate() * annuity_pp(t) * payment_factor(t)
if t == 0:
return charge + prem_pp() * acq_expense_rate() * pols_if_init()
return charge
[docs]
def net_cf(t):
"""Net cash flow of period t, **income positive**: premium less every outgo.
The library-wide orientation, so that a ``result_cf()["net_cf"]`` column can be summed
or compared across every model here without checking which product it came from. The
technical notes' own ``CF(t)`` is outgo-positive and is published verbatim as
:func:`liability_cf`, which is this cells' exact negative.
"""
return (premiums(t)
- annuity_payments(t)
- claims(t, "DEATH")
- claims(t, "LAPSE")
- claims(t, "MATURITY")
- commissions(t)
- expenses(t))
[docs]
def liability_cf(t):
"""CF(t): total gross liability **outgo** in period t, the technical notes' orientation.
The exact negative of :func:`net_cf`. Both are published as columns rather than one
being made to stand for the other, so that a reader holding the notes beside the model
reads the same sign in both.
"""
return -net_cf(t)
[docs]
def check_net_cf_resid(t):
"""The ledger residual of row t: the statement's own parts, less ``net_cf``.
Rebuilt from ``result_cf()``'s published columns rather than from the formulas, so that
a component missing from the statement fails here rather than being reconciled only in
prose. The identity is the product's: premium income at inception, less the 생존연금,
the 사망보험금, the 해약환급금, the 만기보험금, the 모집수수료 and the expenses. There
is no premium term to reconcile and no acquisition cost to amortise, which is what makes
this the shortest ledger in the library.
"""
row = result_cf().loc[t]
built = row["premiums"] - (row["annuity_payments"]
+ row["claims_death"]
+ row["claims_lapse"]
+ row["claims_maturity"]
+ row["commissions"]
+ row["expenses"])
return built - row["net_cf"]
[docs]
def check_net_cf():
"""Whether the cash flow statement reconciles to ``net_cf`` at every projected period."""
tol = val_tol * prem_pp() # noqa: F821
return bool(all(abs(check_net_cf_resid(t)) < tol
for t in range(proj_len())))
[docs]
def check_pols_roll_fwd_resid(t):
"""``IF(t) - exits(t) - IF(t + 1)``: the obligation roll-forward residual.
:func:`pols_exit` is built from the decrements and the guarantee rather than from
:func:`pols_if`, so the two are independent constructions. On the life shape the check
has real content: an implementation that decremented the obligation on every death
would show a residual for every period inside the 보증지급기간, because a death there
does not end the obligation, and would then miss the step at the end of it.
"""
return pols_if(t) - pols_exit(t) - pols_if(t + 1)
[docs]
def check_pols_roll_fwd():
"""Whether the payment obligation rolls forward on its own decrements at every t."""
return bool(all(abs(check_pols_roll_fwd_resid(t)) < roll_fwd_tol # noqa: F821
for t in range(proj_len())))
[docs]
def check_lives_roll_fwd_resid(t):
"""``prod(1 - q) - l(t)``: the survival curve against a direct product.
:func:`lives_if` is a one-step recursion; this rebuilds the same probability as an
explicit product of ``(1 - q)`` over the attained ages, with no reference to the
recursion. An off-by-one in the age indexing — reading ``q`` at the attained age at the
*end* of the period rather than the start — shows up here from the first period.
"""
built = pols_if_init()
for s in range(0, t):
built *= (1.0 - mort_rate_mth(s))
return built - lives_if(t)
[docs]
def check_lives_roll_fwd():
"""Whether the annuitant's survival curve closes against a direct product at every t."""
return bool(all(abs(check_lives_roll_fwd_resid(t)) < roll_fwd_tol # noqa: F821
for t in range(proj_len() + 1)))
[docs]
def check_av_roll_fwd_resid(t):
"""The 계약자적립액 against its per-shape closed form, one step back.
Each branch is a genuinely different derivation of the same fund and not the recursion
written twice:
* **inheritance, as designed** — ``V(t) = V(t-1) + (M - V(t-1)) / s(m, i)``, the
algebraic reduction of ``V(1 + i) - A`` once the retention is substituted, which is
the form the fund's convergence to ``M`` is visible in;
* **inheritance, as ordered** — ``V(t) = V(t-1)``, the fund standing still because the
annuity is exactly the interest;
* **certain** — ``V(t) = V(t-1) a(m-1, i) / a(m, i)``, the annuity-certain's own
run-off, which exhausts to zero at the last period without being told to;
* **life** — ``V(0) (1 + i)^t - A s(t, i)``, the retrospective closed form at the level
rate :func:`check_rate_level` guarantees the shape carries.
"""
if t == 0:
return av_pp_init() - av_pp(0)
j = crediting_rate_mth(t - 1)
if shape() == "life":
j0 = crediting_rate_mth(0)
built = (av_pp_init() * (1.0 + j0) ** t
- annuity_pp(0) * accum_factor(t, j0))
elif shape() == "certain":
m = annuity_term_mths() - (t - 1)
built = (av_pp(t - 1) * annuity_factor_certain(m - 1, j)
/ annuity_factor_certain(m, j))
elif retention_basis() == "as_ordered":
built = av_pp(t - 1)
else:
m = annuity_term_mths() - (t - 1)
built = av_pp(t - 1) + ((maturity_benefit() - av_pp(t - 1))
/ accum_factor(m, j))
return built - av_pp(t)
[docs]
def check_av_roll_fwd():
"""Whether the 계약자적립액 recursion closes against its closed form at every t."""
tol = val_tol * prem_pp() # noqa: F821
return bool(all(abs(check_av_roll_fwd_resid(t)) < tol
for t in range(proj_len() + 1)))
[docs]
def check_av_terminal():
"""Whether the fund ends where the contract says it must.
Zero at the end of the 연금지급기간 on the certain shape, because the instalments have
exhausted it; the 만기보험금 on the inheritance shape under ``as_designed``, because the
retention was sized to rebuild it; and ``V(0)`` unchanged under ``as_ordered``, because
the annuity was exactly the interest and nothing was retained — which is the whole
reason the maturity benefit then has to come from somewhere else.
True on the life shape without a test, and deliberately: a life annuity's fund has no
contractual terminal value. The pricing identity :func:`check_annuity_basis` is what
holds that shape to its basis instead.
"""
tol = val_tol * prem_pp() # noqa: F821
v = av_pp(proj_len())
if shape() == "certain":
return bool(abs(v) < tol)
if shape() == "inheritance":
target = (maturity_benefit() if retention_basis() == "as_designed"
else av_pp_init())
return bool(abs(v - target) < tol)
return True
[docs]
def check_annuity_basis_resid():
"""The opening fund against the present value of everything it was struck to buy.
``sum A(t) x pricing_factor(t) x v(t + 1)``, plus the discounted 만기보험금 on the
inheritance shape, less ``V(0)`` and less :func:`retention_shortfall_pp`. Discounting
runs on the crediting-rate path, so a stepping floor is handled without assuming a level
rate.
It ties the projection to the pricing across all three shapes at once. Under
``as_ordered`` the identity does **not** close on ``V(0)`` alone, and it should not: the
excess is precisely the shortfall the determination left the insurer to fund, which is
why that term appears on the right-hand side rather than being tolerated away.
"""
built = sum(annuity_pp(t) * pricing_factor(t) * disc_factor(t + 1)
for t in range(proj_len()))
if shape() == "inheritance":
built += maturity_benefit() * disc_factor(annuity_term_mths())
return built - av_pp_init() - retention_shortfall_pp()
[docs]
def check_annuity_basis():
"""Whether the annuity the model projects is the annuity the fund actually bought."""
return bool(abs(check_annuity_basis_resid()) < val_tol * prem_pp()) # noqa: F821
[docs]
def check_premium_split():
"""Whether the single premium divides exactly as the 약관 says it does.
``A = B + C + D``: the 보장계약 보험료, the 사업비 and the 연금계약 순보험료 that becomes
the opening 계약자적립액, with the 사업비 split here into the commission actually paid
out and the expense actually incurred. Nothing is left over in either direction, which
is the statement that this product has **no acquisition strain**: the charge taken from
the fund at inception is exactly the outgo at inception.
"""
built = (prem_pp() * comm_rate()
+ prem_pp() * acq_expense_rate()
+ risk_prem_pp()
+ av_pp_init())
return bool(abs(prem_pp() - built) < val_tol * prem_pp()) # noqa: F821
[docs]
def check_rate_level():
"""Whether the crediting rate is level wherever the model relies on it being so.
Required on the **life** shape and asserted there. The 종신연금형 factor is struck once
at commencement — 「연금개시시의 계약자적립액을 기준으로 … 산출」 — and this model does
not recompute it, so a life-shape model point whose floor stepped above the declared
rate part-way through would be projected on a basis the model never priced. The
representative declared rate of 2.50% is above every step of the floor, so the condition
holds on every shipped life-shape point; a model point that broke it would fail here
rather than silently.
True on the other two shapes, whose annuities are recomputed every year and which
therefore carry a stepping rate correctly; model point 8 is the one that does.
"""
if shape() != "life":
return True
i0 = crediting_rate(0)
return bool(all(abs(crediting_rate(t) - i0) < 1e-15
for t in range(proj_len())))
[docs]
def check_guarantee_certain():
"""Whether every payment inside the 보증지급기간 is weighted at exactly one.
「종신연금형의 경우 연금지급 개시 후 보증지급기간안에 사망시에는 잔여보증지급기간 동안,
미지급된 연금월액을 매월 연금지급일에 드립니다」. The guaranteed instalments are due
whether or not the annuitant lives, so their weight is the whole obligation and not a
survival probability, and this is the assertion that the ``max`` in
:func:`payment_factor` is doing its job rather than being shadowed by a product.
True on the other two shapes, neither of which has a 보증지급기간: the certain shape's
whole term is certain and the inheritance shape's annuity is conditional on survival
throughout.
"""
if shape() != "life":
return True
return bool(all(abs(payment_factor(t) - pols_if_init()) < roll_fwd_tol # noqa: F821
for t in range(0, annuity_term_mths())))
[docs]
def check_payment_factor_resid(t):
"""``F(t)`` against a second construction of the same weight, per shape.
On the life shape the ``max`` of the survival probability and the guarantee indicator;
on the certain shape the obligation itself, mortality being irrelevant to it; and on the
inheritance shape the obligation less the period's deaths, routed through
:func:`pols_death` rather than through the mortality rate directly. A shape that
started weighting its certain payments by survival, or its survival payments by the
guarantee, shows up here.
"""
if shape() == "life":
guaranteed = pols_if_init() if t + 1 <= annuity_term_mths() else 0.0
built = max(lives_if(t + 1), guaranteed)
elif shape() == "certain":
built = pols_if(t)
else:
built = pols_if(t) - pols_death(t)
return built - payment_factor(t)
[docs]
def check_payment_factor():
"""Whether the payment weight matches its second construction at every period."""
return bool(all(abs(check_payment_factor_resid(t)) < roll_fwd_tol # noqa: F821
for t in range(proj_len())))
[docs]
def check_surr_value():
"""Whether the surrender machinery matches the contract on each shape.
On the **life** shape: the rate is nil at every duration and the 해약환급금 is nil at
every duration, because 「종신연금이 지급개시된 이후에는 해지할 수 없습니다」 and on an
immediate annuity that is from month one. A life-shape model point carrying a surrender
rate is a defect in the table, and this is where it fails.
On the other two shapes: the 해약환급금 is the 계약자적립액 exactly, because the
해약공제액 is nil at every duration on every retrieved carrier — the published run is a
run of zeros — and the statutory 표준해약공제액 cap therefore binds nothing here.
"""
tol = val_tol * prem_pp() # noqa: F821
if shape() == "life":
return bool(all(lapse_rate(t) == 0.0 and cv_pp(t) == 0.0
for t in range(proj_len())))
return bool(all(abs(cv_pp(t) - max(av_pp(t), 0.0)) < tol
for t in range(proj_len() + 1)))
[docs]
def result_cf():
"""Result table of cash flows, indexed by the 0-based period index t.
Row ``t`` carries period ``t``, which runs from time ``t`` to time ``t + 1``: the single
premium falls at time 0 on row 0, and the annuity shown on row ``t`` falls at time
``t + 1``, in arrears on the 계약해당일. The frame is ``range(proj_len())``, so the
table has ``proj_len()`` rows indexed ``0 .. proj_len() - 1``.
``pols_if`` is the probability that a **payment obligation remains**, which on this
product is not the probability that the annuitant is alive; see :func:`pols_if`. The
three ``claims_*`` columns are the split of :func:`claims` and there is deliberately no
``claims`` column beside them, so that the columns sum to ``net_cf``. Both signs of the
net flow are published: ``net_cf`` is income-positive, the library-wide convention, and
``liability_cf`` is the technical notes' outgo-positive ``CF(t)``.
"""
ts = list(range(proj_len()))
return pd.DataFrame( # noqa: F821
{
"pols_if": [pols_if(t) for t in ts],
"premiums": [premiums(t) for t in ts],
"annuity_payments": [annuity_payments(t) for t in ts],
"claims_death": [claims(t, "DEATH") for t in ts],
"claims_lapse": [claims(t, "LAPSE") for t in ts],
"claims_maturity": [claims(t, "MATURITY") for t in ts],
"commissions": [commissions(t) for t in ts],
"expenses": [expenses(t) for t in ts],
"liability_cf": [liability_cf(t) for t in ts],
"net_cf": [net_cf(t) for t in ts],
},
index=pd.Index(ts, name="t"), # noqa: F821
)
[docs]
def result_pols():
"""Result table of the fund, the annuity and the decrements, indexed by the same t.
The companion to :func:`result_cf`: everything the cash flow statement is built out of
and nothing that is a cash flow itself. ``av_pp`` and ``cv_pp`` are shown at the
**start** of the period, as ``pols_if`` is, so a row reads as the state the period opens
in and the flows that period produces.
"""
ts = list(range(proj_len()))
return pd.DataFrame( # noqa: F821
{
"pols_if": [pols_if(t) for t in ts],
"lives_if": [lives_if(t) for t in ts],
"surr_if": [surr_if(t) for t in ts],
"pols_death": [pols_death(t) for t in ts],
"pols_lapse": [pols_lapse(t) for t in ts],
"payment_factor": [payment_factor(t) for t in ts],
"crediting_rate": [crediting_rate(t) for t in ts],
"av_pp": [av_pp(t) for t in ts],
"cv_pp": [cv_pp(t) for t in ts],
"annuity_pp": [annuity_pp(t) for t in ts],
"retention_pp": [retention_pp(t) for t in ts],
},
index=pd.Index(ts, name="t"), # noqa: F821
)
# ---------------------------------------------------------------------------
# References
data = ("Interface", ("..", "Data"), "auto")
point_id = 1
omega_age = 110
roll_fwd_tol = 1e-10
val_tol = 1e-12
pd = ("Module", "pandas")