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 is the primary
mechanism for regulatory traceability, BEL reconciliation, and debugging complex calculations
under IFRS 17 and Solvency II.
from lactuca import LifeTable
lt = LifeTable("PASEM2020_Rel_1o", "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)) # 13.2805
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("PASEM2020_Rel_1o", "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.
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 \(t_j = j/m + d\), \(j = 0, 1, \ldots, mn-1\) |
|
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 +\) |
|
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\), all dict values are scalars:
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.
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("PASEM2020_Rel_1o", "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)) # 13.2532
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("PASEM2020_Rel_1o", "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)) # 15.6316
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". In this case "due" and "immediate" alias the same underlying
dict — mutations to one will be reflected in the other.
continuous_simplified:
from lactuca import LifeTable, config
lt = LifeTable("PASEM2020_Rel_1o", "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)) # 13.2555
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)) # 13.4242
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 |
When \(n_\text{eff}\) is fractional and \(m>1\): full |
|
dict |
|
Note
When "fractional" is populated (fractional \(n\)), its six inner arrays are Python lists of two
elements — not NumPy arrays. 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:
Ax1 (
"pv_x"):continuous_precisionat termnwithprob_func_method="Ax1".Ax2 (
"pv_x1"):continuous_precisionat termmax(n - 1, 0)withprob_func_method="Ax2".
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 four-key flat scalar 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)}\).
All values except "time_grid" are scalars:
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\) |
|
float |
\({}_{n}p_{\text{joint}}\) — joint survival probability at maturity |
|
float |
\({}_{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("PASEM2020_Rel_1o", "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)
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: 13.2805
# 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 |
|---|---|
|
Table name, sex, ω, active modifications, decimals, sample \(q_x\) values |
|
Same header as |
|
Table name, age range, sample incidence/recovery rates |
|
Table name and lapse-rate sample |
|
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