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.
@@ -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
+ ]
@@ -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)
@@ -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
+ )