fastcashflow.tvog의 소스 코드

"""Time value of options and guarantees (TVOG) for VFA business.

A direct-participation (VFA) contract may carry a *minimum guaranteed
credited 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.modelpoints import ModelPoints
from fastcashflow.projection import 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 _av_and_discount( monthly_credit: FloatArray, monthly_return: FloatArray, fund_fee_m: float ) -> tuple[FloatArray, FloatArray]: """Return ``(av_factor, discount)``, 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[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 = np.concatenate( [ones, np.cumprod(1.0 / (1.0 + monthly_return), axis=1)[:, :-1]], axis=1 ) return av_factor, discount def _discounted_growth( monthly_credit: FloatArray, monthly_return: FloatArray, fund_fee_m: float ) -> FloatArray: """The ``(n_scenarios, n_time)`` factor ``av_factor * discount``. 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 = _av_and_discount(monthly_credit, monthly_return, fund_fee_m) return av_factor * discount def _pv_account_benefits( exit_value: FloatArray, monthly_credit: FloatArray, monthly_return: FloatArray, fund_fee_m: float, ) -> FloatArray: """PV of account-value exit benefits given monthly credit and return paths. ``exit_value`` is the ``(n_time,)`` portfolio total of account value times exiting policies per month, before any growth. Returns the ``(n_scenarios,)`` present value. """ return _discounted_growth(monthly_credit, monthly_return, fund_fee_m) @ exit_value def tvog_weights( *, minimum_crediting_rate: float, fund_fee: float, investment_return: float, return_scenarios: FloatArray, ) -> FloatArray: """Per-month weights for the time value of a minimum guarantee. 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. """ return_scenarios = np.asarray(return_scenarios, dtype=np.float64) n_time = return_scenarios.shape[1] f_m = (1.0 + fund_fee) ** (1.0 / 12.0) - 1.0 g_m = (1.0 + minimum_crediting_rate) ** (1.0 / 12.0) - 1.0 r_m = (1.0 + investment_return) ** (1.0 / 12.0) - 1.0 stochastic = _discounted_growth( np.maximum(return_scenarios, g_m), return_scenarios, f_m ).mean(axis=0) central = np.full((1, n_time), r_m) central_factor = _discounted_growth(np.maximum(central, g_m), central, f_m)[0] return stochastic - central_factor 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, ) -> FloatArray: """Per-model-point time value of the GMDB and GMAB account-value floors. 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. """ return_scenarios = np.asarray(return_scenarios, dtype=np.float64) n_time = return_scenarios.shape[1] f_m = (1.0 + fund_fee) ** (1.0 / 12.0) - 1.0 g_m = (1.0 + minimum_crediting_rate) ** (1.0 / 12.0) - 1.0 r_m = (1.0 + investment_return) ** (1.0 / 12.0) - 1.0 # Account-value multiplier and discount under each scenario, and under the # central flat-return path (whose floor cost is the intrinsic value). av_s, disc_s = _av_and_discount( np.maximum(return_scenarios, g_m), return_scenarios, f_m ) central = np.full((1, n_time), r_m) av_c, disc_c = _av_and_discount(np.maximum(central, g_m), central, f_m) av_c, disc_c = av_c[0], disc_c[0] n_mp = account_value.shape[0] time_value = np.zeros(n_mp) for mp in range(n_mp): av0 = account_value[mp] ti = int(term_index[mp]) # GMDB: floor excess on the death exits each month; GMAB: on the # maturity survivors at the term column. 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 * disc_s).sum(axis=1) gab_excess_s = np.maximum( 0.0, minimum_accumulation_benefit[mp] - av0 * av_s[:, ti] ) cost_s = cost_s + maturity_survivors[mp] * gab_excess_s * disc_s[:, ti] gdb_excess_c = np.maximum(0.0, minimum_death_benefit[mp] - av0 * av_c) cost_c = float((deaths[mp] * gdb_excess_c * disc_c).sum()) gab_excess_c = max( 0.0, minimum_accumulation_benefit[mp] - av0 * av_c[ti] ) cost_c += maturity_survivors[mp] * gab_excess_c * disc_c[ti] time_value[mp] = float(cost_s.mean()) - cost_c return time_value def measure_tvog( model_points: ModelPoints, basis: Basis, return_scenarios: FloatArray ) -> TVOGResult: """Measure the time value of a VFA contract's minimum guarantee. ``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 non-zero ``minimum_crediting_rate`` (otherwise there is no guarantee to value); 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). """ 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]) == 0.0: raise ValueError( "measure_tvog requires a non-zero minimum_crediting_rate on the " "model points -- there is no guarantee to value otherwise" ) 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)") 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]}" ) # 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,) f_m = (1.0 + basis.fund_fee) ** (1.0 / 12.0) - 1.0 g_m = (1.0 + g_annual) ** (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. no_guarantee = float(np.sum(exit_value * (1.0 - f_m) ** np.arange(n_time))) # Stochastic: credit max(return, guarantee), discount at the return. credit = np.maximum(return_scenarios, g_m) pv_stochastic = _pv_account_benefits(exit_value, credit, return_scenarios, f_m) guarantee_cost = pv_stochastic - no_guarantee # Deterministic central scenario -- a flat return path. r_m = (1.0 + basis.investment_return) ** (1.0 / 12.0) - 1.0 central = np.full((1, n_time), r_m) pv_central = _pv_account_benefits( exit_value, np.maximum(central, g_m), central, f_m )[0] 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, )