"""Time value of options and guarantees (TVOG) for VFA business.
A direct-participation (VFA) contract may carry a *minimum guaranteed
crediting rate*: the account is credited ``max(return, guarantee)`` each
period, so the entity funds the shortfall whenever the return falls below
the guarantee.
Because ``max`` is convex, a deterministic projection at the central return
understates the guarantee's cost -- it sees only the *intrinsic value*, the
cost if the future equals the central scenario. The extra cost that comes
from return volatility -- the *time value* -- appears only when the contract
is valued over many scenarios. Their sum is the guarantee's total cost::
total value = intrinsic value + time value (TVOG)
``measure_tvog`` values the account-value benefits under N return scenarios
and reports this split. The scenarios are an input -- fastcashflow is the
engine, not an economic scenario generator.
This is where a stochastic valuation genuinely changes the answer. For
linear protection business the stochastic mean equals the deterministic
result; a guarantee's time value, by contrast, is invisible to any single
deterministic run.
"""
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
from fastcashflow._typing import FloatArray
from fastcashflow.basis import Basis
from fastcashflow.model_points import (
ModelPoints, NO_GUARANTEE_RATE, validate_crediting_rate,
)
from fastcashflow.projection import Cashflows, project_cashflows
[문서]
@dataclass(frozen=True, slots=True, eq=False)
class TVOGResult:
"""The cost of a minimum guarantee, split into intrinsic and time value.
``guarantee_cost`` is the ``(n_scenarios,)`` present value of the
guarantee under each scenario -- its distribution. ``intrinsic_value``
is the cost in the central (deterministic) scenario; ``time_value`` is
the TVOG, the mean cost in excess of the intrinsic value; and
:attr:`total_value` is their sum, the guarantee's full economic cost.
"""
guarantee_cost: FloatArray # (n_scenarios,) -- PV cost of the guarantee
intrinsic_value: float # cost in the central deterministic scenario
time_value: float # TVOG -- mean cost less the intrinsic value
@property
def total_value(self) -> float:
"""Intrinsic value plus time value -- the guarantee's full cost."""
return self.intrinsic_value + self.time_value
def _validate_return_scenarios(return_scenarios: FloatArray) -> FloatArray:
"""Reject scenario sets the time-value kernel cannot price.
An empty set reduces the scenario mean to NaN (which flows into the CSM and
loss component); a non-finite return propagates NaN/inf; a monthly return of
-100% or worse sign-flips the ``1 / (1 + r)`` discount and returns a
plausible-looking wrong number. Returns the validated float array.
"""
rs = np.asarray(return_scenarios, dtype=np.float64)
if rs.shape[0] < 1:
raise ValueError("return_scenarios must contain at least one scenario row")
if not np.all(np.isfinite(rs)):
raise ValueError("return_scenarios must be finite")
if np.any(rs <= -1.0):
raise ValueError(
"return_scenarios must be greater than -1 -- a monthly return of "
"-100% or worse is invalid"
)
return rs
def credited_monthly_rate(
monthly_return: FloatArray, annual_rate: FloatArray
) -> FloatArray:
"""The monthly rate credited to a VFA account under its minimum guarantee.
Each month the account is credited ``max(monthly_return, monthly_floor)``,
where ``monthly_floor`` is the monthly equivalent of the annual
``minimum_crediting_rate``. A rate of :data:`NO_GUARANTEE_RATE` carries no
crediting guarantee -- the bare ``monthly_return`` is credited, which may be
negative -- while ``0.0`` is a real 0% floor (principal protection). A
positive rate floors the credited rate at its monthly equivalent.
``annual_rate`` is a scalar or an array broadcastable against
``monthly_return`` (a per-model-point vector, a scenario matrix, or the
central path). This is the single site the crediting floor is applied:
every deterministic, stochastic and maturity path routes through here, so
no path can silently omit the floor. Callers validate the rate domain
(:func:`validate_crediting_rate`) at the boundary, so the only sub-zero
value reaching here is the exact sentinel.
"""
annual_rate = np.asarray(annual_rate, dtype=np.float64)
monthly_floor = (1.0 + annual_rate) ** (1.0 / 12.0) - 1.0
# No guarantee -> floor at -inf, so the maximum leaves the return untouched.
floor = np.where(annual_rate == NO_GUARANTEE_RATE, -np.inf, monthly_floor)
return np.maximum(monthly_return, floor)
def _av_and_discount(
monthly_credit: FloatArray, monthly_return: FloatArray, fund_fee_m: float
) -> tuple[FloatArray, FloatArray]:
"""Return ``(av_factor, discount_factor)``, each ``(n_scenarios, n_time)``.
``av_factor[s, t]`` is the account-value multiplier at the start of month
``t`` -- the product of ``(1 + credit)(1 - fee)`` over the months before
``t``. ``discount_factor[s, t]`` is the product of ``1 / (1 + return)`` over those
months. Kept separate so a non-linear payoff (a guarantee floor on the
account value) can use the account-value path on its own; their product is
:func:`_discounted_growth`.
"""
growth = (1.0 + monthly_credit) * (1.0 - fund_fee_m)
n = monthly_credit.shape[0]
ones = np.ones((n, 1))
av_factor = np.concatenate([ones, np.cumprod(growth, axis=1)[:, :-1]], axis=1)
discount_factor = np.concatenate(
[ones, np.cumprod(1.0 / (1.0 + monthly_return), axis=1)[:, :-1]], axis=1
)
return av_factor, discount_factor
def _discounted_growth(
monthly_credit: FloatArray, monthly_return: FloatArray, fund_fee_m: float
) -> FloatArray:
"""The ``(n_scenarios, n_time)`` factor ``av_factor * discount_factor``.
The account grows each month at the credited rate net of the fund fee;
the exit benefit discounts at the underlying-items return. Entry
``[s, t]`` is the product of those two over the months before ``t``.
"""
av_factor, discount_factor = _av_and_discount(monthly_credit, monthly_return, fund_fee_m)
return av_factor * discount_factor
def tvog_weights(
*,
minimum_crediting_rate: float,
fund_fee: float,
investment_return: float,
return_scenarios: FloatArray,
reduce: bool = True,
) -> FloatArray:
"""Per-month weights for the time value of a minimum guarantee.
``reduce=True`` (default) returns the ``(n_time,)`` scenario-mean weights;
``reduce=False`` returns the pre-mean ``(n_scenarios, n_time)`` matrix (its
mean over axis 0 is the reduced form), for a vectorised per-scenario pass.
Returns a ``(n_time,)`` vector ``w`` for which the TVOG of a book equals
``w @ e``, where ``e[t]`` is the book's total of account value times
policies exiting in month ``t``. The weight is the mean over scenarios of
the guaranteed discounted-growth factor less that factor in the central
scenario -- the extra account-value cost the convex guarantee adds once
returns vary.
The guarantee is taken as a scalar: TVOG is a portfolio-level aggregate
in v1, so per-MP varying guarantees are not yet supported here.
"""
validate_crediting_rate(minimum_crediting_rate)
return_scenarios = _validate_return_scenarios(return_scenarios)
n_time = return_scenarios.shape[1]
if minimum_crediting_rate == NO_GUARANTEE_RATE:
# No crediting guarantee -> the credited rate is the bare return, so the
# account growth and the discount cancel exactly and the credit-rate
# TVOG is identically zero. Return it directly rather than route through
# the separate cumulative growth / discount products, which over/underflow
# (yielding 0 * inf = NaN) for an extreme-but-valid near-ruin return path.
return np.zeros(n_time) if reduce else np.zeros((return_scenarios.shape[0], n_time))
f_m = (1.0 + fund_fee) ** (1.0 / 12.0) - 1.0
r_m = (1.0 + investment_return) ** (1.0 / 12.0) - 1.0
central = np.full((1, n_time), r_m)
stochastic = _discounted_growth(
credited_monthly_rate(return_scenarios, minimum_crediting_rate),
return_scenarios, f_m
) # (n_scenarios, n_time)
central_factor = _discounted_growth(
credited_monthly_rate(central, minimum_crediting_rate), central, f_m
)[0]
w = stochastic - central_factor # (n_scenarios, n_time)
return w.mean(axis=0) if reduce else w
def tvog_term_weight(
*,
minimum_crediting_rate: float,
fund_fee: float,
investment_return: float,
return_scenarios: FloatArray,
reduce: bool = True,
):
"""The maturity term-column credit-rate TVOG weight (one scalar).
``reduce=True`` (default) returns the scalar scenario-mean weight;
``reduce=False`` returns the pre-mean ``(n_scenarios,)`` vector.
A maturity survivor exits at time = term -- one month past the width-n_time
grid :func:`tvog_weights` covers (whose entries weight start-of-month
exits). It stayed in the fund that final month, credited
``max(return, guarantee)`` and charged the fee, so it carries one more month
of the guarantee's discounted-growth factor. Returns the mean-over-scenarios
less-central weight at the matured term, extending each path by that final
month exactly as :func:`guarantee_floor_time_value` does for the GMAB
(paragraph B119), so the deterministic GMAB, the floor time value and the
credit-rate TVOG agree on the maturity date. ``0.0`` when there is no
crediting guarantee -- the short-circuit avoids forming the
over/underflowing cumulative product (0 * inf = NaN) on an
extreme-but-valid near-ruin return path.
"""
validate_crediting_rate(minimum_crediting_rate)
return_scenarios = _validate_return_scenarios(return_scenarios)
n_time = return_scenarios.shape[1]
if minimum_crediting_rate == NO_GUARANTEE_RATE:
return 0.0 if reduce else np.zeros(return_scenarios.shape[0])
f_m = (1.0 + fund_fee) ** (1.0 / 12.0) - 1.0
r_m = (1.0 + investment_return) ** (1.0 / 12.0) - 1.0
central = np.full((1, n_time), r_m)
av_s, discount_factor_s = _av_and_discount(
credited_monthly_rate(return_scenarios, minimum_crediting_rate),
return_scenarios, f_m
)
av_c, discount_factor_c = _av_and_discount(
credited_monthly_rate(central, minimum_crediting_rate), central, f_m
)
av_c, discount_factor_c = av_c[0], discount_factor_c[0]
# One extra month -- the final month's credited growth net of fee on the AV
# factor and one more month of return-discount -- so the factor lands on the
# matured term column (mirror the GMAB extension in guarantee_floor_time_value).
ext_s = (av_s[:, -1]
* (1.0 + credited_monthly_rate(return_scenarios[:, -1],
minimum_crediting_rate))
* (1.0 - f_m)) * (discount_factor_s[:, -1] / (1.0 + return_scenarios[:, -1]))
ext_c = (av_c[-1]
* (1.0 + credited_monthly_rate(r_m, minimum_crediting_rate))
* (1.0 - f_m)) * (discount_factor_c[-1] / (1.0 + r_m))
return float(ext_s.mean() - ext_c) if reduce else (ext_s - ext_c)
def guarantee_floor_time_value(
*,
account_value: FloatArray,
deaths: FloatArray,
maturity_survivors: FloatArray,
term_index: FloatArray,
minimum_death_benefit: FloatArray,
minimum_accumulation_benefit: FloatArray,
minimum_crediting_rate: float,
fund_fee: float,
investment_return: float,
return_scenarios: FloatArray,
reduce: bool = True,
) -> FloatArray:
"""Per-model-point time value of the GMDB and GMAB account-value floors.
``reduce=True`` (default) returns the ``(n_mp,)`` scenario-mean time value;
``reduce=False`` returns the pre-mean ``(n_mp, n_scenarios)`` matrix.
A death pays ``max(account value, GMDB)`` and a maturity pays
``max(account value, GMAB)`` -- put options on the account value, struck at
the guarantee. The deterministic projection prices only their intrinsic
value (the cost in the central scenario); the *time value* -- the extra
cost convexity adds once returns vary -- is the mean cost over the return
scenarios less that central cost. Returns a ``(n_mp,)`` array, the amount
the CSM additionally absorbs at inception. Discounting is at the underlying
return -- the VFA basis, not a risk-neutral measure -- so this time value
is *not* sign-constrained: a deep in-the-money floor can carry a negative
time value, since volatility there mostly lets scenarios escape the floor.
The account value path uses the credited rate ``max(return, guarantee)``;
``minimum_crediting_rate`` is the (scalar, v1) crediting guarantee. The
GMDB / GMAB floors themselves may vary by model point.
"""
validate_crediting_rate(minimum_crediting_rate)
return_scenarios = _validate_return_scenarios(return_scenarios)
n_time = return_scenarios.shape[1]
f_m = (1.0 + fund_fee) ** (1.0 / 12.0) - 1.0
r_m = (1.0 + investment_return) ** (1.0 / 12.0) - 1.0
central = np.full((1, n_time), r_m)
# Account-value multiplier and discount under each scenario, and under the
# central flat-return path (whose floor cost is the intrinsic value). The
# account is credited max(return, guarantee), or the bare return when the
# crediting guarantee is off (NO_GUARANTEE_RATE).
av_s, discount_factor_s = _av_and_discount(
credited_monthly_rate(return_scenarios, minimum_crediting_rate),
return_scenarios, f_m
)
av_c, discount_factor_c = _av_and_discount(
credited_monthly_rate(central, minimum_crediting_rate), central, f_m
)
av_c, discount_factor_c = av_c[0], discount_factor_c[0]
# The GMAB strikes at maturity (time = term) -- the *matured* account value
# after the final month's growth -- one month past the width-n_time path the
# GMDB walks (whose entries are start-of-month values). Extend each path by
# that final month's growth and discount so the maturity index (term) reads
# the matured value and discounts to time term, matching the deterministic
# intrinsic value.
av_s_mat = np.concatenate(
[av_s, (av_s[:, -1]
* (1.0 + credited_monthly_rate(return_scenarios[:, -1],
minimum_crediting_rate))
* (1.0 - f_m))[:, None]], axis=1)
discount_factor_s_mat = np.concatenate(
[discount_factor_s, (discount_factor_s[:, -1] / (1.0 + return_scenarios[:, -1]))[:, None]],
axis=1)
av_c_mat = np.append(
av_c, av_c[-1]
* (1.0 + credited_monthly_rate(r_m, minimum_crediting_rate))
* (1.0 - f_m))
discount_factor_c_mat = np.append(discount_factor_c, discount_factor_c[-1] / (1.0 + r_m))
n_mp = account_value.shape[0]
n_scen = return_scenarios.shape[0]
time_value = np.zeros(n_mp)
tv_mat = np.zeros((n_mp, n_scen))
for mp in range(n_mp):
av0 = account_value[mp]
ti = int(term_index[mp])
# Maturity index (time = term). ``term_index`` is the deterministic
# path's boundary-clamped maturity column, so ti + 1 <= n_time already;
# the min is a defensive belt against a caller passing an unclamped
# term - 1 (a boundary-cut contract then has zero maturity survivors, so
# the clamped read is harmless either way).
mi = min(ti + 1, n_time)
# GMDB: floor excess on the death exits each month (start-of-month
# account value); GMAB: on the maturity survivors at the matured value.
# Cost per scenario, then the mean less the central (intrinsic) cost.
gdb_excess_s = np.maximum(0.0, minimum_death_benefit[mp] - av0 * av_s)
cost_s = (deaths[mp] * gdb_excess_s * discount_factor_s).sum(axis=1)
gab_excess_s = np.maximum(
0.0, minimum_accumulation_benefit[mp] - av0 * av_s_mat[:, mi]
)
cost_s = cost_s + maturity_survivors[mp] * gab_excess_s * discount_factor_s_mat[:, mi]
gdb_excess_c = np.maximum(0.0, minimum_death_benefit[mp] - av0 * av_c)
cost_c = float((deaths[mp] * gdb_excess_c * discount_factor_c).sum())
gab_excess_c = max(
0.0, minimum_accumulation_benefit[mp] - av0 * av_c_mat[mi]
)
cost_c += maturity_survivors[mp] * gab_excess_c * discount_factor_c_mat[mi]
time_value[mp] = float(cost_s.mean()) - cost_c
tv_mat[mp] = cost_s - cost_c
return time_value if reduce else tv_mat
def ul_guarantee_floor_time_value(
*,
account_value0: FloatArray,
face: FloatArray,
prem_to_av: FloatArray,
coi_rate_m: FloatArray,
admin_fee: FloatArray,
account_charge: FloatArray,
gmab: FloatArray,
minimum_crediting_rate: float,
deaths: FloatArray,
maturity_survivors: FloatArray,
boundary: FloatArray,
investment_return: float,
return_scenarios: FloatArray,
reduce: bool = True,
) -> FloatArray:
"""Per-model-point time value of the GMDB / GMAB floors on a UNIVERSAL-LIFE
account book.
``reduce=True`` (default) returns the ``(n_mp,)`` scenario-mean time value;
``reduce=False`` returns the pre-mean ``(n_mp, n_scenarios)`` matrix.
Unlike :func:`guarantee_floor_time_value` (the closed-form variable-annuity
account), the UL account path is additive and path-dependent -- a premium
injection, a cost-of-insurance drain on the net amount at risk, an admin fee
and a charge, with a floor-at-zero clamp -- so the account is RE-ROLLED under
each return scenario (and under the central flat-return path), reproducing the
deterministic within-month order verbatim with the monthly credit replaced by
``credited_monthly_rate(return, minimum_crediting_rate)`` per scenario-month.
A death pays ``max(av_mid, face)`` and a maturity ``max(av_term, gmab)`` -- put
options on the account, struck at the guarantee; the time value is the mean
floor cost over the scenarios less the central-path (intrinsic) cost.
Discounting is at the underlying-items return (the VFA basis), so this is not
sign-constrained. Returns ``(n_mp,)``.
"""
validate_crediting_rate(minimum_crediting_rate)
return_scenarios = _validate_return_scenarios(return_scenarios)
n_scen, n_time = return_scenarios.shape
n_mp = account_value0.shape[0]
r_m = (1.0 + investment_return) ** (1.0 / 12.0) - 1.0
# Central (flat-r_m) path first, then the scenarios. The central credit
# reproduces the deterministic account_credit, so its floor cost is the
# intrinsic value already in the deterministic BEL.
all_returns = np.concatenate(
[np.full((1, n_time), r_m), return_scenarios], axis=0) # (1+n_scen, n_time)
cr = credited_monthly_rate(all_returns, minimum_crediting_rate) # (n_path, n_time)
n_path = all_returns.shape[0]
bound = np.asarray(boundary, dtype=np.int64)
# Start-of-month discount at each path's own return (length n_time+1 so the
# maturity at t=boundary reads). Deaths settle mid-month, maturity at the
# boundary start-of-month -- mirroring the deterministic UL kernel.
discount_factor_bom = np.ones((n_path, n_time + 1))
discount_factor_bom[:, 1:] = np.cumprod(1.0 / (1.0 + all_returns), axis=1)
discount_factor_mid = discount_factor_bom[:, :n_time] * (1.0 + all_returns) ** (-0.5)
cost = np.zeros((n_path, n_mp))
for p in range(n_path):
a = np.asarray(account_value0, dtype=np.float64).copy()
av_term = a.copy()
half = (1.0 + cr[p]) ** 0.5
full = 1.0 + cr[p]
for t in range(n_time):
active = t < bound # per-MP boundary stop
a = a + np.where(active, prem_to_av[:, t], 0.0)
risk = np.maximum(0.0, face - a)
a = a - np.where(active,
admin_fee[t] + coi_rate_m[:, t] * risk
+ account_charge[:, t], 0.0)
a = np.maximum(0.0, a)
av_mid_t = np.where(active, a * half[t], 0.0)
# GMDB floor on this month's death exits.
cost[p] += deaths[:, t] * np.maximum(0.0, face - av_mid_t) * discount_factor_mid[p, t]
a = np.where(active, a * full[t], a)
av_term = np.where(active, a, av_term)
# GMAB floor on the maturity survivors at the matured balance.
cost[p] += (maturity_survivors
* np.maximum(0.0, gmab - av_term)
* discount_factor_bom[p, np.minimum(bound, n_time)])
if reduce:
return cost[1:].mean(axis=0) - cost[0]
return (cost[1:] - cost[0]).T # (n_mp, n_scenarios)
def ul_credit_rate_time_value(
*,
account_value0: FloatArray,
face: FloatArray,
prem_to_av: FloatArray,
coi_rate_m: FloatArray,
admin_fee: FloatArray,
account_charge: FloatArray,
gmab: FloatArray,
minimum_crediting_rate: float,
deaths: FloatArray,
surrenders: FloatArray,
maturity_survivors: FloatArray,
surr_charge_rate: FloatArray,
boundary: FloatArray,
investment_return: float,
return_scenarios: FloatArray,
) -> FloatArray:
"""Per-path cost of the crediting-rate floor on a UNIVERSAL-LIFE account book.
The minimum-crediting-rate guarantee credits ``max(return, floor)`` each
month; the entity funds the shortfall when the return is below the floor.
That funded extra account value is paid out on the policy's account exits.
Unlike :func:`ul_guarantee_floor_time_value` -- which prices a put on the
account from BELOW (``max(0, guarantee - av)``) on a single floored roll --
the crediting-rate floor is the account-value LIFT the guarantee itself funds,
so the account is rolled TWICE per path: once credited at the floor
(``credited_monthly_rate(return, minimum_crediting_rate)``) and once at the
bare return (no floor). Their exit-payout difference is the guarantee's cost.
The cost of insurance is recomputed inside each roll from that roll's own
running balance (``risk = max(0, face - a)``), so the COI feedback -- a higher
credit lifts ``a``, lowers the net amount at risk, lowers the COI drain, lifts
``a`` further -- is captured automatically; the two rolls cannot be a single
closed-form factor (which is why the variable-annuity :func:`tvog_weights`
cannot be reused). The lift is realized only where the account value is what
is paid: a surrender pays ``av_mid * (1 - surr_charge_rate)`` (the lift net of
the surrender charge), a death pays
``max(av_mid, face)`` (the lift only above the face), and a maturity pays
``max(av_term, gmab)`` (the lift only above the GMAB). Below those strikes the
death / maturity payout is unchanged by the floor (it is the separately-valued
GMDB / GMAB floor that bites there), so the crediting-rate and GMDB / GMAB
guarantees partition the account-value region with no double count.
Discounting is at the underlying-items return (the VFA basis). Row 0 of the
returned ``(1 + n_scenarios, n_mp)`` array is the central flat-return path (the
deterministic intrinsic cost already in the BEL); rows 1.. are the scenarios.
"""
validate_crediting_rate(minimum_crediting_rate)
return_scenarios = _validate_return_scenarios(return_scenarios)
n_scen, n_time = return_scenarios.shape
n_mp = account_value0.shape[0]
r_m = (1.0 + investment_return) ** (1.0 / 12.0) - 1.0
# Central (flat-r_m) path first, then the scenarios -- the central credit
# reproduces the deterministic account_credit, so its lift is the intrinsic
# value already in the deterministic BEL.
all_returns = np.concatenate(
[np.full((1, n_time), r_m), return_scenarios], axis=0) # (1+n_scen, n_time)
cr_floor = credited_monthly_rate(all_returns, minimum_crediting_rate)
# The bare counterfactual: no crediting floor, so the raw return is credited.
cr_bare = credited_monthly_rate(all_returns, NO_GUARANTEE_RATE)
n_path = all_returns.shape[0]
bound = np.asarray(boundary, dtype=np.int64)
discount_factor_bom = np.ones((n_path, n_time + 1))
discount_factor_bom[:, 1:] = np.cumprod(1.0 / (1.0 + all_returns), axis=1)
discount_factor_mid = discount_factor_bom[:, :n_time] * (1.0 + all_returns) ** (-0.5)
av0 = np.asarray(account_value0, dtype=np.float64)
cost = np.zeros((n_path, n_mp))
for p in range(n_path):
a_f = av0.copy() # floored-credit account
a_b = av0.copy() # bare-return account
avt_f = a_f.copy()
avt_b = a_b.copy()
half_f = (1.0 + cr_floor[p]) ** 0.5
full_f = 1.0 + cr_floor[p]
half_b = (1.0 + cr_bare[p]) ** 0.5
full_b = 1.0 + cr_bare[p]
for t in range(n_time):
active = t < bound # per-MP boundary stop
charge = admin_fee[t] + account_charge[:, t]
# Floored roll: same within-month order as the deterministic kernel,
# COI recomputed from this roll's own balance.
a_f = a_f + np.where(active, prem_to_av[:, t], 0.0)
a_f = a_f - np.where(active,
charge + coi_rate_m[:, t] * np.maximum(0.0, face - a_f),
0.0)
a_f = np.maximum(0.0, a_f)
av_mid_f = np.where(active, a_f * half_f[t], 0.0)
# Bare roll: identical inputs, the bare return as the credit.
a_b = a_b + np.where(active, prem_to_av[:, t], 0.0)
a_b = a_b - np.where(active,
charge + coi_rate_m[:, t] * np.maximum(0.0, face - a_b),
0.0)
a_b = np.maximum(0.0, a_b)
av_mid_b = np.where(active, a_b * half_b[t], 0.0)
# Death pays max(av_mid, face) (lift only above the face); a surrender
# pays max(0, av_mid * (1 - surr_charge_rate)) -- the exact marginal of
# the deterministic surrender, so the lift is the floored-minus-bare
# net payout (clamped per term to match the deterministic floor-at-zero
# for a >100% charge; a no-op for the supported [0, 1] domain). Both
# settle mid-month.
keep = 1.0 - surr_charge_rate[:, t]
death_lift = np.maximum(av_mid_f, face) - np.maximum(av_mid_b, face)
surr_lift = (np.maximum(0.0, av_mid_f * keep)
- np.maximum(0.0, av_mid_b * keep))
cost[p] += (deaths[:, t] * death_lift
+ surrenders[:, t] * surr_lift) * discount_factor_mid[p, t]
a_f = np.where(active, a_f * full_f[t], a_f)
avt_f = np.where(active, a_f, avt_f)
a_b = np.where(active, a_b * full_b[t], a_b)
avt_b = np.where(active, a_b, avt_b)
# Maturity pays max(av_term, gmab) -- the lift only above the GMAB.
mat_lift = np.maximum(avt_f, gmab) - np.maximum(avt_b, gmab)
cost[p] += maturity_survivors * mat_lift * discount_factor_bom[p, np.minimum(bound, n_time)]
return cost
def _measure_tvog_ul(
model_points: ModelPoints,
basis: Basis,
proj: Cashflows,
return_scenarios: FloatArray,
g_annual: float,
) -> TVOGResult:
"""measure_tvog for a universal-life account book -- the crediting-rate floor.
The closed-form variable-annuity path (:func:`tvog_weights`) cannot price a UL
account (additive, COI feedback), so the account is re-rolled floored vs bare
by :func:`ul_credit_rate_time_value`. Rejects an annuitizing book (the
conversion floor is a different mechanic), mirroring the VFA GMDB / GMAB path.
"""
am = getattr(model_points, "annuitization_months", None)
if am is not None and np.any(np.asarray(am) > 0):
raise NotImplementedError(
"measure_tvog (crediting-rate floor) is not yet supported for an "
"annuitizing universal-life book.")
inforce = proj.inforce
n_mp, n_time = inforce.shape
# Maturity survivors exit at time = term; a boundary-cut contract (term beyond
# the contract boundary) has none -- exactly the within / term_idx clamp the
# variable-annuity path uses.
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)
# Surrender count = all exits less deaths, with the maturity mass peeled out of
# the per-MP boundary column (death and lapse are the only mid-month exits).
inforce_pad = np.concatenate([inforce, np.zeros((n_mp, 1))], axis=1)
surrenders = inforce_pad[:, :-1] - inforce_pad[:, 1:] - proj.deaths # (n_mp, n_time)
surrenders[np.arange(n_mp), term_idx] -= maturity_survivors
from fastcashflow._measurement.account import _roll_inputs
(av0, face, prem_to_av, coi_rate_m, admin_fee, account_charge,
gmab, _g, surr_charge_rate) = _roll_inputs(model_points, basis)
cost = ul_credit_rate_time_value(
account_value0=av0, face=face, prem_to_av=prem_to_av,
coi_rate_m=coi_rate_m, admin_fee=admin_fee,
account_charge=account_charge, gmab=gmab,
minimum_crediting_rate=g_annual,
deaths=proj.deaths, surrenders=surrenders,
maturity_survivors=maturity_survivors,
surr_charge_rate=surr_charge_rate,
boundary=np.asarray(model_points.contract_boundary_months, np.int64),
investment_return=basis.investment_return,
return_scenarios=return_scenarios) # (1 + n_scen, n_mp)
guarantee_cost = cost[1:].sum(axis=1) # (n_scenarios,)
intrinsic_value = float(cost[0].sum())
time_value = float(guarantee_cost.mean() - intrinsic_value)
return TVOGResult(
guarantee_cost=guarantee_cost,
intrinsic_value=intrinsic_value,
time_value=time_value,
)
def measure_tvog(
model_points: ModelPoints, basis: Basis, return_scenarios: FloatArray
) -> TVOGResult:
"""Measure the time value of a VFA contract's minimum-crediting-rate guarantee.
Values the crediting-rate floor only -- the guarantee that the account is
credited ``max(return, minimum_crediting_rate)`` each month. The GMDB / GMAB
account-value floors are NOT included here; their time value is folded into
``vfa.measure(..., return_scenarios).time_value`` instead. This function is
the standalone crediting-rate analysis.
``return_scenarios`` is an ``(n_scenarios, n_time)`` array of monthly
underlying-items returns -- one path per scenario, ``n_time`` being the
projection horizon. The model points must carry a crediting guarantee --
a ``minimum_crediting_rate`` of ``0.0`` (a real 0% floor, ``max(return, 0)``)
or a positive rate. A contract with no crediting guarantee
(``NO_GUARANTEE_RATE``) is rejected: it has no crediting-rate time value to
measure (use ``vfa.measure(..., return_scenarios).time_value`` for the
GMDB / GMAB floor time value instead). In v1 the rate is taken as a
portfolio-wide scalar (per-MP varying rates with stochastic returns are a
future extension), so the column is required to be uniform across rows.
The guarantee cost is the present value of account-value benefits in
excess of the no-guarantee benefits. Its mean over the scenarios is the
total value; the cost in the central scenario (``investment_return``) is
the intrinsic value; the difference is the time value (TVOG).
Maturity survivors are weighted at the matured term (time = term, one month
past their term - 1 exit column), the same re-seat the folded credit-rate
TVOG in :func:`vfa.measure` uses, so the two agree on a mixed-term book.
"""
validate_crediting_rate(model_points.minimum_crediting_rate)
g_unique = np.unique(np.asarray(model_points.minimum_crediting_rate,
dtype=np.float64))
if g_unique.size > 1:
raise NotImplementedError(
"measure_tvog requires a uniform minimum_crediting_rate across "
"model points in v1; per-MP varying rates with stochastic "
"returns are a future extension"
)
if g_unique.size == 0 or float(g_unique[0]) == NO_GUARANTEE_RATE:
raise ValueError(
"measure_tvog values a crediting-rate guarantee, and this contract "
"carries none (minimum_crediting_rate == NO_GUARANTEE_RATE). A 0.0 "
"rate is a real 0% floor and is valued here; the GMDB / GMAB "
"account-value floors are valued by "
"vfa.measure(..., return_scenarios).time_value instead."
)
g_annual = float(g_unique[0])
return_scenarios = np.asarray(return_scenarios, dtype=np.float64)
if return_scenarios.ndim != 2:
raise ValueError("return_scenarios must be 2-D (n_scenarios, n_time)")
return_scenarios = _validate_return_scenarios(return_scenarios)
proj = project_cashflows(model_points, basis)
inforce = proj.inforce
n_mp, n_time = inforce.shape
if return_scenarios.shape[1] != n_time:
raise ValueError(
f"return_scenarios must have {n_time} columns (the projection "
f"horizon), got {return_scenarios.shape[1]}"
)
# A universal-life account book takes the re-rolled crediting-rate floor; the
# closed-form path below is the variable-annuity (account_value-only) case.
if proj.account is not None:
return _measure_tvog_ul(model_points, basis, proj, return_scenarios, g_annual)
# Portfolio total of (account value x policies exiting) per month -- the
# benefit base before any return growth.
inforce_pad = np.concatenate([inforce, np.zeros((n_mp, 1))], axis=1)
exits = inforce_pad[:, :-1] - inforce_pad[:, 1:]
exit_value = (model_points.account_value[:, None] * exits).sum(axis=0) # (n_time,)
# Maturity survivors exit at time = term, not term - 1; weight them at the
# matured-term discounted-growth column (one month past the n_time grid) so
# this standalone path matches the folded vfa.measure credit-rate TVOG
# (test_vfa_tvog_matches_measure_tvog). Boundary-clamp the maturity column
# exactly as the deterministic VFA path. Peel the per-MP maturity
# account-value mass out of exit_value BEFORE the term re-seat (a mixed-term
# book has the maturity slice at different columns per model point).
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)
mat_value = model_points.account_value * maturity_survivors # (n_mp,)
nm_exit_value = exit_value.copy()
np.add.at(nm_exit_value, term_idx, -mat_value)
f_m = (1.0 + basis.fund_fee) ** (1.0 / 12.0) - 1.0
# Without a guarantee the return cancels between growth and discount, so the
# no-guarantee benefit is identical in every scenario. Non-maturity exits on
# the n_time fee grid; the maturity slice takes one extra (1 - f_m) month (it
# stays in the fund to time = term), keeping the intrinsic value an
# apples-to-apples central comparison.
fee_grid = (1.0 - f_m) ** np.arange(n_time)
no_guarantee = float(nm_exit_value @ fee_grid
+ (mat_value * (1.0 - f_m) ** (term_idx + 1)).sum())
r_m = (1.0 + basis.investment_return) ** (1.0 / 12.0) - 1.0
central = np.full((1, n_time), r_m)
# Stochastic: credit max(return, guarantee), discount at the return. Extend
# each path one month so the maturity slice reads the matured term column.
dg_s = _discounted_growth(
credited_monthly_rate(return_scenarios, g_annual), return_scenarios, f_m)
ext_s = (dg_s[:, -1]
* (1.0 + credited_monthly_rate(return_scenarios[:, -1], g_annual))
* (1.0 - f_m) / (1.0 + return_scenarios[:, -1]))
dg_s_mat = np.concatenate([dg_s, ext_s[:, None]], axis=1) # (n_scen, n_time+1)
pv_stochastic = dg_s @ nm_exit_value + dg_s_mat[:, term_idx + 1] @ mat_value
guarantee_cost = pv_stochastic - no_guarantee
# Deterministic central scenario -- a flat return path, the intrinsic value.
dg_c = _discounted_growth(
credited_monthly_rate(central, g_annual), central, f_m)[0]
ext_c = (dg_c[-1] * (1.0 + credited_monthly_rate(r_m, g_annual))
* (1.0 - f_m) / (1.0 + r_m))
dg_c_mat = np.append(dg_c, ext_c) # (n_time+1,)
pv_central = float(dg_c @ nm_exit_value + dg_c_mat[term_idx + 1] @ mat_value)
intrinsic_value = float(pv_central - no_guarantee)
time_value = float(guarantee_cost.mean() - intrinsic_value)
return TVOGResult(
guarantee_cost=guarantee_cost,
intrinsic_value=intrinsic_value,
time_value=time_value,
)