invest.drawdown-sustainability
How many years a pension or investment pot lasts under a yearly withdrawal and a constant growth rate, year by year.
1.0.0 (not the latest) · published 2026-10-03 by charlie · Anterra
Pinned by 19 tests, run in TypeScript, Python and Rust.
Not professional advice. This capability calculates investment figures from published rules. It is a software component for developers, not financial advice. Rules change and every rate here has an effective date. Check that the dates cover your case. Verify results against the official sources listed in its README, and have a tax adviser review how you use it, before anyone relies on the output. Provided “as is” under its licence, without warranty.
What it does
How long a pot lasts when a fixed (or inflation-rising) income is drawn from it each year and the rest grows at a constant rate: the arithmetic behind "will my pension last?" and the 4% rule.
## The convention
For example
drawdown_sustainability(£1,000.00, £300.00, 0%, 0%, 10)→ full years 3, exhausted true, total withdrawn £1,000.00, closing balance £0.00, schedule ×4 £1,000 drawn at £300 a year with no growth: three full years and a £100 fourthdrawdown_sustainability(£1,000.00, £100.00, 10%, 0%, 3)→ full years 3, exhausted false, total withdrawn £300.00, closing balance £966.90, schedule ×3 withdrawal first, then 10% growth on what is left (not growth first)drawdown_sustainability(£1,100.00, £100.00, 10%, 0%, 5)→ full years 5, exhausted false, total withdrawn £500.00, closing balance £1,100.00, schedule ×5 a withdrawal equal to the growth on the remainder lasts for ever
The function
The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.
def drawdown_sustainability(pot: Money, annual_withdrawal: Money, annual_growth_basis_points: int, withdrawal_increase_basis_points: int, max_years: int) -> Drawdown
| pot | Money | the pot at the start, 0 or more |
| annual_withdrawal | Money | the first year's withdrawal, taken at the start of the year; greater than zero |
| annual_growth_basis_points | int | growth on what is left each year, 400 = 4%; not below -10000 |
| withdrawal_increase_basis_points | int | yearly rise in the withdrawal, 250 = 2.5% for inflation; 0 for a level income |
| max_years | int | how far to model, 1 to 100 |
| returns | Drawdown |
The types it declares, generated into your project
@dataclass(frozen=True)
class DrawdownYear:
"""One year of the drawdown."""
#: 1 for the first year
year: int
opening: Money
#: the planned withdrawal, or what was left if less
withdrawal: Money
#: on the balance after the withdrawal; negative in a falling year
growth: Money
closing: Money
@dataclass(frozen=True)
class Drawdown:
"""How long the pot lasted and how it got there."""
#: years in which the whole planned withdrawal was paid
full_years: int
#: the pot reached zero within maxYears
exhausted: bool
total_withdrawn: Money
#: at the end of the last year modelled
closing_balance: Money
#: one row per year, ending in the year the pot ran out or at maxYears
schedule: List[DrawdownYear]
Your code names it in one line, in the file that uses it
from fune.invest.drawdown_sustainability import drawdown_sustainability # invest.drawdown-sustainability@^1
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
from typing import List
from .invest_drawdown_sustainability_types import Drawdown, DrawdownYear
from .money_amount import Money, money ← from money.amount ^1.0.0 · built alongside by fune
from .money_apply_rate import apply_rate ← from money.apply-rate ^1.0.0 · built alongside by fune
def _is_int(value: object) -> bool:
return isinstance(value, int) and not isinstance(value, bool)
def drawdown_sustainability(
pot: Money,
annual_withdrawal: Money,
annual_growth_basis_points: int,
withdrawal_increase_basis_points: int,
max_years: int,
) -> Drawdown:
"""Draw an income from a pot year by year, withdrawal first and growth on
what is left, until the pot runs out or max_years is reached."""
currency = pot.currency
if annual_withdrawal.currency != currency:
raise ValueError("currency mismatch: %s and %s" % (currency, annual_withdrawal.currency))
if not _is_int(pot.minor) or pot.minor < 0:
raise ValueError("pot must be whole minor units, 0 or more; received %s" % (pot.minor,))
if not _is_int(annual_withdrawal.minor) or annual_withdrawal.minor <= 0:
raise ValueError(
"annualWithdrawal must be whole minor units greater than zero; received %s" % (annual_withdrawal.minor,)
)
g = annual_growth_basis_points
if not _is_int(g) or g < -10000:
raise ValueError("annualGrowthBasisPoints must be a whole number, -10000 or more; received %s" % (g,))
inc = withdrawal_increase_basis_points
if not _is_int(inc) or inc < -10000:
raise ValueError("withdrawalIncreaseBasisPoints must be a whole number, -10000 or more; received %s" % (inc,))
if not _is_int(max_years) or max_years < 1 or max_years > 100:
raise ValueError("maxYears must be a whole number from 1 to 100; received %s" % (max_years,))
balance = pot.minor
planned = annual_withdrawal.minor
full_years = 0
total = 0
exhausted = False
schedule: List[DrawdownYear] = []
for year in range(1, max_years + 1):
opening = balance
withdrawal = min(planned, opening)
if withdrawal == planned:
full_years += 1
total += withdrawal
remaining = opening - withdrawal
growth = apply_rate(money(remaining, currency), g, "half-up").minor
balance = remaining + growth
schedule.append(
DrawdownYear(
year=year,
opening=money(opening, currency),
withdrawal=money(withdrawal, currency),
growth=money(growth, currency),
closing=money(balance, currency),
)
)
if balance == 0:
exhausted = True
break
planned += apply_rate(money(planned, currency), inc, "half-up").minor
return Drawdown(
full_years=full_years,
exhausted=exhausted,
total_withdrawn=money(total, currency),
closing_balance=money(balance, currency),
schedule=schedule,
)Install
fune build
With that line in your source, in a Python project (language python in fune.project), fune build resolves it and its 2 dependencies, pins them in fune.lock, downloads only the Python package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. Or pin a range in fune.project and build in one step:
fune add invest.drawdown-sustainability
The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./invest.drawdown-sustainability-1.0.0-python.fune, or fetch it from a terminal with fune pull invest.drawdown-sustainability@1.0.0:python.
The whole function, every language, is one file too: invest.drawdown-sustainability-1.0.0.fune, 36,681 bytes, sha256 6697983dff7e398f9061260a2adbc52b26f2b22eda0e03e535fc84d0d0244da9. It installs into a project of any language.
Customise it in your app
The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.
before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.
# fune: before invest.drawdown-sustainability
after — your function gets the result and the arguments, and returns the final result.
# fune: after invest.drawdown-sustainability
replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.
# fune: replace money.amount in invest.drawdown-sustainability
# fune: replace money.apply-rate in invest.drawdown-sustainability
step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show invest.drawdown-sustainability --steps.
# fune: step invest.drawdown-sustainability after <n|label>
Tests
A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.
| Case | Arguments | Expected | |
|---|---|---|---|
| £1,000 drawn at £300 a year with no growth: three full years and a £100 fourth | £1,000.00, £300.00, 0%, 0%, 10 | → | full years 3, exhausted true, total withdrawn £1,000.00, closing balance £0.00, schedule ×4 |
| withdrawal first, then 10% growth on what is left (not growth first) | £1,000.00, £100.00, 10%, 0%, 3 | → | full years 3, exhausted false, total withdrawn £300.00, closing balance £966.90, schedule ×3 |
| a withdrawal equal to the growth on the remainder lasts for ever | £1,100.00, £100.00, 10%, 0%, 5 | → | full years 5, exhausted false, total withdrawn £500.00, closing balance £1,100.00, schedule ×5 |
| an income rising 10% a year runs out in the fifth year | £1,000.00, £200.00, 0%, 10%, 10 | → | full years 4, exhausted true, total withdrawn £1,000.00, closing balance £0.00, schedule ×5 |
| growth is rounded to the penny every year | £100.01, £33.33, 3.33%, 0%, 10 | → | full years 3, exhausted true, total withdrawn £103.52, closing balance £0.00, schedule ×4 |
| a pot that reaches exactly zero on a full withdrawal counts that year | £200.00, £100.00, 0%, 0%, 5 | → | full years 2, exhausted true, total withdrawn £200.00, closing balance £0.00, schedule ×2 |
| a market halving every year | £1,000.00, £100.00, -50%, 0%, 10 | → | full years 3, exhausted true, total withdrawn £337.50, closing balance £0.00, schedule ×4 |
| one year modelled | £1,000.00, £10.00, 5%, 0%, 1 | → | full years 1, exhausted false, total withdrawn £10.00, closing balance £1,039.50, schedule ×1 |
| an empty pot pays nothing and is exhausted at once | £0.00, £10.00, 5%, 0%, 5 | → | full years 0, exhausted true, total withdrawn £0.00, closing balance £0.00, schedule ×1 |
| a falling withdrawal: 50% less each year | £100.00, £40.00, 0%, -50%, 5 | → | full years 5, exhausted false, total withdrawn £77.50, closing balance £22.50, schedule ×5 |
Show the other 9 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| a negative pot is an error | -£0.01, £10.00, 0%, 0%, 5 | → | error: pot must be whole minor units, 0 or more |
| a zero withdrawal is an error | £10.00, £0.00, 0%, 0%, 5 | → | error: annualWithdrawal must be whole minor units greater than zero |
| a fractional withdrawal is an error | £10.00, £10..5, 0%, 0%, 5 | → | error: annualWithdrawal must be whole minor units greater than zero |
| growth below -100% is an error | £10.00, £1.00, -100.01%, 0%, 5 | → | error: annualGrowthBasisPoints must be a whole number, -10000 or more |
| an increase below -100% is an error | £10.00, £1.00, 0%, -100.01%, 5 | → | error: withdrawalIncreaseBasisPoints must be a whole number, -10000 or more |
| zero years is an error | £10.00, £1.00, 0%, 0%, 0 | → | error: maxYears must be a whole number from 1 to 100 |
| 101 years is an error | £10.00, £1.00, 0%, 0%, 101 | → | error: maxYears must be a whole number from 1 to 100 |
| fractional years are an error | £10.00, £1.00, 0%, 0%, 2.5 | → | error: maxYears must be a whole number from 1 to 100 |
| a withdrawal in another currency is an error | £10.00, €1.00, 0%, 0%, 5 | → | error: currency mismatch: GBP and EUR |
More from the author
Each year, in whole minor units:
1. The withdrawal is taken at the **start** of the year: the planned amount, or everything left if that is less. 2. What remains grows by `annualGrowthBasisPoints`, rounded half away from zero (`money.apply-rate`, half-up). 3. The next year's planned withdrawal rises by `withdrawalIncreaseBasisPoints`, rounded the same way.
The run stops in the year the pot reaches zero, or after `maxYears`. `fullYears` counts the years in which the whole planned income was paid; a last, partial year appears in the schedule but not in `fullYears`. A pot that reaches exactly zero on a full withdrawal counts that year and is `exhausted`.
Taking the withdrawal first and growing the remainder is the cautious order, and the one an income drawn in advance follows. Growing first and then withdrawing gives a longer life for the same inputs; the vectors pin the order used here.
## What it does not do
It uses a single constant growth rate, so it says nothing about sequence of returns risk; run it for several rates, or use a stochastic model, for that. It ignores charges (see `invest.fee-drag`), tax on withdrawals and the tax-free lump sum; pass net figures if you need them.
## Errors
A negative pot, a withdrawal of 0 or less, growth or an increase below -10000, `maxYears` outside 1-100, fractional amounts and mixed currencies.
Files
| Path | Bytes |
|---|---|
| README.md | 1,617 |
| impl/python.py | 2,901 |
| impl/rust.rs | 5,078 |
| impl/typescript.ts | 2,778 |
| vectors.json | 17,360 |