fastcashflow._measurement.tvog의 소스 코드

"""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, )