Inspecting Cash Flows#
The return_flows=True parameter causes supported calculation methods to return not just a scalar
present value but a full dict of the arrays that produced it. This supports BEL/FCF
building-block traceability, reconciliation checks, and debugging — not a complete IFRS 17
GMM (CSM, RA) or Solvency II SCR calculation by itself. See Regulatory reporting scope
and the note in § Decomposing and verifying a present value below.
from lactuca import LifeTable
lt = LifeTable("GRMF95", "m", interest_rate=0.03)
result = lt.äx(65, n=20, m=12, return_flows=True)
# result is a dict — inspect all available keys:
print(list(result.keys()))
# ['time_grid', 'interest_rate', 'discount_factor', 'survival_probability',
# 'growth', 'payment_adjustment', 'present_value_raw', 'present_value']
# Reconstruct the scalar PV:
pv = result["present_value"].sum()
print(round(pv, 4)) # 12.3774
Important
The structure of the returned dict depends on whether the call is single-policy or batch.
Call mode |
|
|---|---|
Single-policy — scalar age ( |
Per-payment engine dict — many keys, detailed arrays. This page documents this structure. |
Batch — array of ages ( |
Compact portfolio dict with exactly four keys: |
A one-element list or array such as x=[65] triggers batch mode and returns the
compact four-key dict — not the detailed per-payment dict:
lt = LifeTable("GRMF95", "m", interest_rate=0.03)
# Scalar x — detailed per-payment engine dict
flows_scalar = lt.äx(65, n=20, return_flows=True)
print(list(flows_scalar.keys()))
# ['time_grid', 'interest_rate', 'discount_factor', 'survival_probability',
# 'growth', 'payment_adjustment', 'present_value_raw', 'present_value']
# 1-element list — compact portfolio dict (batch schema, same as N > 1)
flows_batch1 = lt.äx([65], n=20, return_flows=True)
print(list(flows_batch1.keys()))
# ['time_grid', 'expected_cf', 'pv_cf', 'total_pv']
The key time_grid exists at both levels: in single-policy calls it holds per-payment
times; in batch calls it holds the union grid across all policies. Code that processes
return_flows=True dicts can rely on time_grid being present regardless of call mode.
For a key-by-key mapping between scalar and batch dicts in discrete_precision, see
Scalar–batch key mapping (discrete_precision).
discrete_simplified and continuous_simplified modes support return_flows=True in
single-policy calls (returning the per-engine diagnostic dict documented below) but raise
ValueError when used in batch mode.
The structure of the per-payment dict (single-policy calls) also depends on the calculation mode in effect:
Active mode |
Dict structure |
|---|---|
|
Flat arrays — see Annuity keys, Insurance keys, Pure endowment |
|
Integration-grid arrays — see Continuous mode keys |
|
Nested dicts (annuity/insurance); flat scalars (endowment) — see Simplified modes |
Annuity cash-flow keys (discrete_precision)#
All arrays contain one entry per payment interval. For \(m\) payments per year over \(n\) years that is \(m \times n\) entries (e.g. \(12 \times 20 = 240\) for the example below).
Key |
Description |
|---|---|
|
Payment times: due ( |
|
Effective annual rates at each \(t_j\) (piecewise-term aware) |
|
Discount factors \(v^{t_j}\) |
|
Survival probabilities \({}_{t_j}p_x\) |
|
Benefit growth factors at \(t_j\) (1.0 if no |
|
Fractional-final-payment scale (1.0 for full payments; < 1 for the last fractional period) |
|
\({}_{t_j}p_x \cdot v^{t_j} \cdot g(t_j) \cdot \text{adj}_j\) — element-wise product before frequency scaling |
|
Contribution of each payment to the total PV: \(\text{raw}_j / m\) |
The total annuity value is result["present_value"].sum().
Insurance cash-flow keys (discrete_precision)#
All arrays contain one entry per payment interval — same length semantics as the annuity table above (\(m \times n\) entries total).
Key |
Description |
|---|---|
|
Integer interval indices \(j = 0, 1, \ldots\) |
|
Policy-year starts for each interval: \(t_j = j/m + d\) |
|
Times at which the benefit is discounted: \(t_j + \text{offset}/m\), where |
|
Effective annual rates at |
|
Discount factors \(v^{\text{discount\_time}}\) |
|
Raw per-interval death probabilities from the table |
|
Fractional-final-period scale |
|
Adjusted death probabilities used in the summation |
|
Benefit growth factors (1.0 if no |
|
PV contribution of each interval |
The total insurance value is result["present_value"].sum().
Pure-endowment cash-flow keys#
Because a pure endowment has a single payment at time \(n\), the dict uses scalar survival and discount fields plus single-element arrays for the maturity time grid and cash-flow slots (batch-compatible schema):
Key |
Description |
|---|---|
|
Single-element array |
|
\({}_{n}p_x\) (single-life) or \(\prod_i {}_{n}p_{x_i}\) (joint) |
|
\(v^n\) |
|
Single-element array \([{}_{n}p_{\text{joint}}]\) — survival probability at maturity |
|
Single-element array \([{}_{n}E_{\text{joint}}]\) — discounted survival at maturity |
|
Scalar — \({}_{n}E_{\text{joint}}\) |
Note
Both discrete_precision and discrete_simplified return this same six-key structure.
For continuous_precision and continuous_simplified, see the sections below.
Endowment insurance (nEx) vs. pure endowment flows. Standalone nEx(..., return_flows=True) always exposes the maturity representation above (time_grid = [np.array([n])]). For endowment insurance in continuous modes, annuity and insurance
legs may include integration grids over \([0, n]\); the endowment survival leg is still
reported as a single payment at \(t = n\) in the batch-oriented keys — not on the integration
grid. See Continuous mode keys § Endowment.
Scalar–batch key mapping (discrete_precision)#
Single-policy calls return a diagnostic dict (per-payment arrays and actuarial
components). Batch calls return a portfolio dict with exactly four keys.
The underlying quantities match; only the key names and aggregation level differ.
The table below applies when calculation_mode='discrete_precision', benefits are
level (gr=None, no custom cashflow_amounts), and insurance uses unit sum insured.
Let \(g_j\) be flows["growth"][j] (or flows["amount"][j] when custom amounts
are provided). In batch mode, each policy is weighted by benefits[i] when
benefits= is supplied; the scalar formulas are per policy before that scaling.
Portfolio concept |
Batch key |
Scalar role |
|---|---|---|
Payment or event times |
|
|
Undiscounted expected cash flow |
|
Product-specific column below |
Discounted contribution |
|
Product-specific column below |
Total present value |
|
Sum of the scalar discounted column |
Annuity (ax, äx, joint and n-life annuity methods)#
Batch aggregate at \(t_j\) |
Reconstruct from scalar dict |
|---|---|
|
|
|
|
|
|
For annuity-due (äx, …) with a fractional final period, payment_adjustment
scales the last payment; batch aggregation applies the same convention on the
merged grid.
Insurance (Ax, Axy, …)#
Batch aggregate at \(t_j\) |
Reconstruct from scalar dict |
|---|---|
|
|
|
|
|
|
Scalar insurance dicts expose death_probability, not expected_cf. When
rebuilding a portfolio manually from per-policy scalar calls, stack
death_probability (times per-policy benefits if needed). Discounting uses
discount_time semantics (time_grid[j] + offset/m from config.mortality_placement),
already reflected in scalar present_value[j]. See Batch Calculations
§ When to avoid benefits=.
Pure endowment (nEx, nExy, nExyz, nEjoint)#
Scalar discrete_precision already uses the same key names as batch (single
maturity payment):
Key |
Scalar (one policy) |
Batch (portfolio) |
|---|---|---|
|
|
Union of maturity times |
|
|
Sum of survival probabilities at each grid point |
|
|
Sum of discounted contributions |
|
|
|
For one policy, scalar nEx(x, …) and batch nEx([x], …) agree exactly
(float64); batch only reshapes when policies mature at different \(n\).
Note
Batch return_flows=True is supported only for discrete_precision and
continuous_precision. Simplified modes return incompatible nested dicts in
scalar calls; see Simplified modes — nested structure.
Continuous mode keys#
For continuous_precision, methods return integration grid arrays over \([d, d+n]\)
instead of per-payment arrays:
Note
Unified time-axis key
Both scalar and batch dicts use "time_grid" as the time-axis key. For scalar calls
(single age), "time_grid" contains the per-policy payment times or integration grid;
for batch calls (array of ages), it holds the union grid of all policy times.
Generic code that processes return_flows=True dicts works at both levels with the
same key.
Annuity (continuous_precision):
All arrays are evaluated on a linearly-spaced integration grid over \([d,\, d+n]\).
Key |
Description |
|---|---|
|
Grid points \(t\) over \([d,\, d+n]\) — the integration time axis |
|
\({}_{t}p_x\) (raw \(\ell_x\), no rounding applied) |
|
\(v^t\) at each grid point |
|
Effective annual rates \(i(t)\) at each grid point |
|
Growth factors \(g(t)\) at each grid point |
|
\({}_{t}p_x \cdot v^t \cdot g(t)\) — raw density (no \(\mathrm{d}t\)) |
|
Undiscounted density × \(\mathrm{d}t\): \({}_{t}p_x \cdot g(t) \cdot \mathrm{d}t\) |
|
PV density × \(\mathrm{d}t\): |
|
Scalar — |
Note
The continuous annuity dict does not include a "present_value" key; use total_pv
instead, which equals np.sum(pv_cf). You can also reconstruct \(\bar{a}_x\) manually by
integrating the integrand array:
import numpy as np
from lactuca import LifeTable, config
lt = LifeTable("GRMF95", "m", interest_rate=0.03)
config.calculation_mode = "continuous_precision"
result = lt.äx(65, n=20, return_flows=True)
pv = np.trapezoid(result["integrand"], result["time_grid"])
print(round(pv, 4)) # 12.3474
config.calculation_mode = "discrete_precision"
Insurance (continuous_precision):
All arrays are evaluated on the same integration grid; present_value is a scalar.
Key |
Description |
|---|---|
|
Integration grid points — the time axis |
|
\({}_{t}p_x\) |
|
\(\mu_{x+t}\) (numerically derived) |
|
\(v^t\) |
|
Benefit growth factors |
|
\(v^t \cdot {}_t p_x \cdot \mu_{x+t} \cdot g(t)\) — raw density (no \(\mathrm{d}t\)) |
|
Undiscounted density × \(\mathrm{d}t\): \({}_{t}p_x \cdot \mu_{x+t} \cdot g(t) \cdot \mathrm{d}t\) |
|
PV density × \(\mathrm{d}t\): |
|
Scalar — |
|
Scalar — trapezoidal integral \(\bar{A}_x\) (authoritative value) |
Endowment (continuous_precision):
The endowment component pays at maturity \(t = n\) as a single event. The dict has two logical groups: the continuous integration grid used internally, followed by the single-payment representation used by the batch interface.
Integration grid (internal computation):
Key |
Description |
|---|---|
|
\({}_t p_{\text{joint}}\) at each grid point |
|
Combined force of mortality \(\sum_i \mu_i(t)\) at each grid point |
|
Force of interest \(\delta(t)\) at each grid point |
|
\(\delta(t) + \sum_i \mu_i(t)\) — integrand of \(-\ln({}_{n}E_x)\) |
|
Scalar — \(\int_0^n [\delta(t) + \sum_i \mu_i(t)]\,\mathrm{d}t\) (trapezoidal) |
Single-payment representation (batch interface + result):
Key |
Description |
|---|---|
|
Single-element array |
|
Single-element array \([{}_{n}p_{\text{joint}}]\) — survival probability at maturity |
|
Single-element array \([{}_{n}E_{\text{joint}}]\) |
|
Scalar — \({}_{n}E_{\text{joint}}\) |
Simplified modes — nested structure#
discrete_simplified and continuous_simplified return nested dicts for annuity
and insurance methods.
For annuity methods the inner "due" and "immediate" components are
themselves full discrete_precision flow dicts.
For insurance methods the structure differs — see below.
For endowment methods, both modes return flat scalar dicts — see below.
discrete_simplified:
from lactuca import LifeTable, config
lt = LifeTable("GRMF95", "m", interest_rate=0.03)
config.calculation_mode = "discrete_simplified"
result = lt.äx(65, m=12, return_flows=True)
due_flows = result["due"] # full discrete_precision dict for annual äx
imm_flows = result["immediate"] # full discrete_precision dict for annual ax
pv_due = result["pv_due"] # scalar: sum of due component
pv_immediate = result["pv_immediate"] # scalar: sum of immediate component
coef_due = result["coef_due"] # Woolhouse weight for due component
coef_imm = result["coef_immediate"]
pv_final = result["interpolated"] # scalar PV: coef_due*pv_due + coef_imm*pv_immediate
print(f"coef_due={coef_due:.4f}, coef_imm={coef_imm:.4f}") # 0.5417, 0.4583
print(round(pv_final, 4)) # 14.5029
config.calculation_mode = "discrete_precision" # restore
Note
"pv_due" and "pv_immediate" are only present when m > 1.
For m = 1 the method delegates to discrete_precision and the returned dict has only
five keys: "due", "immediate", "coef_due" (= 1.0), "coef_immediate" (= 0.0),
and "interpolated". The "due" and "immediate" sub-dicts are separate
discrete_precision results (annual due vs annual immediate); for äx only the due
leg contributes because coef_immediate = 0.
When \(n_\text{eff}\) is fractional and \(m > 1\), the return dict also includes
"fractional_tail" (m-thly discrete_precision tail flows over \((k,\,n_\text{eff}]\);
empty dict when \(k = 0\)), alongside "pv_due", "pv_immediate", and
"interpolated".
continuous_simplified:
from lactuca import LifeTable, config
lt = LifeTable("GRMF95", "m", interest_rate=0.03)
config.calculation_mode = "continuous_simplified"
# Integer n — fractional sub-dict is empty
result = lt.äx(65, n=20, return_flows=True)
print(list(result.keys()))
# ['due', 'immediate', 'interpolated', 'fractional']
print(round(result["interpolated"], 4)) # 12.35
print(result["fractional"]) # {}
# Fractional n — fractional sub-dict holds the two-point quadrature tail
result2 = lt.äx(65, n=20.5, return_flows=True)
frac = result2["fractional"]
print(list(frac.keys()))
# ['time_grid', 'interest_rate', 'discount_factor',
# 'survival_probability', 'growth', 'present_value']
print(round(result2["interpolated"], 4)) # 12.4868
config.calculation_mode = "discrete_precision" # restore
Key |
Type |
Description |
|---|---|---|
|
dict |
Annual annuity-due flows over the integer portion \(k = \lfloor n \rfloor\) (a full |
|
dict |
Annual annuity-immediate flows over \(k = \lfloor n \rfloor\) (a full |
|
float |
Woolhouse PV from annual due/immediate components (integer \(k\) years) plus tail PV when \(n\) is fractional |
|
dict |
|
Note
When "fractional" is populated (fractional \(n\)), five fields (interest_rate,
discount_factor, survival_probability, growth, present_value) are Python lists of
two elements; time_grid is a two-element NDArray[np.float64]. Use
sum(frac["present_value"]) or np.add(*frac["present_value"]) rather than
frac["present_value"].sum().
Insurance (discrete_simplified, m > 1):
Insurance uses linear age interpolation (not Woolhouse). When \(n_\text{eff}\) is
fractional, integer years \(k=\lfloor n_\text{eff}\rfloor\) use the interpolation
below and the terminal fraction is in "fractional_tail":
Key |
Type |
Description |
|---|---|---|
|
dict |
Full |
|
dict |
Full |
|
float |
Interpolation weight \(\frac{m-1}{2m}\) |
|
dict |
m-thly |
|
float |
Interpolated integer-year PV plus tail sum |
Insurance (continuous_simplified):
Returns three scalar trapezoidal totals from underlying continuous_precision
integrations (per-grid arrays are available only from a direct
continuous_precision call with return_flows=True on each leg).
When n > 1, the simplified insurance value is the arithmetic mean of two precision
legs:
First leg (
"pv_x"):continuous_precisionwhole-life insurance at the valuation age, termn.Second leg (
"pv_x1"):continuous_precisionat termmax(n - 1, 0)(one-year-shorter duration at age \(x+1\) whenn > 1).
from lactuca import LifeTable, config
lt = LifeTable("PASEM2020_Rel_1o", "m", interest_rate=0.03)
config.calculation_mode = "continuous_simplified"
flows = lt.Ax(65, n=10, ir=0.03, m=1, return_flows=True)
print(list(flows.keys())) # ['present_value', 'pv_x', 'pv_x1']
assert flows["present_value"] == 0.5 * (flows["pv_x"] + flows["pv_x1"])
flows_short = lt.Ax(65, n=0.5, ir=0.03, m=1, return_flows=True)
assert flows_short["pv_x1"] is flows_short["pv_x"] # Ax2 leg absent
assert flows_short["present_value"] == flows_short["pv_x"]
config.calculation_mode = "discrete_precision" # restore
Key |
Type |
Description |
|---|---|---|
|
float |
Trapezoidal total from the Ax1 leg ( |
|
float |
Trapezoidal total from the Ax2 leg (term |
|
float |
When |
Note
When n <= 1, the Ax2 leg is not evaluated: "pv_x1" aliases "pv_x" (same
object). Do not interpret the duplicate as an independent \(\bar{A}_{x+1}\) estimate.
For integration-grid detail, call continuous_precision with return_flows=True on
each leg separately.
Endowment (discrete_simplified):
Returns the same six-key flat structure as discrete_precision — see
Pure-endowment cash-flow keys.
Endowment (continuous_simplified):
Uses average-force approximation \({}_{n}E_x \approx e^{-n(\bar{\delta} + \sum_i \bar{\mu}_i)}\).
Force-related keys are scalars; maturity cash-flow keys use single-element arrays (as in
discrete_precision).
Key |
Type |
Description |
|---|---|---|
|
ndarray |
Maturity time as single-element array |
|
float |
\(\bar{\delta} = -\ln(v^n)/n\) |
|
float |
\(\sum_i \bar{\mu}_i = \sum_i(-\ln({}_np_{x_i})/n)\) |
|
float |
\(\bar{\delta} + \sum_i \bar{\mu}_i\) |
|
ndarray |
Single-element array \([{}_{n}p_{\text{joint}}]\) — approximate joint survival at maturity |
|
ndarray |
Single-element array \([{}_{n}E_{\text{joint}}]\) — discounted joint survival |
|
float |
\(\exp\!\bigl(-n\cdot(\bar{\delta}+\sum_i \bar{\mu}_i)\bigr)\) |
Decomposing and verifying a present value#
return_flows=True makes it possible to inspect every factor that contributed to a
present value and verify that they compose exactly as expected. This kind of
component-level traceability is the building block for model validation tasks —
such as auditing an actuarial cash-flow model or cross-checking a single benefit
stream inside a larger IFRS 17 / Solvency II projection.
Note
A real BEL under IFRS 17 / Solvency II covers a full portfolio (many policyholders), multiple cash-flow types (benefits, premiums, expenses), multiple decrement causes (mortality, lapse, disability …), and a Risk Adjustment / Risk Margin on top of the pure present value. The example below isolates one benefit stream for a single policy: it demonstrates the decomposition pattern, not a complete BEL calculation.
import numpy as np
from lactuca import LifeTable
lt = LifeTable("GRMF95", "m", interest_rate=0.03)
x, n, m = 65, 20, 12
flows = lt.äx(x, n=n, m=m, return_flows=True)
# 240 entries — one per payment (m=12 per year × n=20 years; d=0 due grid)
t = flows["time_grid"] # t_j = j/12, j = 0, …, 239
v_t = flows["discount_factor"] # v^{t_j}
tpx = flows["survival_probability"] # _{t_j}p_x
g_t = flows["growth"] # 1.0 everywhere (no gr=)
adj = flows["payment_adjustment"] # 1.0 for all full payments
pv_per_pmnt = flows["present_value"] # = tpx * v_t * g_t * adj / m
pv = pv_per_pmnt.sum()
print(f"PV: {pv:.4f}") # PV: 12.3774
# Verify that each element matches its formula component-by-component
reconstructed = tpx * v_t * g_t * adj / m
np.testing.assert_allclose(pv_per_pmnt, reconstructed, rtol=1e-12)
print("Components verified ✓")
summary() — audit snapshots#
Every main Lactuca object implements summary(), returning a human-readable snapshot
of its current state. This is the standard audit header for IFRS 17 and Solvency II
model documentation.
Class |
What |
|---|---|
|
Shared decrement header (table name, sex, taxonomy, cohort/duration, |
|
Same shared header plus decimals and sample \(i_x\) values |
|
Same shared header plus decimals and sample \(o_x\) (exit) values |
|
Rate type (constant / piecewise), rate values, scenario name if multi-scenario |
|
Growth type (geometric / arithmetic), rate values |
|
Builder configuration and included tables |
from lactuca import LifeTable, InterestRate
lt = LifeTable("PASEM2020_Rel_1o", "m")
print(lt.summary())
# LifeTable: PASEM2020_Rel_1er.orden
# Sex: m
# Table: PASEM2020_Rel_1er.orden | Type: life
# Age range: 0-109 | Valid sexes: f, m
# Generational: False
# w (current): 109
# Modified: False
# Decimals: qx=15
# Sample qx values (first 5):
# qx(0) = 0.0020038
# ...
# Sample qx values (last 5):
# ...
# qx(109) = 1.0
ir = InterestRate(0.03)
print(ir.summary())
# InterestRate: constant rate = 0.030000
See also#
Calculation Modes — which mode to choose for regulatory calculations
Prospective Reserves and the ts Parameter — the
ts=parameter and mid-year reserve calculationsBatch Calculations —
return_flows=Truewith array ages for portfolio BEL (IFRS 17 / Solvency II)Actuarial Formulas — mathematical definitions of each quantity
Numerical Precision — float64 precision policy for the arrays returned