manyfutures 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- manyfutures/__init__.py +32 -0
- manyfutures/analytics.py +96 -0
- manyfutures/constants.py +96 -0
- manyfutures/data.py +127 -0
- manyfutures/life_table.csv +9220 -0
- manyfutures/models.py +856 -0
- manyfutures/mortgage.py +184 -0
- manyfutures/paths.py +493 -0
- manyfutures/places/__init__.py +31 -0
- manyfutures/places/at.py +134 -0
- manyfutures/places/es.py +133 -0
- manyfutures/places/fr.py +92 -0
- manyfutures/places/it.py +97 -0
- manyfutures/places/nl.py +145 -0
- manyfutures/places/uk.py +116 -0
- manyfutures/py.typed +0 -0
- manyfutures/readouts.py +49 -0
- manyfutures/scenario.py +852 -0
- manyfutures/simulation.py +1807 -0
- manyfutures/tax.py +218 -0
- manyfutures/us_cpi.csv +113 -0
- manyfutures-0.1.0.dist-info/METADATA +106 -0
- manyfutures-0.1.0.dist-info/RECORD +25 -0
- manyfutures-0.1.0.dist-info/WHEEL +4 -0
- manyfutures-0.1.0.dist-info/licenses/LICENSE +21 -0
manyfutures/__init__.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""A household's retirement plan run over many simulated futures.
|
|
2
|
+
|
|
3
|
+
The Scenario seam (``manyfutures.scenario``): a ``Scenario``, the household's decisions, is run from a ``Start``, its
|
|
4
|
+
Position on a date and the market's returns, on ``Assumptions``, the household's facts and the model's constants, and
|
|
5
|
+
gives an ``Outcome``. ``run`` runs one Scenario, ``return_band`` runs it with every year's return a point either side
|
|
6
|
+
(a ``ReturnBand``), ``compare`` runs it in every city, and ``inputs_that_matter`` moves one hand-set input at a time.
|
|
7
|
+
The engine's other modules keep their own names: ``manyfutures.models``, ``manyfutures.simulation`` and the rest.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from manyfutures.scenario import (
|
|
11
|
+
Assumptions,
|
|
12
|
+
Outcome,
|
|
13
|
+
ReturnBand,
|
|
14
|
+
Scenario,
|
|
15
|
+
Start,
|
|
16
|
+
compare,
|
|
17
|
+
inputs_that_matter,
|
|
18
|
+
return_band,
|
|
19
|
+
run,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"Assumptions",
|
|
24
|
+
"Outcome",
|
|
25
|
+
"ReturnBand",
|
|
26
|
+
"Scenario",
|
|
27
|
+
"Start",
|
|
28
|
+
"compare",
|
|
29
|
+
"inputs_that_matter",
|
|
30
|
+
"return_band",
|
|
31
|
+
"run",
|
|
32
|
+
]
|
manyfutures/analytics.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""The two measures over a run's futures: the liquid portfolio's percentiles at each age, and the Sustainable rule's
|
|
2
|
+
spending by age (the comparison lives in ``scenario``).
|
|
3
|
+
|
|
4
|
+
Both give plain records, one per age; a caller that shows them as a table builds the table itself.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Sequence
|
|
10
|
+
from typing import Literal
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
from manyfutures.models import PortfolioAtAge, SimConfig, SpendingAtAge
|
|
15
|
+
from manyfutures.simulation import compute_downsize_year_index
|
|
16
|
+
|
|
17
|
+
__all__ = ["spending_by_age", "summarise"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def summarise(
|
|
21
|
+
values: np.ndarray,
|
|
22
|
+
config: SimConfig,
|
|
23
|
+
*,
|
|
24
|
+
net_equities: np.ndarray | None = None,
|
|
25
|
+
current_age: int,
|
|
26
|
+
downsize_failed_mask: np.ndarray | None = None,
|
|
27
|
+
alive: np.ndarray | None = None,
|
|
28
|
+
) -> tuple[tuple[PortfolioAtAge, ...], tuple[PortfolioAtAge, ...], float]:
|
|
29
|
+
"""Return (liquid, total, success_rate): the liquid portfolio's record at each age, the same with the home's net
|
|
30
|
+
equity added (the liquid records themselves without ``net_equities``), and the share never depleted in retirement.
|
|
31
|
+
|
|
32
|
+
One record per age, at the end of each model year. Its percentiles are over the paths alive at that age (`alive`,
|
|
33
|
+
shape (n, T), the year loop's mask; None means every path lasts to the last age), so a path frozen after the
|
|
34
|
+
year its End Age falls in leaves the records past it, and `share_alive` is how many of all the paths are alive
|
|
35
|
+
there (decision 26). An age no path reaches has no percentiles.
|
|
36
|
+
|
|
37
|
+
`downsize_failed_mask` (shape (n,)) excludes paths whose new mortgage would have exceeded
|
|
38
|
+
100% LTV from the post-move records, so failure-flagged paths don't blend silently
|
|
39
|
+
with successful ones, unless every path alive at that age failed. Pre-move records use every path alive.
|
|
40
|
+
"""
|
|
41
|
+
n_paths, total_years = values.shape[0], config.accumulation_years + config.drawdown_years
|
|
42
|
+
downsize_idx = compute_downsize_year_index(config)
|
|
43
|
+
affordable = None if downsize_failed_mask is None else ~downsize_failed_mask
|
|
44
|
+
liquid_rows: list[PortfolioAtAge] = []
|
|
45
|
+
total_rows: list[PortfolioAtAge] = []
|
|
46
|
+
for t in range(total_years):
|
|
47
|
+
counted = np.ones(n_paths, dtype=bool) if alive is None else alive[:, t]
|
|
48
|
+
share_alive = float(counted.mean())
|
|
49
|
+
if affordable is not None and downsize_idx is not None and t > downsize_idx and (counted & affordable).any():
|
|
50
|
+
counted = counted & affordable
|
|
51
|
+
percentiles = _percentiles_over(values[:, t], counted, (10, 25, 50, 75, 90))
|
|
52
|
+
age, phase = current_age + t + 1, "accumulation" if t < config.accumulation_years else "drawdown"
|
|
53
|
+
liquid_rows.append(_row(age, phase, percentiles, share_alive))
|
|
54
|
+
if net_equities is not None:
|
|
55
|
+
equity = float(net_equities[t])
|
|
56
|
+
with_equity = [None if p is None else p + equity for p in percentiles]
|
|
57
|
+
total_rows.append(_row(age, phase, with_equity, share_alive))
|
|
58
|
+
else:
|
|
59
|
+
total_rows.append(liquid_rows[-1])
|
|
60
|
+
|
|
61
|
+
drawdown_values = values[:, config.accumulation_years :]
|
|
62
|
+
depleted = (drawdown_values == 0).any(axis=1)
|
|
63
|
+
success_rate = float(1 - depleted.mean())
|
|
64
|
+
return tuple(liquid_rows), tuple(total_rows), success_rate
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _row(
|
|
68
|
+
age: int, phase: Literal["accumulation", "drawdown"], percentiles: Sequence[float | None], share_alive: float
|
|
69
|
+
) -> PortfolioAtAge:
|
|
70
|
+
p10, p25, p50, p75, p90 = percentiles
|
|
71
|
+
return PortfolioAtAge(age=age, phase=phase, p10=p10, p25=p25, p50=p50, p75=p75, p90=p90, share_alive=share_alive)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _percentiles_over(year: np.ndarray, counted: np.ndarray, levels: Sequence[int]) -> list[float | None]:
|
|
75
|
+
"""The percentiles ``levels`` (10, ...) of one year's (n,) values over the futures ``counted`` (n,) at it, the
|
|
76
|
+
ones alive at that age; None for each when none is (decision 26)."""
|
|
77
|
+
values = year[counted]
|
|
78
|
+
if not values.size:
|
|
79
|
+
return [None] * len(levels)
|
|
80
|
+
return [float(p) for p in np.percentile(values, list(levels))]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def spending_by_age(
|
|
84
|
+
spending_level: np.ndarray, alive: np.ndarray, *, current_age: int, retired_from: int
|
|
85
|
+
) -> tuple[SpendingAtAge, ...]:
|
|
86
|
+
"""The Sustainable rule's level by age from Retirement: p10, p50 and p90 over the futures alive at each age.
|
|
87
|
+
|
|
88
|
+
``spending_level`` and ``alive`` are the year loop's (n, T) arrays, the level in today's € before cost of living,
|
|
89
|
+
the age band and noise (decision 28). One record per model year from ``retired_from``, the first
|
|
90
|
+
retired one, at the age the year ends at; an age no future reaches has no percentiles.
|
|
91
|
+
"""
|
|
92
|
+
records = []
|
|
93
|
+
for t in range(retired_from, spending_level.shape[1]):
|
|
94
|
+
p10, p50, p90 = _percentiles_over(spending_level[:, t], alive[:, t], (10, 50, 90))
|
|
95
|
+
records.append(SpendingAtAge(age=current_age + t + 1, p10=p10, p50=p50, p90=p90))
|
|
96
|
+
return tuple(records)
|
manyfutures/constants.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""The engine's model constants: the defaults of the Assumptions' model constants and the engine's own settings.
|
|
2
|
+
|
|
3
|
+
No household's facts are here: the Assumptions require them of the caller (decision 9).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
# The loan a downsize takes on the new home is an annuity over this many years, the longest whose interest is
|
|
9
|
+
# deductible in the Netherlands; its rate is that of the mortgage the household has (decision 9)
|
|
10
|
+
DOWNSIZE_MORTGAGE_TERM = 30
|
|
11
|
+
|
|
12
|
+
# Ages
|
|
13
|
+
PENSION_AGE = 67 # the state-pension age, from which the pension is paid
|
|
14
|
+
MAX_MODEL_AGE = 100
|
|
15
|
+
AOW_AGE = 67 # NL state-pension age; below this, Box 1 bracket-1 includes ~27.65% premies
|
|
16
|
+
GBP_EUR_FIXED_RATE = 1.15 # GBP→EUR spot, 2026-05-12 (poundsterlinglive.com)
|
|
17
|
+
|
|
18
|
+
# Simulation assumptions
|
|
19
|
+
INFLATION_RATE = 0.025
|
|
20
|
+
EXPENSE_RATIO = 0.0035
|
|
21
|
+
BLOCK_LENGTH = 5
|
|
22
|
+
PROPERTY_TAX_RATE = 0.005
|
|
23
|
+
MAINTENANCE_RATE = 0.01
|
|
24
|
+
N_SIMS_DRAFT = 5_000
|
|
25
|
+
N_SIMS_FULL = 25_000
|
|
26
|
+
_Z_90 = 1.2815515655446004 # scipy.stats.norm.ppf(0.90)
|
|
27
|
+
|
|
28
|
+
# Child cost brackets (Nibud, annual €, today's money)
|
|
29
|
+
CHILD_COSTS: list[tuple[int, int, float]] = [(0, 3, 7_000), (4, 11, 9_000), (12, 17, 11_000), (18, 21, 13_000)]
|
|
30
|
+
CHILD_CASH_RESERVE_STEP = 7_000
|
|
31
|
+
|
|
32
|
+
# Property assumptions
|
|
33
|
+
PROPERTY_APPRECIATION_RATE = 0.035 # 3.5%/yr nominal, the deterministic mortgage schedule's
|
|
34
|
+
REAL_HOME_APPRECIATION = 0.01 # 1%/yr above CPI: the target each path's home value reverts to
|
|
35
|
+
# After a Last-resort Sale the household rents at this share of the sold home's value a year, rising with the path's
|
|
36
|
+
# CPI from the sale (decision 33).
|
|
37
|
+
RENT_RATE = 0.04
|
|
38
|
+
|
|
39
|
+
# The long-life line gives the headline's readouts over the futures whose End Age is this age or later
|
|
40
|
+
# (decision 29): what a long life looks like.
|
|
41
|
+
LONG_LIFE_AGE = 95
|
|
42
|
+
|
|
43
|
+
# A year is Running Short when more than this share of its Must-have Spending goes unfunded (decision 27):
|
|
44
|
+
# less left unfunded is within ordinary spending noise.
|
|
45
|
+
RUNNING_SHORT_THRESHOLD = 0.10
|
|
46
|
+
|
|
47
|
+
# The Return Band (decision 32): the headline is also run with every year's return this much lower and
|
|
48
|
+
# this much higher, on the same random futures.
|
|
49
|
+
RETURN_BAND_WIDTH = 0.01
|
|
50
|
+
|
|
51
|
+
# The Sustainable Spending Rule (decision 28): each retired year it spends what the money can carry to
|
|
52
|
+
# the planning age at the planning rate (real), moving at most a tenth a year and never above the ceiling times the
|
|
53
|
+
# chosen annual spending. A fall in its level of the cut threshold or more is a cut.
|
|
54
|
+
SUSTAINABLE_PLANNING_AGE = 95
|
|
55
|
+
SUSTAINABLE_PLANNING_RATE = 0.02
|
|
56
|
+
SUSTAINABLE_MAX_CHANGE = 0.10
|
|
57
|
+
SUSTAINABLE_CEILING = 1.5
|
|
58
|
+
SUSTAINABLE_CUT_THRESHOLD = 0.05
|
|
59
|
+
|
|
60
|
+
# Retirement real-spending multipliers by age band (applied to baseline household spending only).
|
|
61
|
+
RETIREMENT_SPENDING_BANDS: list[tuple[int, int, float]] = [
|
|
62
|
+
(0, 66, 1.00),
|
|
63
|
+
(67, 74, 0.90),
|
|
64
|
+
(75, 84, 0.80),
|
|
65
|
+
(85, 200, 0.85),
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
__all__ = [
|
|
69
|
+
"AOW_AGE",
|
|
70
|
+
"BLOCK_LENGTH",
|
|
71
|
+
"CHILD_CASH_RESERVE_STEP",
|
|
72
|
+
"CHILD_COSTS",
|
|
73
|
+
"DOWNSIZE_MORTGAGE_TERM",
|
|
74
|
+
"EXPENSE_RATIO",
|
|
75
|
+
"GBP_EUR_FIXED_RATE",
|
|
76
|
+
"INFLATION_RATE",
|
|
77
|
+
"LONG_LIFE_AGE",
|
|
78
|
+
"MAINTENANCE_RATE",
|
|
79
|
+
"MAX_MODEL_AGE",
|
|
80
|
+
"N_SIMS_DRAFT",
|
|
81
|
+
"N_SIMS_FULL",
|
|
82
|
+
"PENSION_AGE",
|
|
83
|
+
"PROPERTY_APPRECIATION_RATE",
|
|
84
|
+
"PROPERTY_TAX_RATE",
|
|
85
|
+
"REAL_HOME_APPRECIATION",
|
|
86
|
+
"RENT_RATE",
|
|
87
|
+
"RETIREMENT_SPENDING_BANDS",
|
|
88
|
+
"RETURN_BAND_WIDTH",
|
|
89
|
+
"RUNNING_SHORT_THRESHOLD",
|
|
90
|
+
"SUSTAINABLE_CEILING",
|
|
91
|
+
"SUSTAINABLE_CUT_THRESHOLD",
|
|
92
|
+
"SUSTAINABLE_MAX_CHANGE",
|
|
93
|
+
"SUSTAINABLE_PLANNING_AGE",
|
|
94
|
+
"SUSTAINABLE_PLANNING_RATE",
|
|
95
|
+
"_Z_90",
|
|
96
|
+
]
|
manyfutures/data.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""The engine's data: the bundled life table and US inflation, and the type of the return history a Start carries.
|
|
2
|
+
|
|
3
|
+
Only readers of the files bundled beside this module: nothing here fetches. The caller fetches the market's returns
|
|
4
|
+
and hands them to the engine as ``AnnualReturns``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import csv
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from datetime import date
|
|
12
|
+
from functools import cache
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import get_args
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
|
|
18
|
+
from manyfutures.models import InflationHistory, LifeSeries, LifeTable
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"AnnualReturns",
|
|
22
|
+
"inflation_history",
|
|
23
|
+
"life_table",
|
|
24
|
+
"parse_inflation_history",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True, eq=False)
|
|
29
|
+
class AnnualReturns:
|
|
30
|
+
"""Calendar-year returns, each labelled by its year: ``returns[i]`` is the change over ``years[i]``.
|
|
31
|
+
|
|
32
|
+
A year's return is its last close over the year before's, price-only (no dividends). The current year is
|
|
33
|
+
included while it is still partial; a run leaves it out, with every year that has no US inflation, and takes
|
|
34
|
+
each other year's real return with that year's own inflation (decision 2).
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
years: np.ndarray # int, ascending
|
|
38
|
+
returns: np.ndarray # float, one per year
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
_LIFE_TABLE = Path(__file__).with_name("life_table.csv")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@cache
|
|
45
|
+
def life_table() -> LifeTable:
|
|
46
|
+
"""The bundled life table End Ages are drawn from: CBS's projected chances of dying, for men and for women, at
|
|
47
|
+
every age in every year from 2025 to 2070 (decision 26).
|
|
48
|
+
|
|
49
|
+
The file's ``# source_url:`` and ``# source_date:`` header lines fill those fields; its other header lines are
|
|
50
|
+
the notes: what was taken, how it was reduced, and the licence. Every series must give a chance at every age
|
|
51
|
+
from 0 and in every year from the first, and nothing else.
|
|
52
|
+
"""
|
|
53
|
+
header, notes, rows = _commented_csv(_LIFE_TABLE.read_text(encoding="utf-8"))
|
|
54
|
+
chances: dict[tuple[str, int, int], float] = {}
|
|
55
|
+
for row in csv.DictReader(rows):
|
|
56
|
+
key = (row["series"], int(row["age"]), int(row["year"]))
|
|
57
|
+
if key in chances:
|
|
58
|
+
raise ValueError(f"{_LIFE_TABLE.name}: two chances for {key}") # noqa: TRY003
|
|
59
|
+
chances[key] = float(row["death_probability"])
|
|
60
|
+
ages = range(max(age for _, age, _ in chances) + 1)
|
|
61
|
+
years = range(min(year for *_, year in chances), max(year for *_, year in chances) + 1)
|
|
62
|
+
every_series = get_args(LifeSeries)
|
|
63
|
+
if len(chances) != len(every_series) * len(ages) * len(years) or {s for s, *_ in chances} != set(every_series):
|
|
64
|
+
raise ValueError(f"{_LIFE_TABLE.name}: not one chance for each series at every age in every year") # noqa: TRY003
|
|
65
|
+
return LifeTable(
|
|
66
|
+
source_url=header["source_url"],
|
|
67
|
+
source_date=header["source_date"],
|
|
68
|
+
notes=" ".join(notes),
|
|
69
|
+
first_year=years.start,
|
|
70
|
+
chances={
|
|
71
|
+
series: tuple(tuple(chances[series, age, year] for year in years) for age in ages)
|
|
72
|
+
for series in every_series
|
|
73
|
+
},
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _commented_csv(text: str) -> tuple[dict[str, str], list[str], list[str]]:
|
|
78
|
+
"""A bundled CSV's ``# source_url:`` and ``# source_date:`` header lines, its other header lines, and its rows."""
|
|
79
|
+
header: dict[str, str] = {}
|
|
80
|
+
notes: list[str] = []
|
|
81
|
+
rows: list[str] = []
|
|
82
|
+
for line in text.splitlines():
|
|
83
|
+
if not line.startswith("#"):
|
|
84
|
+
rows.append(line)
|
|
85
|
+
continue
|
|
86
|
+
key, sep, value = line.removeprefix("#").strip().partition(": ")
|
|
87
|
+
if sep and key in ("source_url", "source_date"):
|
|
88
|
+
header[key] = value
|
|
89
|
+
else:
|
|
90
|
+
notes.append(line.removeprefix("#").strip())
|
|
91
|
+
return header, notes, rows
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
_US_CPI = Path(__file__).with_name("us_cpi.csv")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@cache
|
|
98
|
+
def inflation_history() -> InflationHistory:
|
|
99
|
+
"""The bundled US inflation each year's real return is taken with: the CPI-U's December-to-December change in
|
|
100
|
+
every year from 1928 to the latest complete year (decision 2)."""
|
|
101
|
+
return parse_inflation_history(_US_CPI.read_text(encoding="utf-8"))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def parse_inflation_history(cpi_csv: str) -> InflationHistory:
|
|
105
|
+
"""FRED's ``observation_date,CPIAUCNS`` rows → each calendar year's December-to-December inflation.
|
|
106
|
+
|
|
107
|
+
Only each December's index is read, so another month, missing or blank, changes nothing; a year has inflation
|
|
108
|
+
when its December and the December before it both have a value. The ``# source_url:`` and ``# source_date:``
|
|
109
|
+
header lines fill those fields and the other header lines are the notes.
|
|
110
|
+
"""
|
|
111
|
+
header, notes, rows = _commented_csv(cpi_csv)
|
|
112
|
+
december: dict[int, float] = {}
|
|
113
|
+
for row in csv.DictReader(rows):
|
|
114
|
+
observed, value = date.fromisoformat(row["observation_date"]), row["CPIAUCNS"].strip()
|
|
115
|
+
if observed.month != 12 or not value:
|
|
116
|
+
continue
|
|
117
|
+
if observed.year in december:
|
|
118
|
+
raise ValueError(f"Two December values for {observed.year}") # noqa: TRY003
|
|
119
|
+
december[observed.year] = float(value)
|
|
120
|
+
years = tuple(year for year in sorted(december) if year - 1 in december)
|
|
121
|
+
return InflationHistory(
|
|
122
|
+
source_url=header["source_url"],
|
|
123
|
+
source_date=header["source_date"],
|
|
124
|
+
notes=" ".join(notes),
|
|
125
|
+
years=years,
|
|
126
|
+
inflation=tuple(december[year] / december[year - 1] - 1 for year in years),
|
|
127
|
+
)
|