Growth Rates#
This guide covers the GrowthRate class — how to create, configure, and use growth
rate objects in actuarial annuity and insurance calculations.
GrowthRate models a per-period revaluation factor applied to benefit amounts.
Applications are not limited to escalating benefits: common uses include pension CPI
indexation, salary-scale projections, guaranteed annual increases, and stress testing
with alternative growth assumptions. Growth can be geometric (compound, default)
or arithmetic (linear additive), with constant or piecewise schedules and
multi-scenario containers — analogous to InterestRate.
Creating a GrowthRate#
Constant geometric growth#
Geometric (compound) growth is the default: each payment receives a factor of \((1+g)^k\), where \(k\) is the number of complete policy years elapsed since the first payment.
from lactuca import GrowthRate
# 2% compound annual increase (growth_type='g' is the default)
gr = GrowthRate(0.02)
print(gr.summary()) # GrowthRate: geometric, constant rate = 0.020000
print(round(gr.factor(3), 6)) # 1.061208 — 1.02 ** 3
Constant arithmetic growth#
Arithmetic growth applies a flat linear increment: the factor at anniversary \(k\) is \(1 + g \cdot k\). Use this for benefit schedules expressed as a fixed percentage of the original benefit (step increases), rather than a compound multiplier.
from lactuca import GrowthRate
# 2% linear annual increase (additive)
gr_a = GrowthRate(0.02, growth_type='a')
print(gr_a.summary()) # GrowthRate: arithmetic, constant rate = 0.020000
print(gr_a.factor(3)) # 1.06 — 1 + 0.02 × 3
Piecewise schedule#
Pass rates and terms for a step-wise schedule. rates must have one more
element than terms; the last rate applies indefinitely:
from lactuca import GrowthRate
# 1% for the first year, 2% thereafter
gr = GrowthRate(rates=[0.01, 0.02], terms=[1])
print(gr.summary()) # GrowthRate: geometric, piecewise terms=[1], rates=[0.01 0.02]
print(gr.factor(1)) # 1.01 — one period at 1%
print(round(gr.factor(2), 4)) # 1.0302 — 1.01 × 1.02
print(round(gr.factor(3), 6)) # 1.050804 — 1.01 × 1.02 ** 2
When growth applies: apply_from_first#
By default (apply_from_first=False) the first payment has factor 1 — growth
applies from the second payment onward. Set apply_from_first=True to apply
growth already to the first payment:
from lactuca import GrowthRate
gr = GrowthRate(0.03, apply_from_first=False) # standard convention
print(gr.factor(0)) # 1.0 — first payment, no growth yet
print(gr.factor(1)) # 1.03
print(gr.factor(2)) # 1.0609
gr_first = GrowthRate(0.03, apply_from_first=True)
print(gr_first.factor(0)) # 1.03 — growth applied even at t=0
print(gr_first.factor(1)) # 1.0609
Multi-scenario container#
Store named alternatives in a single object and switch with active_scenario:
from lactuca import GrowthRate
gr = GrowthRate({
"base": GrowthRate(0.02),
"stress": GrowthRate(0.04),
"flat": GrowthRate(0.00),
})
print(gr.summary()) # lists all scenarios and the active one
gr.active_scenario = "base"
print(round(gr.factor(5), 4)) # 1.1041 — 1.02 ** 5
gr.active_scenario = "stress"
print(round(gr.factor(5), 4)) # 1.2167 — 1.04 ** 5
Computing growth factors#
GrowthRate.factor(t) returns the cumulative growth factor at anniversary index
t (a non-negative integer). See Anniversary convention
below for how the engine maps payment indices to anniversary indices.
from lactuca import GrowthRate
gr = GrowthRate(0.03)
print(gr.factor(0)) # 1.0 — reference payment (no growth yet)
print(gr.factor(1)) # 1.03
print(round(gr.factor(5), 4)) # 1.1593 — 1.03 ** 5
print(gr.factor([0, 1, 2, 5]).round(4)) # [1. 1.03 1.0609 1.1593]
Using with table methods#
Pass gr= to LifeTable annuity and insurance methods. A plain float is also
accepted and wrapped automatically:
from lactuca import LifeTable, GrowthRate
lt = LifeTable("PASEM2020_Rel_1o", "m")
gr = GrowthRate(0.02)
# Escalating whole-life annuity-due at 3% interest
lt.äx(65, ir=0.03, gr=gr)
# 20-year temporary annuity, monthly, using a plain float for growth
lt.äx(65, n=20, m=12, ir=0.03, gr=0.02)
# Escalating whole-life insurance
lt.Ax(65, ir=0.03, gr=gr)
The gr= parameter is accepted by the annuity methods äx, ax and their
joint-life variants (äxy, axy, äxyz, axyz), and by the insurance methods
Ax, Axy, Axyz. Pure endowments (nEx, nExy, nExyz) do not support gr=.
Anniversary convention#
Growth factors are indexed by the number of complete policy years elapsed before
each payment. For payment frequency \(m\), the \(j\)-th payment (0-based) receives
factor \(F\!\left(\lfloor j/m \rfloor\right)\). The deferment parameter d= shifts
payment times but does not affect the anniversary index — growth always counts
complete years from the first payment of the stream, regardless of when it starts.
For the full mathematical derivation and worked examples, see Growth Rate Conventions.
Piecewise schedules and shifted()#
GrowthRate.shifted(ts) returns a new GrowthRate with the first int(ts)
anniversary years of the schedule consumed. The engine calls it automatically when
ts > 0 so that the in-force growth schedule is correctly aligned from the
valuation date.
Only the integer part of ts advances the schedule: shifted(2.5) produces
the same result as shifted(2). The fractional remainder does not move the growth
horizon — growth still revaluates at whole-year policy anniversaries counted from
contract inception. config.force_integer_ts (default False) controls whether
fractional ts values are accepted at the annuity level (with a UserWarning
reminding that the fractional part has no effect on the growth schedule) or raise
a ValueError. For full context and worked examples see Prospective Reserves and the ts Parameter.
For the mathematical derivation of the schedule-shifting formula, see Growth Rate Conventions.
Combining growth and interest#
When both gr= and ir= are specified, the annuity engine compounds the growth
factor with the discount factor for each payment. For constant geometric growth, the
effective growth-adjusted discount factor per complete year simplifies to:
For piecewise or arithmetic schedules, and for sub-annual frequency \(m > 1\), the
engine evaluates each payment individually. GrowthRate and InterestRate are
independent objects: neither modifies the other.
Inspecting a GrowthRate#
Use print(gr) or gr.summary() (alias for str(gr)) to get a human-readable
description of the current growth rate curve:
from lactuca import GrowthRate
gr = GrowthRate(0.03)
print(gr)
# GrowthRate: geometric, constant rate = 0.030000
gr_neg = GrowthRate(-0.01)
print(gr_neg)
# GrowthRate: geometric, constant rate = -0.010000 (negative rate — shrinkage)
gr_pw = GrowthRate(rates=[0.02, 0.03], terms=[5])
print(gr_pw)
# GrowthRate: geometric, piecewise terms=[5], rates=[0.02 0.03]
Zero rates and negative rates are explicitly annotated so that they are visible in IFRS 17 and Solvency II reserving contexts. Multi-scenario containers list all scenario names alongside the active scenario.
Use repr(gr) for a compact, copy-pasteable constructor form (e.g. in logs or
debugging sessions).
Mutating rates and terms#
After construction, the rate values and segment durations of a piecewise schedule
can be updated in-place via the rates and terms setters. The array length (i.e.
number of segments) cannot be changed — create a new GrowthRate for structural
changes.
from lactuca import GrowthRate
# Update a constant rate in-place
gr = GrowthRate(0.02)
gr.rates = [0.03]
print(round(gr.factor(5), 4)) # 1.1593 — 1.03 ** 5
# Update rates in a piecewise schedule
gr_pw = GrowthRate(rates=[0.01, 0.02], terms=[3])
gr_pw.rates = [0.025, 0.035]
print(gr_pw.factor(1)) # 1.025
print(round(gr_pw.factor(4), 6)) # 1.114582 — 1.025 ** 3 × 1.035
# Update segment durations (structure stays the same, only durations change)
gr_pw.terms = [5] # segment now lasts 5 years instead of 3
print(gr_pw.get_segment_info(4)["segment_end"]) # 5
Setters validate the new values with the same rules as the constructor. Passing a
wrong-length array, a rate that violates the growth type constraint, or a non-positive
term raises ValueError.
Both setters are unavailable on multi-scenario containers — set individual scenario
instances directly. The terms setter is also unavailable on constant GrowthRate
instances (they have no segment structure).
Querying rates and segment metadata#
get_rate(t) retrieves the growth rate of the segment that applies at period t.
It accepts scalar or array input and follows the same scalar/array return convention
as factor():
from lactuca import GrowthRate
gr = GrowthRate(0.02)
print(gr.get_rate(0)) # 0.02
print(gr.get_rate([0, 5, 10])) # [0.02 0.02 0.02]
gr_pw = GrowthRate(rates=[0.01, 0.02], terms=[3])
print(gr_pw.get_rate(2)) # 0.01 — period 2 is in segment 0
print(gr_pw.get_rate(3)) # 0.02 — period 3 starts the tail
print(gr_pw.get_rate([0, 2, 3, 10])) # [0.01 0.01 0.02 0.02]
get_segment_info(t) returns a dict with full metadata about which segment applies
at period t:
info = gr_pw.get_segment_info(1)
# {
# 'type': 'piecewise',
# 'rate': 0.01,
# 'growth_type': 'geometric',
# 'apply_from_first': False,
# 'segment_index': 0,
# 'segment_start': 0,
# 'segment_end': 3,
# 'is_terminal': False,
# }
tail_info = gr_pw.get_segment_info(100)
# segment_end is None for the open-ended tail
Validation reports#
validate() returns a structured dict describing the state of the growth rate
curve, including zero-rate and negative-rate warnings:
from lactuca import GrowthRate
gr = GrowthRate(rates=[0.0, 0.02], terms=[3])
report = gr.validate()
# {
# 'type': 'piecewise',
# 'growth_type': 'geometric',
# 'apply_from_first': False,
# 'status': 'warnings',
# 'warnings': ['Zero growth rate found (1 occurrence(s)) — flat growth, no revaluation.'],
# 'rate_statistics': {'min': 0.0, 'max': 0.02, 'mean': 0.01,
# 'zero_count': 1, 'negative_count': 0},
# 'segment_count': 1,
# 'total_defined_term': 3,
# }
Status is 'ok' when no warnings are found, 'warnings' otherwise. For
multi-scenario containers, the dict includes a 'scenarios' sub-dict with one
entry per scenario.
Curve analysis#
curve_analysis() returns a quantitative summary of the growth curve, including
monotonicity flags and key cumulative factors at standard horizons:
from lactuca import GrowthRate
gr = GrowthRate(rates=[0.01, 0.03, 0.02], terms=[3, 5])
ca = gr.curve_analysis()
# {
# 'type': 'piecewise',
# 'growth_type': 'geometric',
# 'rate_range': {'min': 0.01, 'max': 0.03},
# 'curve_properties': {
# 'is_flat': False,
# 'is_monotone_inc': False,
# 'is_monotone_dec': False,
# 'has_zero_rates': False,
# 'has_negative_rates': False,
# },
# 'key_factors': {'t1': ..., 't5': ..., 't10': ..., 't20': ...},
# 'segment_count': 2,
# 'total_defined_term': 8,
# }
The key_factors sub-dict provides the cumulative factor(t) at t = 1, 5, 10, 20, giving a quick cross-section of the curve without enumerating the full
schedule.
Exporting#
export() serialises the growth rate configuration. In addition to the 'dict'
and 'json' formats, the 'regulatory' format produces a JSON string enriched
with audit-trail metadata required for Solvency II and IFRS 17 reporting:
from lactuca import GrowthRate
import json
gr = GrowthRate(0.02)
# Plain dict (default)
print(gr.export())
# JSON string
print(gr.export(format="json"))
# Regulatory JSON — adds export_timestamp (UTC ISO 8601), format_version, library
d = json.loads(gr.export(format="regulatory"))
print(d["export_timestamp"]) # e.g. "2025-07-15T10:32:11.123456+00:00"
print(d["format_version"]) # "1.0"
print(d["library"]) # "lactuca"
Generating cashflow amounts#
GrowthRate.amounts() produces the escalated cashflow amount for each payment
in a schedule, using the anniversary convention described above. It is the
companion to generate_payment_times(): build the time grid first,
then derive the amounts vector.
from lactuca import GrowthRate, generate_payment_times as gpt
import numpy as np
gr = GrowthRate(0.02) # 2 % geometric annual growth
times = gpt(n=5, m=12) # monthly payments for 5 years
amounts = gr.amounts(times, start=1_000.0, m=12)
# First year: 1 000 / month (no growth yet)
print(amounts[0]) # 1000.0
print(amounts[11]) # 1000.0
# Second year: × 1.02
print(round(amounts[12], 2)) # 1020.0
# Third year: × 1.02²
print(round(amounts[24], 2)) # 1040.4
For arithmetic growth, the amount increases by a fixed additive amount each year:
gr_a = GrowthRate(0.05, growth_type='a') # flat +5 % per year
times_a = gpt(n=3, m=1)
print(gr_a.amounts(times_a, start=1000.0, m=1))
# [1000. 1050. 1100.]
For a piecewise schedule — for example, CPI-linked for 10 years, then a fixed rate:
gr_pw = GrowthRate(rates=[0.03, 0.02], terms=[10]) # 3% for 10 yr, 2% thereafter
times_pw = gpt(n=15, m=1)
amounts_pw = gr_pw.amounts(times_pw, start=500.0, m=1)
print(round(amounts_pw[0], 2)) # 500.0 — year 1
print(round(amounts_pw[9], 2)) # 671.96 — year 10 (500 × 1.03^9)
print(round(amounts_pw[10], 2)) # 692.12 — year 11 (500 × 1.03^10)
print(round(amounts_pw[11], 2)) # 705.96 — year 12 (500 × 1.03^10 × 1.02)
Note
gr.amounts(times, start, m) and lt.äx(x, gr=gr) use the same anniversary
index \(\lfloor k/m \rfloor\). Passing the amounts vector as cashflow_amounts=
to ax() always produces the same present value as using
gr= in the formula — modulo rounding, because cashflow_amounts bypasses the
engine’s internal growth loop and rounds at the input boundary.
See also#
Growth Rate Conventions — full anniversary-convention reference and fractional-
tspolicyInterest Rates — analogous guide for
InterestRateCalculation Modes — how growth and interest interact across calculation modes
Irregular Cashflows — building non-standard cashflow schedules