Irregular Cashflows#
Most annuity formulas assume a uniform payment schedule: equal payments of \(1/m\) per period at regular \(1/m\)-year intervals throughout the term. When a benefit design deviates from this pattern — e.g., step-up pensions, inflation-indexed benefits, or arbitrary single payment schedules — Lactuca’s irregular cashflow interface provides a flexible alternative.
Note
Explicit cashflow schedules are only supported by immediate (postpayable) annuity
methods: lactuca.LifeTable.ax(), lactuca.LifeTable.axy(),
lactuca.LifeTable.axyz(), and lactuca.LifeTable.ajoint().
Due (prepayable) variants (äx, äxy, äxyz, äjoint) do not accept
cashflow_times or cashflow_amounts.
To simulate due-style timing with explicit cashflows, include \(t = 0\) as the first
element of cashflow_times; see below.
Parameters#
Parameter |
Type |
Description |
|---|---|---|
|
sequence of |
Payment times in years from valuation date |
|
sequence of |
Payment amounts at each time |
Both arrays must have the same length. Times must be non-negative and strictly ascending (no duplicates). Amounts must be strictly positive (> 0).
How it works#
Instead of internally generating the \(t_k = k/m\) payment grid, the engine uses the
user-supplied cashflow_times array directly. The present value calculation becomes:
where \(c_k\) is cashflow_amounts[k], \(v^{t_k}\) is the discount factor from the
InterestRate, and \({}_{t_k}p_x\) is the survival probability to time \(t_k\).
Restrictions#
Condition |
Behaviour |
|---|---|
|
|
|
|
|
|
|
|
Unsorted or duplicate times |
|
Any amount ≤ 0 |
|
Note
cashflow_amounts can also be provided without cashflow_times to apply custom
per-payment amounts to a regular schedule (defined by m and n). In that case
m can be any supported payment frequency (1, 2, 3, 4, 6, 12, 14, 24, 26, 52, or 365) and the array length must match the number of scheduled payments
\(\lfloor n \cdot m \rfloor\). Passing cashflow_amounts together with gr= raises a
ValueError — they are mutually exclusive; pass gr=None when using cashflow_amounts.
Use cases#
Step-up pension#
Annual payments that step up by 10 % every five years over 20 years:
from lactuca import LifeTable, generate_payment_times as gpt, tiered_amounts
lt = LifeTable("PASEM2020_Rel_1o", "m", interest_rate=0.03)
times = gpt(n=20, m=1)
amounts = tiered_amounts(
times,
breakpoints=[5, 10, 15],
values=[1.00, 1.10, 1.21, 1.331], # one value per tier
)
pv = lt.ax(65, cashflow_times=times, cashflow_amounts=amounts)
print(round(pv, 4)) # 14.5547 (unit benefit ≈ 1 per year; scale freely, e.g. × 10 000)
The breakpoints list is inclusive on the right: a payment at exactly \(t = 5\)
falls in the first tier and receives amount 1.00; a payment at \(t = 5 + \epsilon\)
falls in the second tier and receives 1.10.
Inflation-indexed annuity#
Monthly payments that grow at 2 % per year over 20 years:
import numpy as np
from lactuca import LifeTable
lt = LifeTable("PASEM2020_Rel_1o", "m", interest_rate=0.03)
months = 20 * 12
times = np.arange(1, months + 1, dtype=float) / 12.0 # t = 1/12, 2/12, ..., 20
amounts = (1.02 ** times) / 12.0 # 2 % annual inflation, 1/12 per month
pv = lt.ax(65, cashflow_times=times, cashflow_amounts=amounts)
print(round(pv, 3)) # 15.717
Arbitrary benefit schedule#
Any non-standard payment pattern — lump sums, variable benefits, or irregular timing:
from lactuca import LifeTable
lt = LifeTable("PASEM2020_Rel_1o", "m", interest_rate=0.03)
times = [1.0, 2.0, 3.0, 5.0, 10.0]
amounts = [1.0, 1.0, 0.5, 2.0, 0.5] # all strictly positive
pv = lt.ax(40, cashflow_times=times, cashflow_amounts=amounts)
print(round(pv, 4)) # 4.4566
Prepayable (due) payments via explicit times#
The äx family does not accept explicit cashflow schedules, but a prepayable annuity —
where the first payment falls at \(t = 0\) — can be constructed with ax by including
\(t = 0\) in cashflow_times:
import numpy as np
from lactuca import LifeTable
lt = LifeTable("PASEM2020_Rel_1o", "m", interest_rate=0.03)
x, n = 65, 20
# Due schedule: payments at t = 0, 1, 2, ..., n-1
times_due = np.arange(0, n, dtype=float)
amounts_due = np.ones(n)
pv_cashflow = lt.ax(x, cashflow_times=times_due, cashflow_amounts=amounts_due)
pv_due = lt.äx(x, n=n) # standard due annuity (prepayable)
print(np.isclose(pv_cashflow, pv_due)) # True
Verifying equivalence with the standard formula#
Explicit cashflows fully replicate a standard uniform annuity when the times and amounts match the regular payment grid. This confirms that both approaches produce identical results (within floating-point tolerance):
import numpy as np
from lactuca import LifeTable
lt = LifeTable("PASEM2020_Rel_1o", "m", interest_rate=0.03)
x, n, m = 65, 10, 4
# Standard quarterly annuity
pv_std = lt.ax(x, n=n, m=m)
# Equivalent via explicit cashflow_times + cashflow_amounts
times = np.arange(1, n * m + 1, dtype=float) / m # 0.25, 0.50, ..., 10.0
amounts = np.full(n * m, 1.0 / m) # 1/m per payment
pv_cf = lt.ax(x, cashflow_times=times, cashflow_amounts=amounts)
print(np.isclose(pv_std, pv_cf)) # True
Interaction with m and n#
When cashflow_times is supplied, passing m != 1 or n > 0 raises a ValueError
immediately — these parameters are not silently ignored. The payment schedule is
entirely determined by cashflow_times, so no frequency or duration hint is needed.
Use cashflow_times=None (the default) together with m and n for all standard
uniform-payment scenarios.
Inspecting the cashflows#
Pass return_flows=True to retrieve the per-payment arrays (times, discount factors,
survival probabilities, amounts, and present-value contributions) as a dict.
See Inspecting Cash Flows for the full key reference.
Performance notes#
For very long benefit schedules (e.g., 1 200 monthly payments), the engine is vectorised
over the full cashflow_times array using NumPy — no Python loop is executed.
Custom per-payment amounts on a uniform schedule#
When cashflow_times is not supplied, you can still pass cashflow_amounts to
override per-payment amounts on a standard uniform grid (defined by m and n).
The array length must equal \(\lfloor n \cdot m \rfloor\). All standard values of m
are allowed, and n must be positive. Passing cashflow_amounts together with gr=
raises a ValueError — they are mutually exclusive; pass gr=None when using
cashflow_amounts.
This is useful when benefit amounts follow a deterministic but non-geometric pattern
that cannot be expressed as a constant gr:
import numpy as np
from lactuca import LifeTable
lt = LifeTable("PASEM2020_Rel_1o", "m", interest_rate=0.03)
# Quarterly annuity for 5 years with arbitrary amounts (20 payments)
amounts = np.array([1.0, 1.0, 1.1, 1.1, # year 1
1.1, 1.1, 1.2, 1.2, # year 2
1.2, 1.2, 1.3, 1.3, # year 3
1.3, 1.3, 1.4, 1.4, # year 4
1.4, 1.4, 1.5, 1.5]) # year 5
pv = lt.ax(60, n=5, m=4, cashflow_amounts=amounts)
print(round(pv, 4)) # 22.6638
Generating payment schedules with generate_payment_times#
For common actuarial patterns — payments on specific months or quarters only —
the helper lactuca.generate_payment_times() generates a ready-to-use
cashflow_times array without boilerplate:
from lactuca import generate_payment_times
# All quarterly payments for 10 years (default: all periods)
times = generate_payment_times(n=10, m=4)
# times = [0.25, 0.50, 0.75, 1.00, ..., 10.00] — 40 payments
# Quarterly payments in March (Q1) and September (Q3) only for 10 years
times = generate_payment_times(n=10, m=4, selected_periods=[1, 3])
# times = [0.25, 0.75, 1.25, 1.75, ..., 9.25, 9.75]
Argument |
Type |
Description |
|---|---|---|
|
|
Total duration in years (can be fractional) |
|
|
Payment frequency: one of 1, 2, 3, 4, 6, 12, 14, 24, 26, 52, 365 |
|
sequence of |
Period indices within each year, \(1 \leq p \leq m\). |
Payment times follow the formula \(t = k + p/m\), where \(k = 0, 1, \ldots\) are complete years and \(p\) is the selected period index. The output is always sorted in ascending order and free of duplicates.
Supplemental payments added to a regular annuity#
When a benefit design includes both a regular payment stream and additional supplemental
payments at specific times (such as an extra payment each mid-year and year-end), the
present value is simply the sum of two separate calculations: one for the regular
component using the standard formula, and one for the supplemental component using
explicit cashflow_times.
import numpy as np
from lactuca import LifeTable
from lactuca import generate_payment_times
lt = LifeTable("PASEM2020_Rel_1o", "m", interest_rate=0.03)
x, n = 65, 20.0
# Regular component: standard monthly annuity
pv_regular = lt.ax(x, n=n, m=12)
# Supplemental component: two extra payments per year at months 6 and 12
t_extra = generate_payment_times(n=n, m=12, selected_periods=[6, 12])
a_extra = np.full(t_extra.size, 1.0 / 12) # each supplement equals one monthly amount
pv_extra = lt.ax(x, cashflow_times=t_extra, cashflow_amounts=a_extra)
# Total present value = regular + supplemental
pv_total = pv_regular + pv_extra
print(f"Regular (12/yr): {pv_regular:.4f}")
print(f"Supplemental (2/yr): {pv_extra:.4f}")
print(f"Total (14/yr): {pv_total:.4f}")
print(f"Ratio total/regular: {pv_total / pv_regular:.4f}") # ≈ 14/12 ≈ 1.1667
The additivity of present values means there is no need to merge and sort the two
cashflow_times arrays — each component can be valued independently.
See also#
lactuca.generate_payment_times()— helper for building payment schedulesLast Payment Adjustment — alignment of regular grids when \(n\) is not a multiple of \(1/m\)
Inspecting Cash Flows —
return_flows=Truekey referenceNotation and Glossary — \(a\), \(v\), \({}_{t}p_x\) definitions
Calculation Modes — discrete vs continuous modes