"""IFRS 17 Variable Fee Approach (VFA) -- direct-participation contracts.
The VFA is IFRS 17's measurement model for insurance contracts with direct
participation features -- contracts where the policyholder's benefit is a
share of a pool of *underlying items* (a fund). It is the model for
unit-linked and with-profits business.
This module measures a single-premium account-value contract: a premium is
paid into an account at issue; the account value grows at the underlying-
items return less a variable fee; the benefit is the account value on
surrender, max(account value, a guaranteed minimum death benefit) on death,
and max(account value, a guaranteed minimum accumulation benefit) at
maturity. The entity's profit is the *variable fee* it deducts, its share
of the underlying items.
Under the VFA the financial result flows through the CSM rather than profit
or loss, so the account-value cash flows are discounted, and the CSM is
accreted, at the underlying-items return -- not a locked-in rate.
fastcashflow is deterministic (a single scenario), so the VFA's hallmark --
the CSM absorbing the variability of the underlying items -- reduces here to
that return-rate accretion. A minimum guaranteed crediting rate is supported
-- the account is credited ``max(return, guarantee)`` each period. Its
intrinsic cost appears in this deterministic measurement; the time value of
the guarantee, the extra cost from return volatility, is measured
stochastically by ``measure_tvog``. A surrender charge (a fraction of the
account withheld on surrender, ``Basis.surrender_charge_annual``) is supported;
a lapse-risk adjustment is left for later. The risk adjustment here covers
expense risk -- the main non-financial risk an account-value contract carries,
with the policyholder bearing the investment risk.
"""
from __future__ import annotations
import math
from dataclasses import dataclass
import numpy as np
from fastcashflow._measurement.model import VFA
from fastcashflow._typing import FloatArray, IntArray
from fastcashflow._measurement.basis import _inforce_marker_columns
from fastcashflow.basis import Basis, _single_basis
from fastcashflow.io import (
write_measurement, _write_measurement_columns,
_stream_single_file, _vfa_model_points_from_frame)
from fastcashflow._numerics import (
_carry_lic_residual,
_csm_kernel,
_norm_ppf,
_settlement_factor,
_settlement_lic,
_settlement_lic_discounted,
)
from fastcashflow.model_points import ModelPoints, NO_GUARANTEE_RATE
from fastcashflow.projection import Cashflows, project_cashflows
from fastcashflow.multistate import resolve_model
from fastcashflow._measurement.tvog import (
guarantee_floor_time_value, ul_guarantee_floor_time_value,
measure_tvog,
tvog_weights, tvog_term_weight,
_validate_return_scenarios,
credited_monthly_rate,
)
# In-force helpers shared with the GMM path (engine does not import _vfa, and io
# imports engine lazily, so this top-level import is cycle-free).
from fastcashflow._measurement.inforce import _inforce_rescale, _reconcile_state
# Result types and the CSM-basis vocabulary live in the sibling results module
# (this module is the engine that produces them).
from fastcashflow._measurement.vfa.results import (
Aggregate, CSM_BASIS_CARRY_ONLY, TVOG, Measurement,
SettlementAggregate, SettlementMovement, _VFA_SETTLEMENT_LINES)
def moneyness_lapse_multiplier(moneyness, sensitivity, *, floor: float = 0.0,
cap=None):
"""Dynamic-lapse multiplier as a function of account-value moneyness.
``moneyness`` is the account value divided by the guarantee (the guaranteed
floor / GMAB): ``> 1`` means the guarantee is out-of-the-money (the account
already exceeds it), ``< 1`` means it is in-the-money (the floor is valuable).
The fixed linear form ``1 + sensitivity * (moneyness - 1)`` LIFTS lapse when the
guarantee is worthless (policyholders surrender to chase the account) and LOWERS
it when the floor bites (they hold the valuable guarantee), clamped to
``[floor, cap]`` (``cap=None`` is no upper bound). At-the-money (``moneyness ==
1``) the multiplier is 1 (no adjustment).
``sensitivity`` is the injected behavioural elasticity -- the seam; the FORM is
fixed, so a moneyness PATH (the account value is exogenous to lapse, a per-policy
level) resolves to a per-period multiplier array up front, with no per-step
callback. This is the account-value counterpart to
:func:`fastcashflow.solvency.dynamic_lapse_multiplier` (the parallel rate form).
It is a behavioural PRIMITIVE; feeding the resulting lapse back into the in-force
projection is a separate integration step. Accepts a scalar or an array and
returns the same shape."""
m = np.asarray(moneyness, dtype=np.float64)
mult = np.clip(1.0 + sensitivity * (m - 1.0), floor,
np.inf if cap is None else cap)
return float(mult) if m.ndim == 0 else mult
def moneyness_lapse_scale(model_points: ModelPoints, basis: Basis,
sensitivity: float, *, floor: float = 0.0,
cap=None) -> FloatArray:
"""Per-policy-year lapse multiplier from the account-value moneyness path.
Resolves the dynamic-lapse seam UP FRONT into the ``(n_mp, n_years)`` array
:func:`fastcashflow.projection.project_cashflows` consumes as ``lapse_scale``.
At the start of each policy year the account value -- the closed-form VFA
growth path ``av0 * growth ** month`` -- is divided by the GMAB
(``minimum_accumulation_benefit``, the guarantee a surrender forgoes) to get
the moneyness, then :func:`moneyness_lapse_multiplier` (``sensitivity``,
``floor``, ``cap``) maps it to a lapse factor for that year. A contract with
no GMAB (guarantee ``<= 0``) gets a flat ``1.0`` -- no floor to weigh, so the
lapse is untouched.
The account value is exogenous to lapse (a per-policy level, not a count), so
the whole moneyness path is known before the decrement projection -- the
factor array is built once here and the kernel sees only the scaled lapse (no
per-step callback). This is the inception path (``elapsed_months = 0``); an
in-force re-anchored moneyness is a later extension. It is the account-value
counterpart to :func:`fastcashflow.solvency.interest_with_dynamic_lapse` (the
parallel-rate scalar form)."""
basis = _single_basis(basis, entry="moneyness_lapse_scale")
r_m = (1.0 + basis.investment_return) ** (1.0 / 12.0) - 1.0
f_m = (1.0 + basis.fund_fee) ** (1.0 / 12.0) - 1.0
credit_m = credited_monthly_rate(r_m, model_points.minimum_crediting_rate)
growth = (1.0 + credit_m) * (1.0 - f_m) # (n_mp,)
n_time = int(model_points.contract_boundary_months.max())
n_years = (n_time + 11) // 12
year_start = np.arange(n_years) * 12 # 0, 12, 24, ...
av0 = np.asarray(model_points.account_value, dtype=np.float64)
av_year = av0[:, None] * growth[:, None] ** year_start[None, :] # (n_mp, n_years)
return _moneyness_scale_from_av_year(
av_year, model_points.minimum_accumulation_benefit, sensitivity, floor, cap)
def _moneyness_scale_from_av_year(av_year, gmab, sensitivity, floor, cap):
"""The shared moneyness -> per-policy-year lapse factor: ``av_year / GMAB`` mapped
through :func:`moneyness_lapse_multiplier`, with a flat ``1.0`` where the GMAB is
absent (``<= 0``). ``av_year`` is the account value at each policy-year start --
the closed-form path for the variable-annuity case, the rolled account path for
the universal-life case."""
gmab = np.asarray(gmab, dtype=np.float64)
has_g = gmab > 0.0
safe_g = np.where(has_g, gmab, 1.0)[:, None]
scale = moneyness_lapse_multiplier(av_year / safe_g, sensitivity,
floor=floor, cap=cap)
scale = np.where(has_g[:, None], scale, 1.0)
return np.ascontiguousarray(scale)
# What the ``csm`` on a Measurement represents -- so downstream accounting
# output cannot mistake an in-force carry-only figure for a settlement CSM. The
# discriminator is carried on the result (not just the docstring) and the
# accounting-output entry points (roll_forward / report / group / serialise)
# guard on it via _require_settlement_csm.
def _require_settlement_csm(measurement: "Measurement", op: str) -> None:
"""Reject a carry-only in-force CSM where a settlement CSM is required.
``vfa.measure_inforce`` returns a carry-only CSM (the prior CSM rolled at the
basis return, with the IFRS 17 paragraph-45 remeasurement deferred) -- a
diagnostic headline, not an accounting settlement figure. Accounting-output
functions call this guard so that figure cannot be silently rolled forward,
reported, grouped or serialised as if it were paragraph-45 compliant.
"""
if measurement.csm_basis == CSM_BASIS_CARRY_ONLY:
raise ValueError(
f"{op}: this VFA measurement carries a carry-only in-force CSM "
f"(csm_basis='{CSM_BASIS_CARRY_ONLY}', from vfa.measure_inforce -- "
"the prior CSM rolled at the basis return, with the paragraph-45 "
"remeasurement deferred). It is not a settlement figure, so it "
f"cannot be used by {op}. For the real paragraph-45 subsequent "
"measurement (the opening->closing settlement movement) use "
"fastcashflow.vfa.settle(...).")
@write_measurement.register
def _(measurement: Measurement, path, *, ids=None):
_require_settlement_csm(measurement, "write_measurement")
cols = {"bel": measurement.bel, "ra": measurement.ra,
"csm": measurement.csm,
"variable_fee": measurement.variable_fee,
"time_value": measurement.time_value,
"loss_component": measurement.loss_component}
# A paragraph-45 closing balance gets the same marker columns as the
# other models' non-inception output; inception output is unchanged.
cols.update(_inforce_marker_columns(measurement, measurement.bel.shape[0]))
_write_measurement_columns(cols, path, ids)
def _scatter_headline(n_mp, results):
"""Scatter per-chunk headline-only Measurements into one ``(n_mp,)`` result.
``results`` is ``[(idx, Measurement)]`` from ``measure(..., full=False)``
over row-blocks; only the headline ``bel`` / ``ra`` / ``csm`` /
``variable_fee`` / ``time_value`` / ``loss_component`` are laid back, the
trajectory fields staying ``None``. The portfolio orchestrator uses this on
its ``full=False`` path so a chunked VFA partition costs ``O(n_mp)`` retained,
not ``O(n_mp x n_time)``.
"""
bel = np.empty(n_mp)
ra = np.empty(n_mp)
csm = np.empty(n_mp)
variable_fee = np.empty(n_mp)
time_value = np.empty(n_mp)
loss_component = np.empty(n_mp)
for idx, m in results:
bel[idx] = m.bel
ra[idx] = m.ra
csm[idx] = m.csm
variable_fee[idx] = m.variable_fee
time_value[idx] = m.time_value
loss_component[idx] = m.loss_component
return Measurement(
bel=bel, ra=ra, csm=csm, variable_fee=variable_fee,
time_value=time_value, loss_component=loss_component)
def _stitch_measurements(n_mp, sub_results):
"""Scatter per-segment Measurements into one ``(n_mp, ...)`` result.
``sub_results`` is ``[(idx, Measurement)]`` -- each segment's headline and
trajectories are laid into the portfolio arrays at its rows and zero-padded
on the right to the portfolio's longest horizon (a contract carries no BEL /
CSM past its term). Like the GMM stitch, ``discount_factor_bom`` becomes per-MP 2-D:
VFA discounts at each segment's underlying-items return, so the curve is a
property of the row, not one curve for the book. The padded tail repeats
each row's last factor (a flat curve -> zero forward rate) so a rate read off
it is finite, not a 0/0. The mixed-portfolio orchestrator
(``fcf.portfolio.measure``) uses this to combine a VFA partition that spans
several routing segments into one ``Measurement``.
"""
n_time = max(m.bel_path.shape[1] - 1 for _, m in sub_results)
bel = np.empty(n_mp)
ra = np.empty(n_mp)
csm = np.empty(n_mp)
variable_fee = np.empty(n_mp)
time_value = np.empty(n_mp)
loss_component = np.empty(n_mp)
bel_path = np.zeros((n_mp, n_time + 1))
ra_path = np.zeros((n_mp, n_time + 1))
csm_path = np.zeros((n_mp, n_time + 1))
account_value_path = np.zeros((n_mp, n_time + 1))
csm_accretion = np.zeros((n_mp, n_time))
csm_release = np.zeros((n_mp, n_time))
lic_path = np.zeros((n_mp, n_time + 1))
discount_factor_bom = np.ones((n_mp, n_time + 1))
cf_2d = ("inforce", "deaths", "premium_cf", "mortality_cf", "morbidity_cf",
"expense_cf", "annuity_cf", "disability_cf", "surrender_cf")
cf_arrays = {name: np.zeros((n_mp, n_time)) for name in cf_2d}
maturity_cf = np.zeros(n_mp)
maturity_survivors = np.zeros(n_mp)
for idx, m in sub_results:
t = m.bel_path.shape[1] - 1
bel[idx] = m.bel
ra[idx] = m.ra
csm[idx] = m.csm
variable_fee[idx] = m.variable_fee
time_value[idx] = m.time_value
loss_component[idx] = m.loss_component
bel_path[idx, :t + 1] = m.bel_path
ra_path[idx, :t + 1] = m.ra_path
csm_path[idx, :t + 1] = m.csm_path
account_value_path[idx, :t + 1] = m.account_value_path
csm_accretion[idx, :t] = m.csm_accretion
csm_release[idx, :t] = m.csm_release
lic_path[idx, :t + 1] = m.lic_path
_carry_lic_residual(lic_path, idx, t, n_time, m.lic_path)
# Per-MP discount: lay the segment's 1-D curve across its rows, then
# flat-fill the tail so the padded months read a zero forward rate.
discount_factor_bom[idx, :t + 1] = m.discount_factor_bom
discount_factor_bom[idx, t + 1:] = m.discount_factor_bom[-1]
cf = m.cashflows
for name in cf_2d:
arr = getattr(cf, name)
cf_arrays[name][idx, :arr.shape[1]] = arr
maturity_cf[idx] = cf.maturity_cf
maturity_survivors[idx] = cf.maturity_survivors
cashflows = type(sub_results[0][1].cashflows)(
maturity_cf=maturity_cf, maturity_survivors=maturity_survivors,
**cf_arrays,
)
return Measurement(
bel=bel, ra=ra, csm=csm, variable_fee=variable_fee,
time_value=time_value, loss_component=loss_component,
bel_path=bel_path, ra_path=ra_path, csm_path=csm_path,
account_value_path=account_value_path,
csm_accretion=csm_accretion, csm_release=csm_release, lic_path=lic_path,
discount_factor_bom=discount_factor_bom, cashflows=cashflows,
)
@dataclass(frozen=True, slots=True)
class _VFAProjection:
"""AV-anchored VFA projection shared by :func:`measure` (inception) and
``vfa.measure_inforce`` (subsequent measurement).
Holds the trajectory building blocks that do not depend on the CSM choice:
the account-value path, the BEL / RA trajectories, the variable-fee PV
*trajectory* (PV of the fee from each month onward, so an in-force valuation
can slice the remaining fee at the valuation date), the LIC, the cash flows,
the coverage units and the underlying-items return. Each caller derives its
own CSM (inception ``csm0`` vs a carried ``prior_csm``) and headline.
"""
bel: FloatArray # (n_mp, n_time+1) BEL trajectory
ra: FloatArray # (n_mp, n_time+1) RA trajectory
variable_fee_path: FloatArray # (n_mp, n_time+1) PV of the fee from each t
time_value: FloatArray # (n_mp,) guarantee TVOG at the anchor
lic_path: FloatArray # (n_mp, n_time+1)
benefit_cf: FloatArray # (n_mp, n_time) incurred benefit claims (builds the LIC)
guarantee_excess_cf: FloatArray # (n_mp, n_time) GMDB/GMAB excess over AV (the insurance claim, IC-excluded)
fee_cf: FloatArray # (n_mp, n_time) variable fee skimmed (the entity's inflow)
cashflows: "Cashflows"
inforce: FloatArray # (n_mp, n_time) coverage units
r_m: float # monthly underlying-items return
av: FloatArray # (n_mp, n_time+1) account-value path
discount_factor_bom: FloatArray # (n_time+1,) start-of-month discount
guarantee_excess_pv: FloatArray # (n_mp, n_time+1) PV of the GMDB/GMAB excess over AV
expense_pv: FloatArray # (n_mp, n_time+1) PV of expenses
def _project(
model_points: ModelPoints,
basis: Basis,
return_scenarios: FloatArray | None = None,
*,
elapsed_months: IntArray | None = None,
account_value: FloatArray | None = None,
tv_reduce: bool = True,
_proj: "Cashflows | None" = None,
) -> "_VFAProjection":
"""Project VFA cash flows / trajectories, optionally re-anchoring the AV.
With ``elapsed_months`` / ``account_value`` left ``None`` (the default) this
is the inception projection: the account value grows from the model point's
``account_value`` at issue. For subsequent measurement, pass each contract's
``elapsed_months`` and its **observed** per-MP fund value (``account_value``)
-- the account-value path is re-anchored so it equals the observed value at
that duration (``av[t] = observed * growth ** (t - elapsed_months)``). The
decrements come from the inception projection (they depend on policy
duration, not the fund), and the AV-dependent benefits / fees recompute from
the re-anchored path; the caller slices the trajectories at the valuation
date.
``_proj`` (private) lets a caller running this twice on the same book
(``vfa.settle``: an expected and an observed leg) share one decrement
projection -- the legs differ only in the AV anchor, and sharing makes
the in-force / coverage-unit arrays bit-identical between them.
"""
basis = _single_basis(basis, entry="measure")
# The VFA death money is ``deaths * death_benefit`` (below), computed from
# the occupancy decrement -- it never reads the GMM death-claim factor. A
# state-conditioned death benefit or occupancy exit would be silently
# ignored here, so reject it rather than mis-measure the guarantee.
state_machine = resolve_model(basis)
if any(s.death_benefit_factor != 1.0 for s in state_machine.states):
raise NotImplementedError(
"state-conditioned death benefit (State.death_benefit_factor) is "
"not supported on the VFA path; measure pays the GMDB/GMAB "
"floor on the occupancy decrement, which the GMM death-claim "
"factor does not reach."
)
if any(tr.after_sojourn_months
for s in state_machine.states for tr in s.transitions):
raise NotImplementedError(
"a deterministic transition (Transition.after_sojourn_months) is not "
"supported on the VFA path."
)
proj = _proj if _proj is not None else project_cashflows(model_points, basis)
inforce = proj.inforce
n_mp, n_time = inforce.shape
r_m = (1.0 + basis.investment_return) ** (1.0 / 12.0) - 1.0
f_m = (1.0 + basis.fund_fee) ** (1.0 / 12.0) - 1.0
# A minimum guarantee credits max(return, guarantee) to the account. The
# guarantee is a per-policy contract term (locked at issue, cohort-aware),
# carried on the model point: NO_GUARANTEE_RATE means the account follows
# the bare return, 0.0 is a real 0% floor (see credited_monthly_rate). r_m
# is a scalar and the guarantee is per model point, so credit_m is (n_mp,).
credit_m = credited_monthly_rate(r_m, model_points.minimum_crediting_rate)
growth = (1.0 + credit_m) * (1.0 - f_m) # (n_mp,)
# Account-value trajectory -- per-policy closed form, anchored at the
# valuation date. At inception elapsed_months = 0 and account_value is the
# model point's, so this is exactly av0 * growth^t (the original path);
# for an in-force valuation the path is re-seeded to the observed fund value
# at t = elapsed_months.
periods = np.arange(n_time + 1)
em = (np.zeros(n_mp, dtype=np.int64) if elapsed_months is None
else np.asarray(elapsed_months, dtype=np.int64))
av_anchor = (model_points.account_value if account_value is None
else np.asarray(account_value, dtype=np.float64))
av = av_anchor[:, None] * (
growth[:, None] ** (periods[None, :] - em[:, None])
)
# Every policy eventually exits and receives its account value -- except
# that a death exit pays max(account value, guaranteed minimum death
# benefit). ``deaths`` is the mortality portion of the decrement; the
# remainder (lapse, maturity) takes the account value. With the default
# zero GMDB this reduces exactly to ``exits * av`` (max(AV, 0) = AV).
inforce_pad = np.concatenate([inforce, np.zeros((n_mp, 1))], axis=1)
exits = inforce_pad[:, :-1] - inforce_pad[:, 1:] # (n_mp, n_time)
deaths = proj.deaths # (n_mp, n_time)
death_benefit = np.maximum(
av[:, :n_time], model_points.minimum_death_benefit[:, None]
)
rows = np.arange(n_mp)
# Maturity survivors are part of the exit flow at the maturity column, but
# unlike a mid-month death or lapse (paid the start-of-month account value)
# they reach the maturity date (time = term) with the *matured* account
# value av[term] -- the value after the final month's growth. Split them out
# of the start-of-month exit payout and pay them separately below, so the
# base account-value half and the GMAB floor share one maturity date and
# value (the GMM convention: maturity paid at time term).
#
# Maturity is realised only when the term falls within *that contract's own*
# paragraph 34 boundary -- the projection runs each contract over range(boundary),
# so maturity_survivors is non-zero only when term <= boundary. Decide
# eligibility per contract, NOT from the portfolio-wide horizon n_time (= max
# boundary): in a mixed book a short contract would otherwise read
# term - 1 < n_time as "within" off another contract's longer horizon. Past a
# boundary cut there is no maturity, so clamp the index (the zero maturity
# weight makes the clamped cell harmless) rather than index out of bounds. At
# boundary == term (the common case) term_idx is term - 1.
boundary_idx = model_points.contract_boundary_months - 1
within = (model_points.term_months - 1) <= boundary_idx
term_idx = np.where(within, model_points.term_months - 1, boundary_idx)
maturity_survivors = np.where(within, proj.maturity_survivors, 0.0)
# Non-maturity exits (mid-month deaths and lapses) keep the start-of-month
# account value; remove the maturity survivors from that flow.
non_maturity_exits = exits - deaths
non_maturity_exits[rows, term_idx] -= maturity_survivors
benefit_cf = deaths * death_benefit + non_maturity_exits * av[:, :n_time]
# Maturity survivors are paid max(account value, GMAB) on the matured value
# av[term] (index term_idx + 1; av is width n_time + 1, so this is always in
# range). Default zero GMAB pays the plain matured account value.
av_at_maturity = av[rows, term_idx + 1]
maturity_benefit = maturity_survivors * np.maximum(
av_at_maturity, model_points.minimum_accumulation_benefit)
# The GMAB excess (the guarantee's lift over the matured account value),
# exposed separately for the paragraph-45 guarantee-cost disclosure below.
maturity_excess = maturity_survivors * np.maximum(
0.0, model_points.minimum_accumulation_benefit - av_at_maturity)
# The maturity benefit enters benefit_cf at the term - 1 exit column
# nominally, so the LIC settlement (and any other benefit_cf consumer) sees
# the incurred amount. It is paid at maturity (time = term), one month past
# that column, so the present-value path below discounts it the extra month.
benefit_cf[rows, term_idx] += maturity_benefit
# The extra-month discount, applied to the PV path only (the LIC keeps the
# nominal amount). discount_factor_bom[term] / discount_factor_bom[term - 1] = 1/(1 + r_m), so
# anchoring the maturity payout at time term scales its term - 1 cell by that
# factor; this matches the GMM maturity convention (discount at the boundary
# index). The guarantee-excess disclosure path takes the same shift on its
# (smaller) GMAB-only amount.
def _mat_shift(amount):
shift = np.zeros((n_mp, n_time))
shift[rows, term_idx] = amount * (1.0 / (1.0 + r_m) - 1.0)
return shift
mat_pv_shift = _mat_shift(maturity_benefit)
g_pv_shift = _mat_shift(maturity_excess)
# Variable fee -- the entity's share, skimmed from the grown account value.
# Charged only on the policies in the fund THROUGH month-end, which incur
# that month's credit-and-fee growth: the start-of-month in-force less this
# month's mid-month exits (deaths and non-maturity lapses). Those exits
# leave at the start of the month with the un-grown av[t] -- the same
# convention benefit_cf uses above (lines 425-429) -- so they take no part
# in the growth or the fee. The maturity survivors are NOT in
# non_maturity_exits (removed at term_idx above): they reach the maturity
# date with the matured av[term] and so correctly keep the final month's
# fee. A paragraph 34 boundary cut sets maturity_survivors = 0, so a censored
# contract pays no final-month fee, matching its un-grown start-of-month
# payout. Written subtractively (== inforce_pad[:, 1:] + maturity_survivors
# at term_idx) so it never MUTATES inforce_pad: the slice inforce_pad[:, 1:]
# is a view, so scattering the maturity add-back in place would corrupt the
# BEL fund (fund = inforce_pad * av) computed below -- the subtractive form
# needs no defensive copy.
fee_base = inforce - deaths - non_maturity_exits
fee_cf = fee_base * av[:, :n_time] * (1.0 + credit_m)[:, None] * f_m
# Liability for incurred claims -- exit benefits settled over the pattern.
if basis.settlement_pattern is None:
lic_path = np.zeros((n_mp, n_time + 1))
else:
lic_path = _settlement_lic(benefit_cf, basis.settlement_pattern)
# Discount at the underlying-items return -- the VFA basis. Benefits are
# discounted start-of-month, consistent with the account value, so a
# zero fee leaves no profit.
base = 1.0 + r_m
discount_factor_bom = base ** (-np.arange(n_time + 1))
disc_mid = base ** (-(np.arange(n_time) + 0.5))
# Present-value trajectories -- the PV at each month t of the cash flows
# from t onward, by a reverse cumulative discounted sum.
def _pv_trajectory(cashflow: FloatArray, discount: FloatArray) -> FloatArray:
tail = np.cumsum((cashflow * discount)[:, ::-1], axis=1)[:, ::-1]
pv = np.zeros((n_mp, n_time + 1))
pv[:, :n_time] = tail
return pv / discount_factor_bom
# A settlement pattern pays the exit benefit over later months -- so
# discount it to those payment dates in the present value.
benefit_for_pv = benefit_cf + mat_pv_shift
if basis.settlement_pattern is not None:
benefit_for_pv = benefit_for_pv * _settlement_factor(
basis.settlement_pattern, r_m
)
pv_benefits = _pv_trajectory(benefit_for_pv, discount_factor_bom[:n_time])
pv_expenses = _pv_trajectory(proj.expense_cf, disc_mid)
# Variable fee as a PV *trajectory* (PV of the fee from each month onward).
# Column 0 is the inception total (= the old scalar sum); an in-force
# valuation slices the remaining fee at the valuation date.
variable_fee_path = _pv_trajectory(fee_cf, disc_mid)
# Guarantee-excess PV (the GMDB/GMAB cost over the account value) and the
# expense PV, exposed for the paragraph-45 settlement movement's
# future-service change (c) = -(dG + dE + dRA). The GMDB excess each month is
# deaths*(max(av,gmdb)-av); the GMAB excess sits at the maturity column. Same
# settlement-pattern discounting as the total benefit path (above).
guarantee_excess_cf = deaths * (death_benefit - av[:, :n_time])
guarantee_excess_cf[rows, term_idx] += maturity_excess
g_for_pv = guarantee_excess_cf + g_pv_shift
if basis.settlement_pattern is not None:
g_for_pv = g_for_pv * _settlement_factor(
basis.settlement_pattern, r_m)
guarantee_excess_pv = _pv_trajectory(g_for_pv, discount_factor_bom[:n_time])
# The deterministic BEL carries the guarantee's intrinsic value only.
# Given return scenarios, fold in its time value too -- under the VFA
# the CSM absorbs it.
time_value = np.zeros(n_mp)
if return_scenarios is not None:
return_scenarios = np.asarray(return_scenarios, dtype=np.float64)
if return_scenarios.ndim != 2 or return_scenarios.shape[1] != n_time:
raise ValueError(
f"return_scenarios must be 2-D (n_scenarios, {n_time}) -- "
"the projection horizon"
)
return_scenarios = _validate_return_scenarios(return_scenarios)
# The credit-rate TVOG weights are portfolio-level (one crediting
# guarantee), so the time-value pass PARTITIONS the book by distinct
# minimum_crediting_rate: each group is uniform and shares the weights,
# and a mixed-guarantee book sums the per-group per-MP results (the
# credit-rate TVOG and the GMDB/GMAB floor are independent per model
# point). The GMDB/GMAB floors themselves already vary per model point.
g_arr = np.asarray(model_points.minimum_crediting_rate, dtype=np.float64)
n_scen = return_scenarios.shape[0]
av0 = model_points.account_value
time_value = np.zeros(n_mp) if tv_reduce else np.zeros((n_mp, n_scen))
for g in np.unique(g_arr):
m = g_arr == g # this group's model points
gf = float(g)
w = tvog_weights(
minimum_crediting_rate=gf, fund_fee=basis.fund_fee,
investment_return=basis.investment_return,
return_scenarios=return_scenarios, reduce=tv_reduce)
w_term = tvog_term_weight(
minimum_crediting_rate=gf, fund_fee=basis.fund_fee,
investment_return=basis.investment_return,
return_scenarios=return_scenarios, reduce=tv_reduce)
# Maturity survivors sit in `exits` at term_idx (= term - 1) but exit
# at time = term, credited the full final month -- peel them off that
# column weight w[term_idx] and re-seat one month later (the GMAB /
# paragraph B119 handling). w_ext = [w, w_term] selects by term_idx + 1.
if tv_reduce:
w_ext = np.append(w, w_term)
credit = av0[m] * (
exits[m] @ w
- maturity_survivors[m] * w[term_idx[m]]
+ maturity_survivors[m] * w_ext[term_idx[m] + 1])
else:
# Per-scenario forms: w is (n_scen, n_time), w_term (n_scen,) ->
# the reduced expression vectorised over the scenario axis.
w_ext = np.concatenate([w, w_term[:, None]], axis=1)
credit = av0[m][:, None] * (
exits[m] @ w.T
- maturity_survivors[m][:, None] * w[:, term_idx[m]].T
+ maturity_survivors[m][:, None] * w_ext[:, term_idx[m] + 1].T)
# The GMDB and GMAB are put-option floors on the account value; add
# their time value (each guarantee's intrinsic value is in the BEL).
floor = guarantee_floor_time_value(
account_value=av0[m], deaths=proj.deaths[m],
maturity_survivors=maturity_survivors[m], term_index=term_idx[m],
minimum_death_benefit=model_points.minimum_death_benefit[m],
minimum_accumulation_benefit=model_points.minimum_accumulation_benefit[m],
minimum_crediting_rate=gf, fund_fee=basis.fund_fee,
investment_return=basis.investment_return,
return_scenarios=return_scenarios, reduce=tv_reduce)
time_value[m] = credit + floor
# BEL and RA as trajectories. The BEL is reported net of the account
# value the entity holds -- a smooth, modest figure that at inception
# nets to benefits and expenses less the premium (= the account value).
fund = inforce_pad * av
bel = pv_benefits + pv_expenses - fund
# RA -- a confidence-level margin for expense risk, the non-financial
# risk an account-value contract carries (mortality risk on the amount
# is near zero, every exit paying the account value).
ra = _norm_ppf(basis.ra_confidence) * basis.expense_cv * pv_expenses
return _VFAProjection(
bel=bel, ra=ra, variable_fee_path=variable_fee_path,
time_value=time_value, lic_path=lic_path, benefit_cf=benefit_cf,
guarantee_excess_cf=guarantee_excess_cf, fee_cf=fee_cf,
cashflows=proj, inforce=inforce,
r_m=r_m, av=av, discount_factor_bom=discount_factor_bom,
guarantee_excess_pv=guarantee_excess_pv, expense_pv=pv_expenses,
)
[문서]
def measure(
model_points: ModelPoints,
basis: Basis,
return_scenarios: FloatArray | None = None,
*,
full: bool = True,
lapse_sensitivity: float | None = None,
lapse_floor: float = 0.0,
lapse_cap=None,
) -> Measurement:
"""Measure a direct-participation portfolio under the Variable Fee Approach.
The account value rolls forward as
``AV[t+1] = AV[t] * (1 + max(r, g)) * (1 - f)`` -- the credited rate (the
underlying-items return ``r`` floored at any guaranteed rate ``g``) less
the variable fee ``f`` -- from ``AV[0]`` = the model point's
``account_value``. A surrender pays the account value; a death exit pays
``max(account value, minimum_death_benefit)`` (GMDB) and the survivors
reaching term pay ``max(account value, minimum_accumulation_benefit)``
(GMAB), so the excess over the account value is each guarantee's intrinsic
cost. When ``return_scenarios`` is given, each guarantee's *time value*
(the extra cost from return volatility) is folded into the CSM too -- the
credit-rate guarantee through the account-value growth, the GMDB and GMAB
floors as put options on the account value.
BEL is the present value of benefits and expenses less the premium, all
at the underlying-items return; the CSM is ``max(0, -(BEL + RA))`` -- the
entity's unearned variable fee -- accreted at the same return and
released by coverage units. The RA is a confidence-level margin for
expense risk.
``full=True`` (default) returns the BEL / RA / CSM / account-value
trajectories; ``full=False`` fills only the headline ``bel`` / ``ra`` /
``csm`` / ``variable_fee`` / ``time_value`` / ``loss_component`` (the
inception CSM is ``csm0``, so the release kernel is skipped) and leaves the
trajectory and cash-flow fields ``None`` -- the building block the portfolio
orchestrator chunks to bound memory.
``basis`` must resolve to a single :class:`Basis`; multi-segment routers are not
accepted.
BEL, RA and CSM are returned as month-by-month trajectories. The
deterministic BEL carries the guarantee's intrinsic value only; when
``return_scenarios`` -- an ``(n_scenarios, n_time)`` array of monthly
underlying-items returns -- is supplied, the time value of the guarantee
enters the inception fulfilment cash flows too, so the CSM absorbs it,
and ``time_value`` records that amount per model point.
``lapse_sensitivity`` (default ``None`` -- a static lapse) turns on a dynamic
lapse driven by the account-value moneyness: the lapse decrement is scaled by
the per-policy-year moneyness factor (``lapse_sensitivity`` the elasticity,
clamped to ``[lapse_floor, lapse_cap]``), which lifts surrenders when the GMAB is
out-of-the-money and lowers them when the floor bites. The factor keys on the
GMAB; both the closed-form variable-annuity path (the account value is the
closed-form growth path) and the account-backed universal-life path (the value
is read from the rolled account) are supported.
"""
basis = _single_basis(basis, entry="measure")
# Variable universal life: an account-backed (universal-life) book is
# measured through the SHARED recursive account roll (the projection fold,
# identical to the GMM path), discounted at the underlying-items return --
# the only thing the VFA model changes. The closed-form _project
# (variable-annuity, no cost-of-insurance) handles the account-flag-absent
# case. Branch STRICTLY on the coverage flags, never account_value (the
# variable-annuity product carries an account value too).
from fastcashflow._measurement.projection import valued_projection
from fastcashflow._measurement.account import _portfolio_has_account
if _portfolio_has_account(model_points, basis):
r_m = (1.0 + basis.investment_return) ** (1.0 / 12.0) - 1.0
n_time = int(model_points.contract_boundary_months.max())
lapse_scale = None
if lapse_sensitivity is not None:
# Moneyness dynamic lapse on the universal-life path. Roll the account
# once (static lapse) to read its value path, resolve the per-policy-year
# moneyness factor against the GMAB, then re-measure with the scaled
# lapse. The account value is a per-policy level (independent of the
# decrement count), so the static-roll path is the right driver.
vp0 = valued_projection(model_points, basis,
discount_monthly=np.full(n_time, r_m))
n_years = (n_time + 11) // 12
year_start = np.minimum(np.arange(n_years) * 12, n_time)
av_year = vp0.cashflows.account.av[:, year_start]
lapse_scale = _moneyness_scale_from_av_year(
av_year, model_points.minimum_accumulation_benefit,
lapse_sensitivity, lapse_floor, lapse_cap)
# The VFA model assembles its OWN measurement from the shared neutral
# bundle (BEL / RA valued at the underlying-items return) -- it no longer
# borrows a GMM measurement; the CSM below is rolled independently.
vp = valued_projection(model_points, basis,
discount_monthly=np.full(n_time, r_m),
lapse_scale=lapse_scale)
zeros = np.zeros_like(vp.bel) # UL has no asset-based variable fee
if return_scenarios is None:
# Deterministic: the BEL carries the guarantee's intrinsic value only.
time_value = zeros
else:
# Guarantee time value: re-roll the account under the return scenarios
# and price the GMDB / GMAB floors (mean cost less the central
# intrinsic), partitioned by the crediting guarantee. v1 still rejects
# an annuitizing book (the conversion floor differs from the
# GMDB/GMAB-at-maturity option) -- raised inside the helper.
return_scenarios = np.asarray(return_scenarios, dtype=np.float64)
if return_scenarios.ndim != 2 or return_scenarios.shape[1] != n_time:
raise ValueError(
f"return_scenarios must be 2-D (n_scenarios, {n_time}) -- "
"the projection horizon")
time_value = _ul_floor_time_value(
model_points, basis, vp.cashflows.deaths,
vp.cashflows.maturity_survivors, return_scenarios, reduce=True)
# Fold the time value into the inception FCF; the CSM absorbs it (with
# time_value == 0 this reproduces the deterministic intrinsic CSM / loss).
fcf = vp.bel + vp.ra + time_value
loss_component = np.maximum(0.0, fcf)
csm0 = np.maximum(0.0, -fcf)
if not full:
return Measurement(
bel=vp.bel, ra=vp.ra, csm=csm0, variable_fee=zeros,
time_value=time_value, loss_component=loss_component,
model_points=model_points)
# Roll the CSM forward on VFA's OWN inception csm0 (which already folds
# in the guarantee time value) -- the deterministic and stochastic paths
# are identical here, so a single kernel call serves both. With
# time_value == 0 this reproduces the GMM roll byte-for-byte (same csm0,
# same in-force coverage units, same flat underlying-items accretion).
csm_path, csm_accretion, csm_release = _csm_kernel(
csm0, vp.cashflows.inforce, np.full(n_time, r_m),
basis.coverage_unit_discount)
return Measurement(
bel=vp.bel, ra=vp.ra, csm=csm_path[:, 0], variable_fee=zeros,
time_value=time_value, loss_component=loss_component,
bel_path=vp.bel_path, ra_path=vp.ra_path, csm_path=csm_path,
account_value_path=vp.cashflows.account.av,
csm_accretion=csm_accretion, csm_release=csm_release,
lic_path=vp.lic_path, discount_factor_bom=vp.discount_factor_bom,
cashflows=vp.cashflows, model_points=model_points)
proj = None
if lapse_sensitivity is not None:
scale = moneyness_lapse_scale(model_points, basis, lapse_sensitivity,
floor=lapse_floor, cap=lapse_cap)
proj = project_cashflows(model_points, basis, lapse_scale=scale)
p = _project(model_points, basis, return_scenarios, _proj=proj)
n_time = p.inforce.shape[1]
variable_fee = p.variable_fee_path[:, 0]
# The inception fulfilment cash flows -- with the guarantee time value --
# drive the CSM and the loss component.
fcf = p.bel[:, 0] + p.ra[:, 0] + p.time_value
loss_component = np.maximum(0.0, fcf)
csm0 = np.maximum(0.0, -fcf)
if not full:
# Headline only: the inception CSM is csm0 (the full path's csm[:, 0]),
# so the release kernel is skipped; the trajectory and cash-flow fields
# are dropped so the chunked portfolio path retains O(n_mp) per row.
return Measurement(
bel=p.bel[:, 0], ra=p.ra[:, 0], csm=csm0, variable_fee=variable_fee,
time_value=p.time_value, loss_component=loss_component,
model_points=model_points)
# VFA accretes at the underlying-items return -- flat across time in
# the deterministic measurement; broadcast to the per-month curve the
# kernel consumes.
csm, csm_accretion, csm_release = _csm_kernel(
csm0, p.inforce, np.full(n_time, p.r_m),
basis.coverage_unit_discount,
)
return Measurement(
bel=p.bel[:, 0],
ra=p.ra[:, 0],
csm=csm[:, 0],
variable_fee=variable_fee,
time_value=p.time_value,
loss_component=loss_component,
bel_path=p.bel,
ra_path=p.ra,
csm_path=csm,
account_value_path=p.av,
csm_accretion=csm_accretion,
csm_release=csm_release,
lic_path=p.lic_path,
discount_factor_bom=p.discount_factor_bom,
cashflows=p.cashflows,
guarantee_excess_cf=p.guarantee_excess_cf,
benefit_cf=p.benefit_cf,
fee_cf=p.fee_cf,
model_points=model_points,
)
def measure_stochastic(model_points: ModelPoints, basis: Basis,
return_scenarios: FloatArray):
"""The VFA liability distribution over fund-return scenarios -- the VFA
counterpart of :func:`fastcashflow.gmm.stochastic`.
For each ``return_scenarios`` path the full guarantee cost (the crediting-rate
floor plus the GMDB / GMAB account-value floors) is realised and folded into
the liability, giving a per-scenario BEL / RA / CSM / loss component. Read the
distribution off the returned
:class:`~fastcashflow.StochasticResult` with ``mean`` /
``percentile``. The mean BEL reconciles to ``vfa.measure(...,
return_scenarios).bel + .time_value`` (the risk-neutral price); the mean CSM
differs from the pooled-scenario CSM because the loss-component floor is convex
-- that convexity is the reason to read the distribution.
``return_scenarios`` is an ``(n_scenarios, n_time)`` array of monthly
underlying-items returns (e.g. :attr:`fastcashflow.esg.EconomicScenarios.returns`).
Vectorised over the scenario axis: the deterministic projection (the intrinsic
BEL and RA) is scenario-independent and runs once; only the guarantee time
value varies by scenario, computed as one ``(n_mp, n_scenarios)`` matrix. The
per-scenario realised BEL is the intrinsic BEL plus that path's time value, and
the CSM / loss re-floor the fulfilment cash flow (BEL + RA + time value) per
scenario -- identical to running :func:`measure` once per path, but without
re-projecting."""
from fastcashflow._measurement.stochastic import StochasticResult
rs = np.asarray(return_scenarios, dtype=np.float64)
if rs.ndim != 2:
raise ValueError("return_scenarios must be 2-D (n_scenarios, n_time)")
if rs.shape[0] == 0:
raise ValueError("return_scenarios is empty; need at least one scenario")
n_scen = rs.shape[0]
basis = _single_basis(basis, entry="measure_stochastic")
bel_mp, ra_mp, tv = _vfa_time_value_by_scenario(model_points, basis, rs)
# tv is (n_mp, n_scenarios); bel_mp / ra_mp are the scenario-independent
# intrinsic per-MP BEL and RA. The realised liability per path is the intrinsic
# BEL plus that path's guarantee time value; the CSM / loss re-floor the FCF
# (BEL + RA + time value) per scenario -- exactly the per-scenario measure.
fcf = (bel_mp + ra_mp)[:, None] + tv # (n_mp, n_scenarios)
bel = float(bel_mp.sum()) + tv.sum(axis=0) # (n_scenarios,)
ra = np.full(n_scen, float(ra_mp.sum()))
csm = np.maximum(0.0, -fcf).sum(axis=0)
loss = np.maximum(0.0, fcf).sum(axis=0)
return StochasticResult(bel=bel, ra=ra, csm=csm, loss_component=loss)
def _ul_floor_time_value(model_points: ModelPoints, basis: Basis,
deaths: FloatArray, maturity_survivors: FloatArray,
return_scenarios: FloatArray, *, reduce: bool = True):
"""Per-MP guarantee time value of a universal-life account book, PARTITIONED by
distinct crediting guarantee (each group is uniform; the GMDB / GMAB floors
themselves vary per model point). Re-rolls the account under the return
scenarios and prices the floors (mean cost less the central intrinsic). Returns
``(n_mp,)`` (``reduce``) or ``(n_mp, n_scenarios)``."""
from fastcashflow._measurement.account import _roll_inputs
am = getattr(model_points, "annuitization_months", None)
if am is not None and np.any(np.asarray(am) > 0):
raise NotImplementedError(
"return_scenarios (guarantee time value) is not yet supported "
"for an annuitizing universal-life book.")
(av0, face, prem_to_av, coi_rate_m, admin_fee, account_charge,
gmab, _g, _sc) = _roll_inputs(model_points, basis)
g_arr = np.asarray(model_points.minimum_crediting_rate, dtype=np.float64)
boundary = np.asarray(model_points.contract_boundary_months, np.int64)
n_mp = av0.shape[0]
n_scen = return_scenarios.shape[0]
out = np.zeros(n_mp) if reduce else np.zeros((n_mp, n_scen))
for g in np.unique(g_arr):
m = g_arr == g # this group's model points
out[m] = ul_guarantee_floor_time_value(
account_value0=av0[m], face=face[m], prem_to_av=prem_to_av[m],
coi_rate_m=coi_rate_m[m], admin_fee=admin_fee, # admin_fee is shared (n_time,)
account_charge=account_charge[m], gmab=gmab[m],
minimum_crediting_rate=float(g), deaths=deaths[m],
maturity_survivors=maturity_survivors[m], boundary=boundary[m],
investment_return=basis.investment_return,
return_scenarios=return_scenarios, reduce=reduce)
return out
def _vfa_time_value_by_scenario(model_points: ModelPoints, basis: Basis,
return_scenarios: FloatArray):
"""The scenario-independent intrinsic per-MP BEL / RA and the
``(n_mp, n_scenarios)`` guarantee time-value matrix -- the vectorised core of
:func:`measure_stochastic`. The universal-life (account) book re-rolls the
account under every scenario in one pass; the variable-annuity closed form
builds the credit-rate and floor time-value matrices directly."""
from fastcashflow._measurement.projection import valued_projection
from fastcashflow._measurement.account import _portfolio_has_account
rs = np.asarray(return_scenarios, dtype=np.float64)
if _portfolio_has_account(model_points, basis):
r_m = (1.0 + basis.investment_return) ** (1.0 / 12.0) - 1.0
n_time = int(model_points.contract_boundary_months.max())
if rs.shape[1] != n_time:
raise ValueError(
f"return_scenarios must be 2-D (n_scenarios, {n_time}) -- "
"the projection horizon")
vp = valued_projection(model_points, basis, discount_monthly=np.full(n_time, r_m))
tv = _ul_floor_time_value(model_points, basis, vp.cashflows.deaths,
vp.cashflows.maturity_survivors, rs, reduce=False)
return vp.bel, vp.ra, tv
# Variable-annuity closed form.
p = _project(model_points, basis, rs, tv_reduce=False)
return p.bel[:, 0], p.ra[:, 0], p.time_value
def measure_stream(
input_path,
output_dir,
basis: Basis,
*,
calculation_methods=None,
chunk_size: int = 20_000_000,
id_column: str | None = None,
validate_unique_mp_id: bool = True,
return_scenarios: FloatArray | None = None,
) -> int:
"""Stream a VFA (account-value) valuation through a parquet file, chunk by chunk.
The VFA counterpart of :func:`~fastcashflow.gmm.measure_stream`. The VFA base
is a single policies frame (account value + guarantee floors, no coverages),
so it reads ``input_path`` in ``chunk_size`` blocks, measures each with
``vfa.measure(..., full=False)``, and writes per-chunk ``part-NNNNN.parquet``
results (bel / ra / csm / variable_fee / time_value). Returns the model points
processed. ``basis`` is a single :class:`Basis`.
``return_scenarios`` -- an ``(n_scenarios, horizon)`` array of monthly
underlying-items returns -- prices the guarantee time value (TVOG) of each
chunk, exactly as :func:`measure`. The per-model-point time value depends only
on a contract's own account path over its own term, so it is additive across
chunks and invariant to the chunk a row lands in: each chunk slices the
scenario prefix it needs (its own horizon ``contract_boundary_months.max()``),
and the result equals an in-memory ``measure(..., return_scenarios=...)`` over
the whole book. ``horizon`` must therefore cover the longest contract in the
file. Omitted (the default), the stream values deterministically and
``time_value`` is 0.
Marginal benefit note: streaming is for portfolios too large to hold in
memory (a GMM book of 1e8 rows). Variable books are typically far smaller, so
:func:`measure` / :func:`measure_aggregate` usually suffice; this exists for
API symmetry.
"""
basis = _single_basis(basis, entry="vfa.measure_stream")
rs = None if return_scenarios is None else np.asarray(
return_scenarios, dtype=np.float64)
def measure_fn(mp):
if rs is None:
return measure(mp, basis, full=False)
# Slice the scenario prefix to this chunk's horizon -- a row's time value
# is decided within its own term, so the prefix is exact (and a too-short
# scenario surfaces as the shape check inside measure).
n_time = int(mp.contract_boundary_months.max())
return measure(mp, basis, full=False, return_scenarios=rs[:, :n_time])
return _stream_single_file(
input_path, output_dir, chunk_size=chunk_size, id_column=id_column,
validate_unique_mp_id=validate_unique_mp_id,
build_mp=lambda frame: _vfa_model_points_from_frame(frame, calculation_methods),
measure_fn=measure_fn,
)
[문서]
def measure_aggregate(
model_points: ModelPoints,
basis: Basis,
*,
chunk_size: int = 200_000,
) -> Aggregate:
"""Portfolio-aggregate VFA measurement in bounded memory.
The VFA analogue of :func:`fastcashflow.gmm.measure_aggregate`: BEL / RA /
CSM / variable fee / time value are additive across contracts, so the
portfolio's run-off is the per-model-point trajectories summed over the
model-point axis. Runs ``measure(..., full=True)`` over row-blocks of
``chunk_size`` model points and accumulates only the ``(n_time+1,)`` sums, so
peak memory is ``O(chunk_size x n_time)`` regardless of ``n_mp``.
Returns a :class:`Aggregate` (scalar totals + aggregate ``bel_path`` /
``ra_path`` / ``csm_path`` / ``lic_path``). ``account_value`` does not carry: it is
a per-policy level whose closed-form growth never terminates at the boundary,
so summing it is horizon-dependent (the ``group`` VFA result drops it for the
same reason). The deterministic intrinsic value only -- the guarantee time
value over return scenarios is a per-contract analysis (:func:`vfa.tvog`), not
aggregated here. ``basis`` is a single :class:`Basis` (mixed / routed
portfolios go through :func:`fastcashflow.portfolio.measure_aggregate`).
"""
if chunk_size < 1:
# Guard before the chunk loop: chunk_size <= 0 would skip every block and
# return zero aggregates (silently wrong) instead of measuring anything.
raise ValueError(f"chunk_size must be >= 1, got {chunk_size}")
n_mp = model_points.n_mp
n_time = int(np.asarray(model_points.contract_boundary_months).max())
bel_path = np.zeros(n_time + 1)
ra_path = np.zeros(n_time + 1)
csm_path = np.zeros(n_time + 1)
lic_path = np.zeros(n_time + 1)
bel = ra = csm = variable_fee = time_value = loss = 0.0
for start in range(0, n_mp, chunk_size):
idx = np.arange(start, min(start + chunk_size, n_mp))
m = measure(model_points.subset(idx), basis, full=True)
nt = m.bel_path.shape[1]
bel_path[:nt] += m.bel_path.sum(axis=0)
ra_path[:nt] += m.ra_path.sum(axis=0)
csm_path[:nt] += m.csm_path.sum(axis=0)
lic_path[:nt] += m.lic_path.sum(axis=0)
bel += float(m.bel.sum())
ra += float(m.ra.sum())
csm += float(m.csm.sum())
variable_fee += float(m.variable_fee.sum())
time_value += float(m.time_value.sum())
loss += float(m.loss_component.sum())
return Aggregate(
bel=bel, ra=ra, csm=csm, variable_fee=variable_fee,
time_value=time_value, loss_component=loss, bel_path=bel_path,
ra_path=ra_path, csm_path=csm_path, lic_path=lic_path)
[문서]
def measure_inforce(
model_points: ModelPoints,
state: "InforceState",
basis: Basis,
*,
period_months: int | None = None,
) -> Measurement:
"""In-force diagnostic / runoff valuation of a VFA book at a single date.
Unlike the PAA (no CSM) and like the GMM carry
(:func:`fastcashflow.gmm.measure_inforce`), the prior period's
closing CSM is carried forward; unlike the GMM, the VFA CSM accretes at the
underlying-items return (not a locked-in rate), so the carry is
``_csm_kernel(state.prior_csm, coverage_units, r_m)`` -- the GMM carry
roll with the return substituted for the lock-in rate. The fulfilment cash
flows (BEL / RA / variable fee / guarantee intrinsic value) are re-measured
from the **observed** fund value at the valuation date
(``state.account_value``): the account-value path is re-anchored at that
observed value (:func:`_project`), the decrements come from the inception
projection (they depend on policy duration, not the fund), and the result is
sliced at each contract's ``elapsed_months`` and re-based by
``count / inforce[elapsed]`` (exact for cash flows linear in the in-force).
``state`` (an :class:`~fastcashflow.InforceState`) supplies the period-close
``elapsed_months`` / ``count`` (reconciled onto ``model_points`` by
:func:`~fastcashflow.apply_inforce_state`), the carried ``prior_csm``, and
the observed ``account_value`` (required here -- a VFA in-force needs the
real fund value, not the modelled one). ``period_months`` (default 12) is the
length of the period the prior CSM is rolled.
v1 returns the **as-of valuation-date headline** (``bel`` / ``ra`` / ``csm``
/ ``variable_fee`` / ``time_value`` / ``loss_component``); the trajectory
fields are ``None``. Deferred here: the paragraph-45 remeasurement (the
change in the entity's share of the underlying-items fair value and the
guarantee future-service cash flows) and the loss-component movement --
**both live in** :func:`~fastcashflow.vfa.settle`, the paragraph-45
settlement of the same state -- plus the stochastic time value of the
guarantee (``time_value`` is zero -- the deterministic intrinsic value
only) and the full movement trajectory.
.. warning::
The CSM is a **carry-only approximation**: it is the prior CSM accreted
at the basis underlying-items return and released by the *expected*
(inception-run) coverage units. Unlike the BEL / RA / variable fee, it
does **not** respond to the observed account value -- the paragraph-45
adjustment for the change in the entity's share of the underlying-items
fair value is deferred. So the fulfilment-cash-flow figures are
observed-AV-consistent but the CSM is not yet a paragraph-45-compliant
settlement CSM; for that, settle the same state with
:func:`~fastcashflow.vfa.settle`.
The result is tagged ``csm_basis = 'carry_only'`` (see
:data:`fastcashflow.vfa.CSM_BASES`), and the accounting-output entry
points -- :func:`~fastcashflow.roll_forward`,
:func:`~fastcashflow.reporting.report`, :func:`~fastcashflow.group`,
:func:`~fastcashflow.group_of_contracts` and
:func:`~fastcashflow.write_measurement` -- reject it, so this figure
cannot be silently consumed as a settlement CSM.
"""
basis = _single_basis(basis, entry="vfa.measure_inforce")
# Reorder state to model-points order and reject a stale snapshot whose
# elapsed_months / count disagree (same guard as the GMM/PAA paths).
state = _reconcile_state(model_points, state)
if state.account_value is None:
raise ValueError(
"vfa.measure_inforce needs the observed fund value at the valuation "
"date: set InforceState.account_value. (The modelled account value "
"would assume the fund followed the central return; a settlement "
"must use the real fund. GMM / PAA in-force do not need it.)")
n_mp = model_points.n_mp
em = np.asarray(model_points.elapsed_months, dtype=np.int64)
# The valuation date must lie strictly within each contract's own paragraph 34
# boundary. The projection runs each contract over range(boundary), so its
# in-force is filled for months 0 .. boundary-1; at or beyond the boundary
# there is no remaining coverage to value. Decide this **per contract** --
# not against the portfolio-wide horizon -- so a short contract in a mixed
# book is judged on its own boundary, not another contract's longer one.
# em < boundary <= n_time, so inforce[em] is always a valid, live column.
boundary = np.asarray(model_points.contract_boundary_months, dtype=np.int64)
runoff = em >= boundary
if np.any(runoff):
bad = int(np.argmax(runoff))
raise ValueError(
f"elapsed_months[{bad}]={int(em[bad])} >= "
f"contract_boundary_months[{bad}]={int(boundary[bad])} (the paragraph 34 "
"horizon); the contract has no remaining coverage at the valuation "
"date. Value it strictly before its boundary.")
# Re-anchor the account-value path at the observed fund value at em, then
# re-base the sliced headline to the valuation date (see _inforce_rescale).
p = _project(model_points, basis,
elapsed_months=em, account_value=state.account_value)
rows = np.arange(n_mp)
rescale = _inforce_rescale(p.cashflows.inforce, model_points, em, rows)
bel = p.bel[rows, em] * rescale
ra = p.ra[rows, em] * rescale
variable_fee = p.variable_fee_path[rows, em] * rescale
time_value = np.zeros(n_mp) # stochastic TVOG deferred (intrinsic only)
# Carry the prior closing CSM forward one period: accrete at the
# underlying-items return r_m and release by coverage units, from
# t = em - period_months to t = em -- the GMM settlement roll with r_m in
# place of the lock-in rate (the VFA CSM has no locked-in rate). The carry
# needs no rescale: the coverage-unit release is an in-force *fraction*.
period = 12 if period_months is None else int(period_months)
if period < 1:
raise ValueError(f"period_months must be >= 1, got {period_months}")
prior_t = em - period
if np.any(prior_t < 0):
bad = int(np.argmin(prior_t))
raise ValueError(
f"elapsed_months[{bad}]={int(em[bad])} < period_months={period}; "
"the prior closing date precedes inception, which has no CSM to "
"carry forward")
inforce = p.inforce
n_time_total = inforce.shape[1]
max_len = n_time_total - int(prior_t.min())
col_offsets = np.arange(max_len)
src_cols = prior_t[:, None] + col_offsets[None, :]
mask = src_cols < n_time_total
src_cols_safe = np.where(mask, src_cols, 0)
inforce_seg = np.ascontiguousarray(
np.where(mask, inforce[rows[:, None], src_cols_safe], 0.0))
csm_traj, _, _ = _csm_kernel(
np.asarray(state.prior_csm, dtype=np.float64),
inforce_seg, np.full(max_len, p.r_m), basis.coverage_unit_discount)
csm = csm_traj[:, period]
loss_component = np.zeros(n_mp) # paragraph-45 onerous unlocking deferred
return Measurement(
bel=bel, ra=ra, csm=csm, variable_fee=variable_fee,
time_value=time_value, loss_component=loss_component,
model_points=model_points, csm_basis=CSM_BASIS_CARRY_ONLY)
# The paragraph-48/50(b) CSM / loss-component step is shared with the GMM
# settlement (gmm.settle) and lives in _numerics; re-exported here for the
# existing VFA call sites and tests.
from fastcashflow._numerics import _csm_loss_component_step # noqa: E402,F401
[문서]
def settle(
model_points: ModelPoints,
state: "InforceState",
basis: Basis,
*,
period_months: int | None = None,
premium_experience_future_fraction: float | FloatArray = 0.0,
) -> "SettlementMovement":
"""Paragraph-45 subsequent-measurement settlement of a VFA in-force book
(period close).
The real IFRS 17 paragraph-45 opening -> closing movement, replacing the
carry-only ``vfa.measure_inforce`` headline for settlement / disclosure:
the CSM is adjusted for the change in the entity's share of the fair
value of the underlying items (45(b)) and for the changes in fulfilment
cash flows relating to future service (45(c)), the loss component is
reversed / recognised per paragraphs 48 and 50(b), and one paragraph-B119
coverage-unit release is taken on the post-adjustment balance. Returns a
:class:`~fastcashflow.vfa.SettlementMovement` whose blocks
reconcile exactly and whose :meth:`closing_measurement
<fastcashflow.vfa.SettlementMovement.closing_measurement>` is a
settlement-grade closing balance sheet
(``csm_basis='paragraph_45_settlement'``).
``state`` is the closing-dated :class:`~fastcashflow.InforceState`
extended with the prior reporting date's figures, all at month
``elapsed_months - period_months``: ``prior_csm`` (the opening CSM),
``prior_count``, ``prior_account_value`` and (optionally)
``prior_loss_component``; plus the closing snapshot's ``count`` and
observed ``account_value``. The same state feeds ``measure_inforce``
(the fast carry-only diagnostic) and this settlement.
How it computes: two forward projections of the same book share one
decrement run -- the EXPECTED leg anchors the account value at the
opening observation and advances it under the basis return; the OBSERVED
leg anchors at the closing observation -- and both are sliced at the
closing date. The paragraph-45 future-service change is **minus the
observed-vs-expected difference of the engine's own BEL and RA** (exact
with respect to every engine convention, including the end-of-month fee
timing and a binding minimum-crediting-rate floor); the 45(b) /
45(c) split is a disclosure decomposition of that exact total, the
45(b) line being the fund-consistent (end-of-month-weighted)
variable-fee PV difference. The CSM accretes by direct compounding at
the underlying-items return over the period and is released once at
period end over the coverage units provided in the period against those
provided plus expected from the opening date. With no experience
deviation this reproduces the ``measure_inforce`` monthly carry exactly
(a telescoping identity of the release weights).
A contract whose paragraph 34 boundary falls inside the period is a **final
settlement**: allowed, provided its closing ``count`` and
``account_value`` are zero (validated) -- its remaining CSM releases in
full. The opening date must lie strictly within every contract's
boundary.
An onerous book amortises its loss component through the paragraph-50(a)/51
incurred-service channel (``loss_component_finance`` /
``loss_component_amortised``): the period's guarantee-excess + expense
release (the claims+expenses pool, which for VFA excludes the account-value
investment component) is split on the loss-component ratio, running the loss
component to zero by the end of coverage (52). The B96(c) investment-
component experience (``csm_investment_experience`` -- the expected less the
actual account value returned on exits) adjusts the CSM when
``state.actual_investment_component`` is given.
v1 scope (see :class:`~fastcashflow.vfa.SettlementMovement` for
the full statement): deterministic, single basis rate (the
finance-not-adjusting-CSM split is an exact zero residual), intrinsic
guarantee only, no assumption change, within-period experience assumed
equal to expected (only the closing count and observed account value
deviate), per-model-point floors. A basis with a ``settlement_pattern`` is supported:
the movement carries the liability for incurred claims (``lic_opening`` /
``claims_incurred`` / ``claims_paid`` / ``lic_closing``, paragraphs 40(b) /
42 / 103(b)) -- benefit claims build it up as incurred and run it off over
the pattern, undiscounted and at the expected scale, reconstructed from the
projection each period (the same v1 cuts as gmm.settle: no 42(b)/(c)).
"""
basis = _single_basis(basis, entry="vfa.settle")
period = 12 if period_months is None else int(period_months)
if period < 1:
raise ValueError(f"period_months must be >= 1, got {period_months}")
# Reorder state to model-points order and reject a stale snapshot whose
# elapsed_months / count disagree (same guard as measure_inforce).
state = _reconcile_state(model_points, state)
if state.account_value is None:
raise ValueError(
"vfa.settle needs the observed fund value at the closing date: "
"set InforceState.account_value.")
if state.prior_account_value is None:
raise ValueError(
"vfa.settle needs the observed fund value at the opening date: "
"set InforceState.prior_account_value (the prior reporting "
"date's observation, like prior_csm).")
if state.prior_count is None:
raise ValueError(
"vfa.settle needs the in-force count at the opening date: set "
"InforceState.prior_count (the prior reporting date's count, "
"like prior_csm).")
n_mp = model_points.n_mp
prior_csm = np.asarray(state.prior_csm, dtype=np.float64)
if np.any(prior_csm < 0.0):
raise ValueError(
"InforceState.prior_csm must be >= 0 for vfa.settle; a VFA CSM "
"is floored at zero (an onerous balance is the loss component)")
lc_open = (np.zeros(n_mp) if state.prior_loss_component is None
else np.asarray(state.prior_loss_component, dtype=np.float64))
both = np.minimum(prior_csm, lc_open) > 0.0
if np.any(both):
bad = np.flatnonzero(both)[:5].tolist()
raise ValueError(
f"prior_csm and prior_loss_component are both positive at "
f"row(s) {bad}; an IFRS 17 group carries a CSM or a loss "
"component, never both")
em_close = np.asarray(state.elapsed_months, dtype=np.int64)
em_open = em_close - period
if np.any(em_open < 0):
bad = int(np.argmin(em_open))
raise ValueError(
f"elapsed_months[{bad}]={int(em_close[bad])} < "
f"period_months={period}; the opening date precedes inception, "
"which has no state to settle from")
boundary = np.asarray(model_points.contract_boundary_months, dtype=np.int64)
dead_at_open = em_open >= boundary
if np.any(dead_at_open):
bad = int(np.argmax(dead_at_open))
raise ValueError(
f"elapsed_months[{bad}] - period_months = {int(em_open[bad])} >= "
f"contract_boundary_months[{bad}]={int(boundary[bad])} (the "
"paragraph 34 horizon); the contract had no remaining coverage at the "
"opening date, so there is nothing to settle.")
# A boundary inside the period is a final settlement -- legitimate, but
# the closing snapshot must agree that the contract is gone.
count = np.asarray(state.count, dtype=np.float64)
observed_av = np.asarray(state.account_value, dtype=np.float64)
runoff = em_close >= boundary
bad_runoff = runoff & ((count != 0.0) | (observed_av != 0.0))
if np.any(bad_runoff):
bad = int(np.argmax(bad_runoff))
raise ValueError(
f"row {bad} reaches its contract boundary within the period "
f"(elapsed_months={int(em_close[bad])} >= "
f"contract_boundary_months={int(boundary[bad])}) -- a final "
"settlement -- but its closing count / account_value is not "
"zero. A matured contract must close with count=0 and "
"account_value=0.")
# Per-MP clamped closing column: value-neutral (a contract's trajectory
# columns at/past its own boundary are zero), needed only so a mixed
# book's short contract never indexes off the shared arrays.
em_c = np.minimum(em_close, boundary)
# Two forward projections, one shared decrement run. The legs take their
# anchors EXPLICITLY from the state -- never from the seated model points,
# whose elapsed_months / count are the closing snapshot. The projection
# itself is seeded at count = 1 (the survival curve): the seated count is
# the CLOSING observation, and seeding with it would zero the whole
# in-force for a matured or mass-lapsed row -- the re-basing factors
# below carry the real opening / closing counts instead.
from dataclasses import replace as _replace
proj_mp = _replace(model_points, count=np.ones(n_mp))
proj = project_cashflows(proj_mp, basis)
p_exp = _project(proj_mp, basis, elapsed_months=em_open,
account_value=state.prior_account_value, _proj=proj)
p_obs = _project(proj_mp, basis, elapsed_months=em_close,
account_value=observed_av, _proj=proj)
rows = np.arange(n_mp)
inforce = p_exp.inforce # the ONE shared array
n_time = inforce.shape[1]
inforce_pad = np.concatenate([inforce, np.zeros((n_mp, 1))], axis=1)
r_m = p_exp.r_m
v_half = (1.0 + r_m) ** -0.5
# Re-basing factors -- per-MP scalars from the state's own opening /
# closing counts (computed inline: _inforce_rescale reads
# model_points.count, which is the CLOSING count). A dead-mid-life
# cohort (count = 0 on a live column) gets k_obs = 0 -- full
# derecognition -- not the dead-column no-op.
if_open = inforce[rows, em_open]
k_exp = (np.asarray(state.prior_count, dtype=np.float64)
/ np.where(if_open > 0.0, if_open, 1.0))
if_close = inforce_pad[rows, em_c]
k_obs = np.where(if_close > 0.0,
count / np.where(if_close > 0.0, if_close, 1.0), 1.0)
def _fcf_block(exp_path, obs_path):
"""The five movement lines of one FCF block (BEL or RA).
One residual -- the release line (the expected run-off, the
roll-forward convention); every other line is computed directly, so
an error cannot hide in two places at once.
"""
opening = k_exp * exp_path[rows, em_open]
offsets = np.arange(period)
src = em_open[:, None] + offsets[None, :]
mask = src <= n_time
vals = np.where(mask, exp_path[rows[:, None], np.where(mask, src, 0)],
0.0)
interest = r_m * k_exp * vals.sum(axis=1)
expected_close = k_exp * exp_path[rows, em_c]
release = opening + interest - expected_close
closing = k_obs * obs_path[rows, em_c]
experience = closing - expected_close
return opening, interest, release, experience, closing
(bel_opening, bel_interest, bel_release,
bel_experience, bel_closing) = _fcf_block(p_exp.bel, p_obs.bel)
(ra_opening, ra_interest, ra_release,
ra_experience, ra_closing) = _fcf_block(p_exp.ra, p_obs.ra)
# The paragraph-45 future-service change: exactly minus the
# observed-vs-expected FCF difference. The 45(b)/45(c) lines are a
# disclosure split of this exact total -- the fee line weighted
# end-of-month (the fee is skimmed on the grown account value, while
# variable_fee_path discounts mid-month), so a pure account-value move
# with no guarantee and no crediting floor lands entirely in 45(b).
x = -(bel_experience + ra_experience)
fee_exp = p_exp.variable_fee_path[rows, em_c]
fee_obs = p_obs.variable_fee_path[rows, em_c]
csm_fv_share = v_half * k_obs * (fee_obs - fee_exp)
csm_future_service = x - csm_fv_share
# B96(a)/B97(c) premium experience -- the VFA mirror of the gmm.settle
# split. The actual premium received less the expected (on-track) premium
# over the period splits by the entity's future-service fraction: the
# future leg adjusts the CSM (B96(a), a NEW future-service change with no
# BEL/RA counterpart, so it stays OUTSIDE the csm_fv_share / csm_future_
# service cross-tie -- it enters the paragraph-45 algebra on top of x); the
# current/past leg is a P&L memo (B97(c)). Absent actual_premium => zero on
# both lines (byte-identical to the pre-feature settle).
pe_frac = np.asarray(premium_experience_future_fraction, dtype=np.float64)
if pe_frac.ndim > 1 or (pe_frac.ndim == 1 and pe_frac.shape[0] != n_mp):
raise ValueError(
"premium_experience_future_fraction must be a scalar or one entry "
f"per model point ({n_mp}), got shape {pe_frac.shape}")
if (not np.all(np.isfinite(pe_frac))
or np.any(pe_frac < 0.0) or np.any(pe_frac > 1.0)):
raise ValueError(
"premium_experience_future_fraction must be finite and lie in "
"[0, 1] (the entity's split of the premium experience between "
"future service -> CSM and current/past service -> P&L); got "
f"{premium_experience_future_fraction}")
if state.actual_premium is not None:
pe_off = np.arange(period)
pe_src = em_open[:, None] + pe_off[None, :]
pe_mask = pe_src < n_time
exp_premium = k_exp * np.where(
pe_mask, proj.premium_cf[rows[:, None], np.where(pe_mask, pe_src, 0)],
0.0).sum(axis=1)
premium_experience = (np.asarray(state.actual_premium,
dtype=np.float64) - exp_premium)
else:
premium_experience = np.zeros(n_mp)
csm_premium_experience = pe_frac * premium_experience
premium_experience_revenue = (1.0 - pe_frac) * premium_experience
# paragraph 50(a)/51 incurred-service loss-component channel (the VFA
# mirror of gmm.settle). paragraph 51(a) allocates claims and expenses
# excluding investment components -- for VFA the investment component IS the
# account value, so the insurance "claims" are the guarantee excess (the
# GMDB/GMAB benefit above the account value). The pool is therefore
# guarantee_excess + expenses + RA (inherently IC-excluded). The loss
# component accretes r x the pool interest (51c) and amortises r x the pool
# release (50(a)); r = lc_open / pool_open is re-derived every period so the
# LC runs to zero by the end of coverage (52). A profitable book
# (lc_open == 0) is byte-identical. The algebra below acts on the
# POST-amortisation loss component.
ge_o, ge_i, ge_r, _, _ = _fcf_block(p_exp.guarantee_excess_pv,
p_exp.guarantee_excess_pv)
ex_o, ex_i, ex_r, _, _ = _fcf_block(p_exp.expense_pv, p_exp.expense_pv)
pool_open = ge_o + ex_o + ra_opening
lc_ratio = np.where(pool_open > 0.0,
lc_open / np.where(pool_open > 0.0, pool_open, 1.0), 0.0)
loss_component_finance = lc_ratio * (ge_i + ex_i + ra_interest)
loss_component_amortised = lc_ratio * (ge_r + ex_r + ra_release)
lc_after_incurred = lc_open + loss_component_finance - loss_component_amortised
# B96(c) investment-component experience (VFA): the investment component is
# the account value returned on exits (benefit_cf - guarantee_excess_cf).
# The expected less the actual account value payable over the period adjusts
# the CSM (the whole difference, no fraction -- B96(c) is entirely future
# service); the account value does not touch insurance revenue. Absent
# actual_investment_component => zero (byte-identical).
ic_cf = p_exp.benefit_cf - p_exp.guarantee_excess_cf
ic_src = em_open[:, None] + np.arange(period)[None, :]
ic_mask = ic_src < n_time
expected_ic = k_exp * np.where(
ic_mask, ic_cf[rows[:, None], np.where(ic_mask, ic_src, 0)],
0.0).sum(axis=1)
if state.actual_investment_component is not None:
actual_ic = np.asarray(state.actual_investment_component,
dtype=np.float64)
csm_investment_experience = expected_ic - actual_ic
else:
csm_investment_experience = np.zeros(n_mp)
# CSM / loss-component algebra (paragraphs 45 / 48 / 50(b)). Accrete by
# direct compounding (NOT the monthly roll -- that would interleave
# releases and double-count against the single B119 release below). The
# premium- and investment-experience future legs are new future-service
# changes with no BEL/RA counterpart, so they enter here on top of x.
accreted = prior_csm * (1.0 + r_m) ** period
csm_accretion = accreted - prior_csm
csm_after, lc_reversed, lc_recognised, lc_closing = (
_csm_loss_component_step(
accreted, x + csm_premium_experience + csm_investment_experience,
lc_after_incurred))
# Paragraph-B119 release, once, on the post-adjustment balance: units
# provided in the period (expected basis) over those provided plus
# expected from the OPENING date (the closing-date remainder at the
# observed count). With on-track experience this equals the monthly
# carry's telescoped release exactly; at a final settlement the future
# units are zero and the whole balance releases.
cu_tail = np.concatenate(
[np.cumsum(inforce[:, ::-1], axis=1)[:, ::-1], np.zeros((n_mp, 1))],
axis=1)
cu_period = k_exp * (cu_tail[rows, em_open] - cu_tail[rows, em_c])
cu_future = k_obs * cu_tail[rows, em_c]
denom = cu_period + cu_future
eps = 1e-12 * np.where(denom > 0.0, denom, 1.0)
live = denom > eps
# No coverage units at all (full derecognition -- a mass surrender / all
# exits) releases the WHOLE remaining CSM (B119 / paragraph 76), frac=1,
# matching gmm.settle; a 0.0 fallback would strand the CSM of a group that
# no longer provides coverage.
frac = np.where(live, cu_period / np.where(live, denom, 1.0), 1.0)
csm_release = csm_after * frac
csm_closing = csm_after - csm_release
# Liability for incurred claims (paragraphs 40(b) / 42(c) / 103(b)) -- the
# VFA mirror of the GMM/PAA LIC block. Benefit claims build it up as
# incurred (42(a)) and run it off over the settlement pattern. The LIC is
# measured at fulfilment cash flows -- the discounted PV of the unpaid
# run-off (42(c)). It carries NO risk adjustment: the VFA RA prices expense
# risk only (z x expense_cv x pv_expenses), the benefit risk sitting in the
# variable fee, so the incurred benefits carry no RA in the LIC either. This
# INHERITS the engine's VFA-RA-is-expense-only convention -- a benefit RA on
# the LIC (paragraphs 32/37) would first require pricing benefit RA in the
# VFA LRC; adding it here alone would make the LIC and LRC inconsistent.
# claims_incurred and claims_paid stay NOMINAL (claims_paid the residual on
# the undiscounted trajectory p_exp.lic_path); lic_finance is the reconciling
# residual -- the 42(c) discount unwind plus the discounting measurement
# effect. p_exp.lic_path is all-zero without a settlement_pattern (claims paid as
# incurred, the LIC zero at both dates and lic_finance zero).
#
# DISCOUNT RATE -- the LIC discounts at basis.discount_monthly (the IFRS 17
# discount curve), NOT the underlying-items return r_m the rest of the VFA
# measurement uses. This is deliberate: an INCURRED claim is a fixed,
# determined amount that no longer varies with the underlying items, so its
# liability discounts at a rate that does not reflect underlying-item
# variability (B74) -- the same rate the GMM/PAA LIC uses. r_m is for the
# not-yet-incurred, account-value-linked future benefits. (Pinned by an
# off-diagonal test where discount_annual != investment_return.)
offsets = np.arange(period)
inc_src = em_open[:, None] + offsets[None, :]
inc_mask = inc_src < n_time
benefit = p_exp.benefit_cf
claims_incurred = k_exp * np.where(
inc_mask, benefit[rows[:, None], np.where(inc_mask, inc_src, 0)],
0.0).sum(axis=1)
claims_paid = (k_exp * p_exp.lic_path[rows, em_open] + claims_incurred
- k_exp * p_exp.lic_path[rows, em_c])
if basis.settlement_pattern is not None:
lic_disc = _settlement_lic_discounted(
benefit, basis.settlement_pattern, basis.discount_monthly)
lic_opening = k_exp * lic_disc[rows, em_open]
lic_closing = k_exp * lic_disc[rows, em_c]
else:
lic_opening = k_exp * p_exp.lic_path[rows, em_open]
lic_closing = k_exp * p_exp.lic_path[rows, em_c]
lic_finance = lic_closing - lic_opening - claims_incurred + claims_paid
# B97(b)/(c) within-period claims and expense experience (the gmm.settle
# mirror): the actual benefits / expenses incurred over the period less the
# expected, recognised in the insurance service result (P&L memos -- not the
# CSM, no balance recursion). Expected claims are claims_incurred (above);
# expected expenses are the period expense run at the expected scale. Absent
# state.actual_claims / state.actual_expenses => zero (byte-identical).
exp_expenses = k_exp * np.where(
inc_mask, proj.expense_cf[rows[:, None], np.where(inc_mask, inc_src, 0)],
0.0).sum(axis=1)
if state.actual_claims is not None:
claims_experience = (np.asarray(state.actual_claims, dtype=np.float64)
- claims_incurred)
else:
claims_experience = np.zeros(n_mp)
if state.actual_expenses is not None:
expense_experience = (np.asarray(state.actual_expenses, dtype=np.float64)
- exp_expenses)
else:
expense_experience = np.zeros(n_mp)
return SettlementMovement(
period_months=period,
bel_opening=bel_opening,
bel_interest=bel_interest,
bel_release=bel_release,
bel_experience=bel_experience,
bel_closing=bel_closing,
ra_opening=ra_opening,
ra_interest=ra_interest,
ra_release=ra_release,
ra_experience=ra_experience,
ra_closing=ra_closing,
csm_opening=prior_csm,
csm_accretion=csm_accretion,
csm_fv_share=csm_fv_share,
csm_future_service=csm_future_service,
csm_premium_experience=csm_premium_experience,
premium_experience_revenue=premium_experience_revenue,
csm_investment_experience=csm_investment_experience,
claims_experience=claims_experience,
expense_experience=expense_experience,
loss_component_reversed=lc_reversed,
loss_component_recognised=lc_recognised,
csm_release=csm_release,
csm_closing=csm_closing,
loss_component_opening=lc_open,
loss_component_finance=loss_component_finance,
loss_component_amortised=loss_component_amortised,
loss_component_closing=lc_closing,
variable_fee_closing=k_obs * fee_obs,
coverage_units_provided=cu_period,
coverage_units_future=cu_future,
account_value_closing=observed_av,
lic_opening=lic_opening,
claims_incurred=claims_incurred,
lic_finance=lic_finance,
claims_paid=claims_paid,
lic_closing=lic_closing,
lock_in_rate=float(state.lock_in_rate),
model_points=model_points,
)
[문서]
def settle_aggregate(
model_points: ModelPoints,
state: "InforceState",
basis: Basis,
*,
period_months: int | None = None,
chunk_size: int = 200_000,
premium_experience_future_fraction: float | FloatArray = 0.0,
) -> "SettlementAggregate":
"""Portfolio-total paragraph-45 settlement in bounded memory.
:func:`settle` materialises ``(n_mp, n_time)`` projection intermediates
-- two forward projections over the whole book -- so a million-policy
close would peak far beyond memory. Every line of the settlement
movement is additive across contracts, so this runs :func:`settle`
over row blocks of ``chunk_size`` model points and accumulates only the
scalar line totals; peak memory is ``O(chunk_size x n_time)``
regardless of ``n_mp``.
Returns a :class:`~fastcashflow.vfa.SettlementAggregate`: the
movement's lines summed, movement-positive (``reconcile`` applies the
display negation and reproduces the per-MP movement's table exactly).
The aggregate cannot be chained -- ``closing_inputs()`` raises; chain
per-MP movements instead. ``state`` joins ``model_points`` by mp_id
once, before chunking, so a period-close file in its own row order
never pairs one contract's rows with another's prior balances.
"""
if chunk_size < 1:
raise ValueError(f"chunk_size must be >= 1, got {chunk_size}")
period = 12 if period_months is None else int(period_months)
if period < 1:
raise ValueError(f"period_months must be >= 1, got {period}")
# One global mp_id join (and stale-snapshot check) BEFORE slicing, so a
# chunk's model points and state rows always belong to the same
# contracts; the per-chunk settle re-checks the aligned pair (a no-op).
state = _reconcile_state(model_points, state)
n_mp = model_points.n_mp
# A per-MP fraction is sliced per chunk so the aggregate equals the per-MP
# settle sum even when the split varies by contract (the per-chunk settle
# re-validates the value range / finiteness).
pe_frac = np.asarray(premium_experience_future_fraction, dtype=np.float64)
if pe_frac.ndim > 1 or (pe_frac.ndim == 1 and pe_frac.shape[0] != n_mp):
raise ValueError(
"premium_experience_future_fraction must be a scalar or one entry "
f"per model point ({n_mp}), got shape {pe_frac.shape}")
# Per-chunk partial sums, combined with fsum so the total does not
# depend on the chunking (compensated summation: chunk_size is a memory
# knob, never a numbers knob).
parts: dict[str, list[float]] = {n: [] for n in _VFA_SETTLEMENT_LINES}
for start in range(0, n_mp, chunk_size):
idx = np.arange(start, min(start + chunk_size, n_mp))
frac_arg = (float(pe_frac) if pe_frac.ndim == 0 else pe_frac[idx])
mv = settle(model_points.subset(idx), state.subset(idx), basis,
period_months=period,
premium_experience_future_fraction=frac_arg)
for name in _VFA_SETTLEMENT_LINES:
parts[name].append(float(getattr(mv, name).sum()))
return SettlementAggregate(
period_months=period, lock_in_rate=float(state.lock_in_rate),
**{name: math.fsum(vals) for name, vals in parts.items()})
[문서]
def settle_stream(
input_path,
output_dir,
basis: Basis,
*,
calculation_methods=None,
state_path=None,
period_months: int | None = None,
chunk_size: int = 200_000,
id_column: str | None = None,
validate_unique_mp_id: bool = True,
) -> int:
"""Stream a paragraph-45 period close through a parquet file, chunk by
chunk.
The out-of-core variant of :func:`settle` and the VFA counterpart of
:func:`fastcashflow.gmm.settle_stream`. The VFA base is a single
policies frame (account value + guarantee floors, no coverages), so
``input_path`` is read in ``chunk_size`` blocks, each block's
``(ModelPoints, InforceState)`` pair is settled, and the per-MP
settlement movements land as one ``part-NNNNN.parquet`` per chunk under
``output_dir`` -- every movement line plus the ``measurement_basis``
marker. Returns the model points processed.
Input layouts, as in the GMM variant: ONE combined file (policies spec
plus the closing-state columns, including the observed ``account_value``
and the ``prior_count`` / ``prior_account_value`` /
``prior_loss_component`` figures :func:`settle` needs) or TWO files
(policies parquet + ``state_path`` state parquet, semi-joined per chunk
on ``mp_id`` with the global id sets validated bidirectionally).
``lock_in_rate`` must be uniform across the book (v1 scalar; the VFA
carries it as a state echo only).
**Chaining on disk**: each part carries ``count``, ``lock_in_rate``,
``elapsed_months``, ``account_value_closing`` and the closing balances,
so the next period's state file is assembled from the parts alone
(``prior_csm <- csm_closing``, ``prior_loss_component <-
loss_component_closing``, ``prior_count <- count``,
``prior_account_value <- account_value_closing``, then advance to the
next observation) -- the disk side of
:meth:`SettlementMovement.closing_inputs()
<fastcashflow.vfa.SettlementMovement.closing_inputs>`.
"""
from fastcashflow.io import _settle_stream_driver
basis = _single_basis(basis, entry="vfa.settle_stream")
return _settle_stream_driver(
input_path, output_dir, state_path=state_path, chunk_size=chunk_size,
id_column=id_column, validate_unique_mp_id=validate_unique_mp_id,
build_mp=lambda spec: _vfa_model_points_from_frame(
spec, calculation_methods),
settle_fn=lambda mp, st: settle(mp, st, basis,
period_months=period_months),
entry="vfa.settle_stream",
)
[문서]
def recognition_schedule(
model_points: ModelPoints,
state: "InforceState",
basis: Basis,
*,
band_edges_months=(12, 36, 60),
period_months: int | None = None,
):
"""Paragraph-109 maturity-band disclosure for a settled VFA book.
The VFA counterpart of :func:`~fastcashflow.gmm.recognition_schedule`:
allocates the :func:`settle` closing CSM to maturity bands by each
contract's forward coverage-unit (in-force) fraction, so the bands sum to
the closing CSM -- when, in maturity terms, the remaining CSM is expected
to be recognised in profit or loss. Onerous contracts carry no CSM and
contribute nothing. ``band_edges_months`` are the band boundaries in months
from the valuation date (default 12 / 36 / 60); ``period_months`` is the
settlement period (default 12).
"""
from fastcashflow._measurement.recognition import (
_build_schedule, _validate_band_edges)
edges = _validate_band_edges(band_edges_months)
mv = settle(model_points, state, basis, period_months=period_months)
inforce = measure(model_points, basis, full=True).cashflows.inforce
em = np.asarray(model_points.elapsed_months, dtype=np.int64)
boundary = np.asarray(model_points.contract_boundary_months, dtype=np.int64)
return _build_schedule(
np.asarray(mv.csm_closing, dtype=np.float64), inforce, em, boundary,
edges)
def guarantee_tvog(
model_points: ModelPoints, basis: Basis, return_scenarios: FloatArray
) -> TVOG:
"""Total guarantee time value of a VFA / universal-life book.
Sums the two guarantees a participating account can carry -- the
minimum-crediting-rate floor (:func:`measure_tvog`) and the GMDB / GMAB
account-value floors (:func:`measure`, summed over the model points).
They bite on disjoint regions of the account value, so the sum is the book's
full guarantee time value. A book with no crediting guarantee (every
``minimum_crediting_rate`` is :data:`NO_GUARANTEE_RATE`) contributes a zero
crediting floor rather than raising. ``return_scenarios`` is the shared
``(n_scenarios, n_time)`` set of monthly underlying-items returns.
"""
g = np.asarray(model_points.minimum_crediting_rate, dtype=np.float64)
if g.size == 0 or np.all(g == NO_GUARANTEE_RATE):
credited_rate_floor = 0.0
else:
credited_rate_floor = measure_tvog(
model_points, basis, return_scenarios).time_value
account_floor = float(np.sum(measure(
model_points, basis, return_scenarios=return_scenarios).time_value))
return TVOG(
credited_rate_floor=credited_rate_floor, account_floor=account_floor)