glidepath 0.2.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.
- glidepath/__init__.py +3 -0
- glidepath/app/__init__.py +364 -0
- glidepath/app/backtest.py +281 -0
- glidepath/app/charts.py +759 -0
- glidepath/app/copy.py +174 -0
- glidepath/app/display.py +148 -0
- glidepath/app/drawdown.py +436 -0
- glidepath/app/example.py +66 -0
- glidepath/app/exports.py +487 -0
- glidepath/app/files.py +249 -0
- glidepath/app/firstrun.py +114 -0
- glidepath/app/forms.py +1750 -0
- glidepath/app/inspector.py +506 -0
- glidepath/app/labels.py +66 -0
- glidepath/app/montecarlo.py +399 -0
- glidepath/app/plan.py +354 -0
- glidepath/app/retirement.py +446 -0
- glidepath/app/scenarios.py +831 -0
- glidepath/app/shell.py +185 -0
- glidepath/app/tables.py +138 -0
- glidepath/core/__init__.py +390 -0
- glidepath/core/annuities.py +240 -0
- glidepath/core/backtest.py +514 -0
- glidepath/core/comparison.py +278 -0
- glidepath/core/config.py +82 -0
- glidepath/core/contributions.py +337 -0
- glidepath/core/engine.py +2811 -0
- glidepath/core/entities.py +264 -0
- glidepath/core/glide.py +289 -0
- glidepath/core/investments.py +175 -0
- glidepath/core/money.py +107 -0
- glidepath/core/montecarlo.py +609 -0
- glidepath/core/pensions.py +298 -0
- glidepath/core/periods.py +367 -0
- glidepath/core/provenance.py +271 -0
- glidepath/core/randomness.py +128 -0
- glidepath/core/region.py +46 -0
- glidepath/core/reporting.py +231 -0
- glidepath/core/results.py +504 -0
- glidepath/core/retirement.py +291 -0
- glidepath/core/returns.py +312 -0
- glidepath/core/scenarios.py +579 -0
- glidepath/core/state_pension.py +264 -0
- glidepath/core/tax.py +139 -0
- glidepath/core/withdrawals.py +461 -0
- glidepath/core/wrappers.py +278 -0
- glidepath/gui/__init__.py +6 -0
- glidepath/gui/assets/icon_128.png +0 -0
- glidepath/gui/assets/icon_16.png +0 -0
- glidepath/gui/assets/icon_24.png +0 -0
- glidepath/gui/assets/icon_256.png +0 -0
- glidepath/gui/assets/icon_32.png +0 -0
- glidepath/gui/assets/icon_48.png +0 -0
- glidepath/gui/assets/icon_64.png +0 -0
- glidepath/gui/assets/wordmark.png +0 -0
- glidepath/gui/charts.py +829 -0
- glidepath/gui/forms.py +359 -0
- glidepath/gui/inspector.py +186 -0
- glidepath/gui/main.py +51 -0
- glidepath/gui/scenarios.py +402 -0
- glidepath/gui/style.py +376 -0
- glidepath/gui/tableview.py +67 -0
- glidepath/gui/widgets.py +989 -0
- glidepath/persistence/__init__.py +48 -0
- glidepath/persistence/assumptions.py +112 -0
- glidepath/persistence/decode.py +747 -0
- glidepath/persistence/document.py +101 -0
- glidepath/persistence/encode.py +433 -0
- glidepath/persistence/migrations.py +158 -0
- glidepath/persistence/values.py +298 -0
- glidepath/py.typed +0 -0
- glidepath/regions/__init__.py +7 -0
- glidepath/regions/uk/__init__.py +189 -0
- glidepath/regions/uk/ages.py +156 -0
- glidepath/regions/uk/contributions.py +717 -0
- glidepath/regions/uk/data/age_rules.toml +78 -0
- glidepath/regions/uk/data/assumptions_default.toml +170 -0
- glidepath/regions/uk/data/returns_history.toml +150 -0
- glidepath/regions/uk/data/tax_year_2026_27.toml +98 -0
- glidepath/regions/uk/extension.py +479 -0
- glidepath/regions/uk/loader.py +704 -0
- glidepath/regions/uk/region.py +160 -0
- glidepath/regions/uk/schema.py +563 -0
- glidepath/regions/uk/state_pension.py +129 -0
- glidepath/regions/uk/tax.py +466 -0
- glidepath/regions/uk/wrappers.py +283 -0
- glidepath/regions/uk/years.py +92 -0
- glidepath-0.2.0.dist-info/METADATA +189 -0
- glidepath-0.2.0.dist-info/RECORD +93 -0
- glidepath-0.2.0.dist-info/WHEEL +4 -0
- glidepath-0.2.0.dist-info/entry_points.txt +3 -0
- glidepath-0.2.0.dist-info/licenses/LICENSE +21 -0
- glidepath-0.2.0.dist-info/licenses/LICENSE-DATA +28 -0
|
@@ -0,0 +1,609 @@
|
|
|
1
|
+
"""Monte Carlo path runner and success metrics (roadmap 7.3; planning §5.2).
|
|
2
|
+
|
|
3
|
+
:func:`run_paths` projects a plan N times through the one engine step
|
|
4
|
+
function — path *i* is an ordinary :func:`~glidepath.core.engine.run`
|
|
5
|
+
under ``replace(config, path=i)``, so its randomness is exactly
|
|
6
|
+
determined by ``(config.seed, i)`` and any single path is individually
|
|
7
|
+
re-runnable (planning §4.6). Each path reduces to a
|
|
8
|
+
:class:`PathOutcome` — the ruin signal, the household's closing
|
|
9
|
+
balance per period (what the percentile bands chart, roadmap 9.13),
|
|
10
|
+
and the ending balance; the full period ledgers are dropped, so a
|
|
11
|
+
many-path run holds one projection's snapshots at a time.
|
|
12
|
+
|
|
13
|
+
Success metrics over the outcomes (planning §5.2):
|
|
14
|
+
|
|
15
|
+
- **probability of ruin** — the fraction of paths reporting a period
|
|
16
|
+
with unmet need. The engine's per-period ``shortfall`` is the ruin
|
|
17
|
+
signal by design: it survives gross-defined strategies that ignore
|
|
18
|
+
the need (planning §5.2), and it covers planned outflows as well as
|
|
19
|
+
decumulation spending.
|
|
20
|
+
- **ending-pot percentiles** — order statistics of the paths' final
|
|
21
|
+
nominal balances, linearly interpolated. CPI is deterministic across
|
|
22
|
+
paths (the single-inflation-truth rule), so nominal and real
|
|
23
|
+
percentiles rank paths identically; deflate by the final period's
|
|
24
|
+
inflation factor for today's money.
|
|
25
|
+
- **sustainable income** (:func:`sustainable_income`) — the highest
|
|
26
|
+
starting net withdrawal meeting the target
|
|
27
|
+
(:class:`SustainableIncomeSearch`): a descending scan over the
|
|
28
|
+
bracket finds the highest succeeding scan point, then bisection
|
|
29
|
+
refines upward within the scan cell above it. Under a Monte Carlo
|
|
30
|
+
config a candidate meets by success rate over its seeded paths;
|
|
31
|
+
under a deterministic config by a single run with no unmet need —
|
|
32
|
+
the same §5.2 ruin signal either way. For a strategy whose
|
|
33
|
+
success is monotone in the spending level — the default fixed-real
|
|
34
|
+
strategy, and the gross-defined strategies whose draws ignore the
|
|
35
|
+
need — the result is exact to the search tolerance. A strategy with
|
|
36
|
+
adjustment triggers (guardrails, roadmap 5.3) can make success
|
|
37
|
+
non-monotone: there the result is still never below the highest
|
|
38
|
+
succeeding scan point, but a success island narrower than one scan
|
|
39
|
+
step can be missed — raise ``scan_steps`` to tighten the
|
|
40
|
+
resolution. Every probe reuses the same seed (common random
|
|
41
|
+
numbers), so candidates differ only by the spending level and the
|
|
42
|
+
search is reproducible. Probe plans exist only inside the search:
|
|
43
|
+
the synthetic spending "fact" they carry is never part of any
|
|
44
|
+
returned result.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
from dataclasses import dataclass, replace
|
|
48
|
+
from datetime import UTC, datetime, time
|
|
49
|
+
from decimal import Decimal
|
|
50
|
+
from typing import TYPE_CHECKING, Any
|
|
51
|
+
|
|
52
|
+
from glidepath.core.config import EngineError, RunMode
|
|
53
|
+
from glidepath.core.engine import run
|
|
54
|
+
from glidepath.core.entities import SpendingPlan
|
|
55
|
+
from glidepath.core.money import Money
|
|
56
|
+
from glidepath.core.provenance import Fact
|
|
57
|
+
|
|
58
|
+
if TYPE_CHECKING:
|
|
59
|
+
from collections.abc import Iterator, Sequence
|
|
60
|
+
from concurrent.futures import Executor
|
|
61
|
+
|
|
62
|
+
from glidepath.core.config import RunConfig
|
|
63
|
+
from glidepath.core.entities import Household
|
|
64
|
+
from glidepath.core.periods import Period
|
|
65
|
+
from glidepath.core.provenance import Assumption, AssumptionKey, AssumptionSet
|
|
66
|
+
from glidepath.core.region import Region
|
|
67
|
+
from glidepath.core.results import ProjectionResult, RunProvenance
|
|
68
|
+
|
|
69
|
+
_ZERO = Money(Decimal(0))
|
|
70
|
+
_ONE = Decimal(1)
|
|
71
|
+
_TWO = Decimal(2)
|
|
72
|
+
_HUNDRED = Decimal(100)
|
|
73
|
+
_DEFAULT_TOLERANCE = Money(Decimal(100))
|
|
74
|
+
"""Default bisection tolerance: £100 of annual spending (planning §5.2)."""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True, slots=True)
|
|
78
|
+
class PathOutcome:
|
|
79
|
+
"""One Monte Carlo path, reduced to its success signals.
|
|
80
|
+
|
|
81
|
+
``first_shortfall_period`` is the first period whose need went
|
|
82
|
+
unmet — ``None`` on a path that never fell short.
|
|
83
|
+
``closing_balances`` is the household's total closing balance per
|
|
84
|
+
period in period order, nominal — what the balances chart's
|
|
85
|
+
percentile bands read (roadmap 9.13) — and ``ending_balance`` is
|
|
86
|
+
its final entry, kept as its own field for the ending-pot metrics.
|
|
87
|
+
Re-run the path itself with ``run(plan, assumptions, region,
|
|
88
|
+
replace(config, path=path))``.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
path: int
|
|
92
|
+
first_shortfall_period: Period | None
|
|
93
|
+
ending_balance: Money
|
|
94
|
+
closing_balances: tuple[Money, ...] = ()
|
|
95
|
+
|
|
96
|
+
def __post_init__(self) -> None:
|
|
97
|
+
"""Reject a negative path index or inconsistent balances."""
|
|
98
|
+
if self.path < 0:
|
|
99
|
+
msg = f"PathOutcome.path must be non-negative, got {self.path}"
|
|
100
|
+
raise ValueError(msg)
|
|
101
|
+
if self.ending_balance < _ZERO:
|
|
102
|
+
msg = "PathOutcome.ending_balance must be non-negative"
|
|
103
|
+
raise ValueError(msg)
|
|
104
|
+
if self.closing_balances and self.closing_balances[-1] != self.ending_balance:
|
|
105
|
+
msg = "PathOutcome.closing_balances must end at ending_balance"
|
|
106
|
+
raise ValueError(msg)
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def ruined(self) -> bool:
|
|
110
|
+
"""Whether any period's need went unmet on this path."""
|
|
111
|
+
return self.first_shortfall_period is not None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass(frozen=True, slots=True)
|
|
115
|
+
class MonteCarloResult:
|
|
116
|
+
"""N paths' outcomes with the success metrics over them (§5.2).
|
|
117
|
+
|
|
118
|
+
``config`` and ``provenance`` are the §4.6 manifest side: the
|
|
119
|
+
seed, mode, and every assumption any path read — the union across
|
|
120
|
+
paths, since a balance-dependent read (natural-yield pricing,
|
|
121
|
+
roadmap 5.3) can fire on some paths only — so the whole run is
|
|
122
|
+
reproducible from this result plus the plan.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
outcomes: tuple[PathOutcome, ...]
|
|
126
|
+
config: RunConfig
|
|
127
|
+
provenance: RunProvenance
|
|
128
|
+
|
|
129
|
+
def __post_init__(self) -> None:
|
|
130
|
+
"""Require at least one path."""
|
|
131
|
+
if not self.outcomes:
|
|
132
|
+
msg = "MonteCarloResult needs at least one path outcome"
|
|
133
|
+
raise ValueError(msg)
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def path_count(self) -> int:
|
|
137
|
+
"""How many paths were projected."""
|
|
138
|
+
return len(self.outcomes)
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def probability_of_ruin(self) -> Decimal:
|
|
142
|
+
"""The fraction of paths reporting a period with unmet need."""
|
|
143
|
+
ruined = sum(1 for outcome in self.outcomes if outcome.ruined)
|
|
144
|
+
return Decimal(ruined) / Decimal(self.path_count)
|
|
145
|
+
|
|
146
|
+
@property
|
|
147
|
+
def success_rate(self) -> Decimal:
|
|
148
|
+
"""The complement of :attr:`probability_of_ruin`."""
|
|
149
|
+
return _ONE - self.probability_of_ruin
|
|
150
|
+
|
|
151
|
+
def ending_pot_percentile(self, percentile: Decimal) -> Money:
|
|
152
|
+
"""The ending-balance percentile over paths, in [0, 100].
|
|
153
|
+
|
|
154
|
+
Linear interpolation between order statistics: rank
|
|
155
|
+
``(count - 1) * percentile / 100`` over the sorted balances,
|
|
156
|
+
fractional ranks interpolating between the two neighbours —
|
|
157
|
+
exact ``Decimal`` arithmetic, quantized as a presentation
|
|
158
|
+
value (planning §4.6).
|
|
159
|
+
|
|
160
|
+
Raises:
|
|
161
|
+
ValueError: If ``percentile`` lies outside [0, 100].
|
|
162
|
+
"""
|
|
163
|
+
return _interpolated_percentile(
|
|
164
|
+
[outcome.ending_balance for outcome in self.outcomes], percentile
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
def balance_percentile(self, percentile: Decimal) -> tuple[Money, ...]:
|
|
168
|
+
"""The per-period closing-balance percentile over paths (9.13).
|
|
169
|
+
|
|
170
|
+
One entry per projected period, in period order — the same
|
|
171
|
+
order-statistic interpolation as
|
|
172
|
+
:meth:`ending_pot_percentile`, applied period by period over
|
|
173
|
+
the paths' nominal household balances. CPI is deterministic
|
|
174
|
+
across paths (module docstring), so deflating each entry by
|
|
175
|
+
the period's price level presents the band in today's money.
|
|
176
|
+
|
|
177
|
+
Raises:
|
|
178
|
+
ValueError: If ``percentile`` lies outside [0, 100], or
|
|
179
|
+
the outcomes carry differing period counts.
|
|
180
|
+
"""
|
|
181
|
+
[values] = self.balance_percentiles((percentile,))
|
|
182
|
+
return values
|
|
183
|
+
|
|
184
|
+
def balance_percentiles(
|
|
185
|
+
self, percentiles: Sequence[Decimal]
|
|
186
|
+
) -> tuple[tuple[Money, ...], ...]:
|
|
187
|
+
"""Several balance percentiles from one sort per period (9.24).
|
|
188
|
+
|
|
189
|
+
One :meth:`balance_percentile` row per requested percentile,
|
|
190
|
+
in request order. The fan chart reads nine percentiles at
|
|
191
|
+
once; interpolating them all from a single sorted balance
|
|
192
|
+
vector per period keeps the cost one sort rather than one per
|
|
193
|
+
percentile — the shells build their chart view models on the
|
|
194
|
+
GUI thread, where a 10,000-path result sorted nine times over
|
|
195
|
+
every period is a visible stall.
|
|
196
|
+
|
|
197
|
+
Raises:
|
|
198
|
+
ValueError: If any percentile lies outside [0, 100], or
|
|
199
|
+
the outcomes carry differing period counts.
|
|
200
|
+
"""
|
|
201
|
+
for percentile in percentiles:
|
|
202
|
+
_check_percentile(percentile)
|
|
203
|
+
lengths = {len(outcome.closing_balances) for outcome in self.outcomes}
|
|
204
|
+
if len(lengths) != 1:
|
|
205
|
+
msg = "balance_percentiles needs every path to cover the same periods"
|
|
206
|
+
raise ValueError(msg)
|
|
207
|
+
by_period = [
|
|
208
|
+
sorted(outcome.closing_balances[index] for outcome in self.outcomes)
|
|
209
|
+
for index in range(lengths.pop())
|
|
210
|
+
]
|
|
211
|
+
return tuple(
|
|
212
|
+
tuple(_sorted_percentile(ordered, percentile) for ordered in by_period)
|
|
213
|
+
for percentile in percentiles
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@dataclass(frozen=True, slots=True)
|
|
218
|
+
class PathParallelism:
|
|
219
|
+
"""How :func:`run_paths` spreads its paths over workers (§5.2).
|
|
220
|
+
|
|
221
|
+
``executor`` runs path chunks — a ``ProcessPoolExecutor`` for real
|
|
222
|
+
speedup (the engine is CPU-bound ``Decimal`` work), or any executor
|
|
223
|
+
in tests. ``workers`` is the executor's worker count: paths are
|
|
224
|
+
split into that many contiguous chunks, so every submitted argument
|
|
225
|
+
tuple (plan, assumptions, region, config) is pickled once per
|
|
226
|
+
worker, not once per path. Paths are pure and order-independent
|
|
227
|
+
(planning §4.6), and chunk results are recombined in path order, so
|
|
228
|
+
the result is identical to a serial run whatever the executor.
|
|
229
|
+
|
|
230
|
+
The caller owns the executor's lifecycle: reuse one across the many
|
|
231
|
+
:func:`run_paths` calls of a retirement-age search rather than
|
|
232
|
+
paying process startup per candidate.
|
|
233
|
+
"""
|
|
234
|
+
|
|
235
|
+
executor: Executor
|
|
236
|
+
workers: int
|
|
237
|
+
|
|
238
|
+
def __post_init__(self) -> None:
|
|
239
|
+
"""Reject a worker count below one."""
|
|
240
|
+
if self.workers < 1:
|
|
241
|
+
msg = f"workers must be positive, got {self.workers}"
|
|
242
|
+
raise ValueError(msg)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def run_paths(
|
|
246
|
+
plan: Household,
|
|
247
|
+
assumptions: AssumptionSet,
|
|
248
|
+
region: Region,
|
|
249
|
+
config: RunConfig,
|
|
250
|
+
*,
|
|
251
|
+
paths: int,
|
|
252
|
+
parallelism: PathParallelism | None = None,
|
|
253
|
+
) -> MonteCarloResult:
|
|
254
|
+
"""Project ``plan`` over ``paths`` seeded paths (planning §5.2).
|
|
255
|
+
|
|
256
|
+
Path *i* is ``run(plan, assumptions, region, replace(config,
|
|
257
|
+
path=i))`` — always paths 0 through ``paths - 1``, whatever
|
|
258
|
+
``config.path`` says: paths are order-independent and individually
|
|
259
|
+
re-runnable from the seed alone (planning §4.6). The provenance is
|
|
260
|
+
the union across paths in first-read order (class docstring).
|
|
261
|
+
|
|
262
|
+
With ``parallelism`` the paths run as contiguous chunks on its
|
|
263
|
+
executor; the result is identical to the serial run — same
|
|
264
|
+
outcomes in path order, same provenance union — because every path
|
|
265
|
+
is exactly determined by ``(config.seed, i)`` (§4.6).
|
|
266
|
+
|
|
267
|
+
Raises:
|
|
268
|
+
EngineError: If ``paths`` is not positive, ``config.mode`` is
|
|
269
|
+
not ``MONTE_CARLO``, the config carries no seed, or any
|
|
270
|
+
path's projection is rejected by the engine.
|
|
271
|
+
"""
|
|
272
|
+
if paths < 1:
|
|
273
|
+
msg = f"paths must be positive, got {paths}"
|
|
274
|
+
raise EngineError(msg)
|
|
275
|
+
if config.mode is not RunMode.MONTE_CARLO:
|
|
276
|
+
msg = "run_paths requires RunMode.MONTE_CARLO (planning §5.2)"
|
|
277
|
+
raise EngineError(msg)
|
|
278
|
+
if parallelism is None or parallelism.workers == 1 or paths == 1:
|
|
279
|
+
outcomes, provenance = _run_path_range(
|
|
280
|
+
plan, assumptions, region, config, (0, paths)
|
|
281
|
+
)
|
|
282
|
+
else:
|
|
283
|
+
futures = [
|
|
284
|
+
parallelism.executor.submit(
|
|
285
|
+
_run_path_range, plan, assumptions, region, config, chunk
|
|
286
|
+
)
|
|
287
|
+
for chunk in _path_chunks(paths, parallelism.workers)
|
|
288
|
+
]
|
|
289
|
+
chunk_results = [future.result() for future in futures]
|
|
290
|
+
outcomes = tuple(
|
|
291
|
+
outcome for chunk_outcomes, _ in chunk_results for outcome in chunk_outcomes
|
|
292
|
+
)
|
|
293
|
+
provenance = _merged_provenance(
|
|
294
|
+
[chunk_provenance for _, chunk_provenance in chunk_results]
|
|
295
|
+
)
|
|
296
|
+
return MonteCarloResult(outcomes=outcomes, config=config, provenance=provenance)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _run_path_range(
|
|
300
|
+
plan: Household,
|
|
301
|
+
assumptions: AssumptionSet,
|
|
302
|
+
region: Region,
|
|
303
|
+
config: RunConfig,
|
|
304
|
+
chunk: tuple[int, int],
|
|
305
|
+
) -> tuple[tuple[PathOutcome, ...], RunProvenance]:
|
|
306
|
+
"""Project the ``(start, stop)`` half-open ``chunk`` of path indices.
|
|
307
|
+
|
|
308
|
+
The unit of work one executor worker runs: a module-level function
|
|
309
|
+
over picklable arguments, returning only the reduced outcomes and
|
|
310
|
+
the chunk's provenance union — never the paths' full ledgers, so a
|
|
311
|
+
parallel run ships back kilobytes per chunk, not the projections.
|
|
312
|
+
Merging the chunk unions in chunk order reproduces the serial
|
|
313
|
+
provenance exactly: the union operation is associative over ordered
|
|
314
|
+
concatenation, and this chunk's merged base is its first path's.
|
|
315
|
+
"""
|
|
316
|
+
start, stop = chunk
|
|
317
|
+
outcomes: list[PathOutcome] = []
|
|
318
|
+
provenances: list[RunProvenance] = []
|
|
319
|
+
for index in range(start, stop):
|
|
320
|
+
result = run(plan, assumptions, region, _path_config(config, index))
|
|
321
|
+
outcomes.append(_path_outcome(index, result))
|
|
322
|
+
provenances.append(result.provenance)
|
|
323
|
+
return tuple(outcomes), _merged_provenance(provenances)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _path_chunks(paths: int, workers: int) -> Iterator[tuple[int, int]]:
|
|
327
|
+
"""Split ``range(paths)`` into up to ``workers`` contiguous chunks.
|
|
328
|
+
|
|
329
|
+
Chunk sizes differ by at most one, larger chunks first — a
|
|
330
|
+
deterministic tiling, so the parallel path order never depends on
|
|
331
|
+
scheduling.
|
|
332
|
+
"""
|
|
333
|
+
chunk_count = min(workers, paths)
|
|
334
|
+
size, extra = divmod(paths, chunk_count)
|
|
335
|
+
start = 0
|
|
336
|
+
for index in range(chunk_count):
|
|
337
|
+
stop = start + size + (1 if index < extra else 0)
|
|
338
|
+
yield start, stop
|
|
339
|
+
start = stop
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
@dataclass(frozen=True, slots=True)
|
|
343
|
+
class SustainableIncomeSearch:
|
|
344
|
+
"""The parameters of one sustainable-income search (planning §5.2).
|
|
345
|
+
|
|
346
|
+
The search covers ``[0, maximum]`` of real annual spending: a
|
|
347
|
+
descending scan over ``scan_steps`` equal steps finds the highest
|
|
348
|
+
succeeding scan point, then bisection refines upward within the
|
|
349
|
+
step above it, stopping once the bracket narrows to ``tolerance``.
|
|
350
|
+
The scan is what makes the search robust to a non-monotone success
|
|
351
|
+
predicate (module docstring): success islands narrower than
|
|
352
|
+
``maximum / scan_steps`` can still be missed, so raise
|
|
353
|
+
``scan_steps`` when the withdrawal strategy has adjustment
|
|
354
|
+
triggers; ``scan_steps=1`` is a pure bisection. Probe count is at
|
|
355
|
+
most ``1 + scan_steps`` plus the bisection's
|
|
356
|
+
``log2(step / tolerance)``.
|
|
357
|
+
|
|
358
|
+
``paths`` and ``target_success_rate`` apply only under a Monte
|
|
359
|
+
Carlo config: ``paths`` seeded paths are projected per candidate
|
|
360
|
+
spending level, and a candidate meets the target when at least the
|
|
361
|
+
target fraction of them avoid ruin. A deterministic probe ignores
|
|
362
|
+
them — success is one run with no unmet need, the single-path
|
|
363
|
+
equivalent of a 100% target (the same convention as
|
|
364
|
+
:class:`~glidepath.core.retirement.RetirementAgeSearch`).
|
|
365
|
+
"""
|
|
366
|
+
|
|
367
|
+
maximum: Money
|
|
368
|
+
tolerance: Money = _DEFAULT_TOLERANCE
|
|
369
|
+
scan_steps: int = 10
|
|
370
|
+
paths: int = 1
|
|
371
|
+
target_success_rate: Decimal = _ONE
|
|
372
|
+
|
|
373
|
+
def __post_init__(self) -> None:
|
|
374
|
+
"""Reject an empty path count, an off-range target, or bad bounds."""
|
|
375
|
+
if self.paths < 1:
|
|
376
|
+
msg = f"paths must be positive, got {self.paths}"
|
|
377
|
+
raise ValueError(msg)
|
|
378
|
+
if not Decimal(0) < self.target_success_rate <= _ONE:
|
|
379
|
+
msg = (
|
|
380
|
+
"target_success_rate must lie in (0, 1],"
|
|
381
|
+
f" got {self.target_success_rate}"
|
|
382
|
+
)
|
|
383
|
+
raise ValueError(msg)
|
|
384
|
+
if self.maximum <= _ZERO:
|
|
385
|
+
msg = "maximum must be positive"
|
|
386
|
+
raise ValueError(msg)
|
|
387
|
+
if self.tolerance <= _ZERO:
|
|
388
|
+
msg = "tolerance must be positive"
|
|
389
|
+
raise ValueError(msg)
|
|
390
|
+
if self.scan_steps < 1:
|
|
391
|
+
msg = f"scan_steps must be positive, got {self.scan_steps}"
|
|
392
|
+
raise ValueError(msg)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def sustainable_income(
|
|
396
|
+
plan: Household,
|
|
397
|
+
assumptions: AssumptionSet,
|
|
398
|
+
region: Region,
|
|
399
|
+
config: RunConfig,
|
|
400
|
+
search: SustainableIncomeSearch,
|
|
401
|
+
*,
|
|
402
|
+
parallelism: PathParallelism | None = None,
|
|
403
|
+
) -> Money | None:
|
|
404
|
+
"""The highest starting withdrawal meeting the target (scan + bisect).
|
|
405
|
+
|
|
406
|
+
Searches the spending plan's real annual amount (today's money —
|
|
407
|
+
the "starting withdrawal": the engine escalates it by the run's
|
|
408
|
+
CPI path) over the search's ``[0, maximum]``: a descending scan
|
|
409
|
+
over the search's ``scan_steps`` finds the highest succeeding scan
|
|
410
|
+
point, then bisection refines upward within the step above it.
|
|
411
|
+
Returns the highest probed level that met the target — ``maximum``
|
|
412
|
+
itself when even that meets — or ``None`` when no scan point and
|
|
413
|
+
not even zero spending does (the plan's outflows already exhaust
|
|
414
|
+
it). Every returned value was actually probed, never interpolated.
|
|
415
|
+
|
|
416
|
+
For a monotone success predicate (the default fixed-real strategy)
|
|
417
|
+
the result is within the search's tolerance of the true boundary;
|
|
418
|
+
for adjustment-trigger strategies it is never below the highest
|
|
419
|
+
succeeding scan point, with the resolution caveat on
|
|
420
|
+
:class:`SustainableIncomeSearch`.
|
|
421
|
+
|
|
422
|
+
The plan's stated spending amount is irrelevant to the search —
|
|
423
|
+
only its stage multipliers and the rest of the plan carry over; a
|
|
424
|
+
plan with no spending plan is probed with a bare one. Probes reuse
|
|
425
|
+
``config`` unchanged, and its mode is the search's basis: under a
|
|
426
|
+
Monte Carlo config a candidate meets when its seeded paths' success
|
|
427
|
+
rate meets the search's target (same seed per probe: common random
|
|
428
|
+
numbers, keeping the §4.6 reproducibility guarantee over the whole
|
|
429
|
+
search); under a deterministic config a candidate meets when its
|
|
430
|
+
single run reports no period with unmet need — the §5.2 ruin
|
|
431
|
+
signal, exactly as :func:`~glidepath.core.retirement.earliest_retirement_age`
|
|
432
|
+
reads it. ``parallelism`` spreads each Monte Carlo probe's paths
|
|
433
|
+
over its executor (:class:`PathParallelism` — results identical to
|
|
434
|
+
a serial search); pass one executor for the whole search so probes
|
|
435
|
+
share it.
|
|
436
|
+
|
|
437
|
+
Raises:
|
|
438
|
+
EngineError: If a probe is rejected by the engine — including
|
|
439
|
+
a Monte Carlo config without a seed (planning §5.2).
|
|
440
|
+
"""
|
|
441
|
+
|
|
442
|
+
def meets(amount: Money) -> bool:
|
|
443
|
+
"""Whether spending ``amount`` meets the search's target."""
|
|
444
|
+
probe = probe_with_spending(plan, amount, config)
|
|
445
|
+
if config.mode is not RunMode.MONTE_CARLO:
|
|
446
|
+
return not has_shortfall(run(probe, assumptions, region, config))
|
|
447
|
+
result = run_paths(
|
|
448
|
+
probe,
|
|
449
|
+
assumptions,
|
|
450
|
+
region,
|
|
451
|
+
config,
|
|
452
|
+
paths=search.paths,
|
|
453
|
+
parallelism=parallelism,
|
|
454
|
+
)
|
|
455
|
+
return result.success_rate >= search.target_success_rate
|
|
456
|
+
|
|
457
|
+
high = search.maximum
|
|
458
|
+
if meets(high):
|
|
459
|
+
return high
|
|
460
|
+
low: Money | None = None
|
|
461
|
+
step = search.maximum.amount / Decimal(search.scan_steps)
|
|
462
|
+
for index in range(search.scan_steps - 1, 0, -1):
|
|
463
|
+
candidate = Money(step * Decimal(index))
|
|
464
|
+
if meets(candidate):
|
|
465
|
+
low = candidate
|
|
466
|
+
break
|
|
467
|
+
high = candidate
|
|
468
|
+
if low is None:
|
|
469
|
+
low = _ZERO
|
|
470
|
+
if not meets(low):
|
|
471
|
+
return None
|
|
472
|
+
while high - low > search.tolerance:
|
|
473
|
+
midpoint = Money((low.amount + high.amount) / _TWO)
|
|
474
|
+
if meets(midpoint):
|
|
475
|
+
low = midpoint
|
|
476
|
+
else:
|
|
477
|
+
high = midpoint
|
|
478
|
+
return low
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _check_percentile(percentile: Decimal) -> None:
|
|
482
|
+
"""Reject a percentile outside [0, 100].
|
|
483
|
+
|
|
484
|
+
Raises:
|
|
485
|
+
ValueError: If ``percentile`` lies outside [0, 100].
|
|
486
|
+
"""
|
|
487
|
+
if not Decimal(0) <= percentile <= _HUNDRED:
|
|
488
|
+
msg = f"percentile must lie between 0 and 100, got {percentile}"
|
|
489
|
+
raise ValueError(msg)
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _interpolated_percentile(balances: Sequence[Money], percentile: Decimal) -> Money:
|
|
493
|
+
"""The ``percentile`` of ``balances`` between order statistics.
|
|
494
|
+
|
|
495
|
+
Rank ``(count - 1) * percentile / 100`` over the sorted balances,
|
|
496
|
+
fractional ranks interpolating between the two neighbours — exact
|
|
497
|
+
``Decimal`` arithmetic, quantized as a presentation value
|
|
498
|
+
(planning §4.6).
|
|
499
|
+
|
|
500
|
+
Raises:
|
|
501
|
+
ValueError: If ``percentile`` lies outside [0, 100].
|
|
502
|
+
"""
|
|
503
|
+
_check_percentile(percentile)
|
|
504
|
+
return _sorted_percentile(sorted(balances), percentile)
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _sorted_percentile(ordered: Sequence[Money], percentile: Decimal) -> Money:
|
|
508
|
+
""":func:`_interpolated_percentile` over an already-sorted vector.
|
|
509
|
+
|
|
510
|
+
Split out so a caller reading several percentiles of one
|
|
511
|
+
population (the fan chart's nine) sorts it once.
|
|
512
|
+
"""
|
|
513
|
+
rank = (Decimal(len(ordered)) - _ONE) * percentile / _HUNDRED
|
|
514
|
+
lower = int(rank)
|
|
515
|
+
fraction = rank - Decimal(lower)
|
|
516
|
+
value = ordered[lower]
|
|
517
|
+
if fraction > 0:
|
|
518
|
+
value = value + (ordered[lower + 1] - ordered[lower]) * fraction
|
|
519
|
+
return value.quantized()
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def _merged_provenance(provenances: Sequence[RunProvenance]) -> RunProvenance:
|
|
523
|
+
"""Path 0's provenance with the assumption union of every path.
|
|
524
|
+
|
|
525
|
+
Almost every assumption read is plan- or date-driven and identical
|
|
526
|
+
across paths, but a balance-dependent read — natural-yield pricing
|
|
527
|
+
fires only while a source still holds money (roadmap 5.3) — can
|
|
528
|
+
appear on some paths only. The union keeps the §4.6 manifest
|
|
529
|
+
exhaustive: path 0's first-read order, then any later-path-only
|
|
530
|
+
keys in path order. Facts, decisions, the region data version, and
|
|
531
|
+
the seed are path-independent, so path 0's stand for all.
|
|
532
|
+
"""
|
|
533
|
+
merged: dict[AssumptionKey, Assumption[Any]] = {}
|
|
534
|
+
for provenance in provenances:
|
|
535
|
+
for assumption in provenance.assumptions:
|
|
536
|
+
merged.setdefault(assumption.key, assumption)
|
|
537
|
+
changes: dict[str, Any] = {"assumptions": tuple(merged.values())}
|
|
538
|
+
return replace(provenances[0], **changes) if changes else provenances[0]
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def _path_config(config: RunConfig, index: int) -> RunConfig:
|
|
542
|
+
"""The run configuration for one path: ``config`` at ``path=index``."""
|
|
543
|
+
changes: dict[str, Any] = {"path": index}
|
|
544
|
+
return replace(config, **changes) if changes else config
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def _path_outcome(index: int, result: ProjectionResult) -> PathOutcome:
|
|
548
|
+
"""Reduce one path's projection to its success signals."""
|
|
549
|
+
first_shortfall = None
|
|
550
|
+
balances = []
|
|
551
|
+
for snapshot in result.snapshots:
|
|
552
|
+
shortfall = _ZERO
|
|
553
|
+
closing = _ZERO
|
|
554
|
+
for person in snapshot.persons:
|
|
555
|
+
shortfall = shortfall + person.shortfall
|
|
556
|
+
for wrapper in person.wrappers:
|
|
557
|
+
closing = closing + wrapper.closing_balance
|
|
558
|
+
if shortfall > _ZERO and first_shortfall is None:
|
|
559
|
+
first_shortfall = snapshot.period
|
|
560
|
+
balances.append(closing)
|
|
561
|
+
return PathOutcome(
|
|
562
|
+
path=index,
|
|
563
|
+
first_shortfall_period=first_shortfall,
|
|
564
|
+
ending_balance=balances[-1],
|
|
565
|
+
closing_balances=tuple(balances),
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def has_shortfall(result: ProjectionResult) -> bool:
|
|
570
|
+
"""Whether any period's need went unmet — the §5.2 ruin signal.
|
|
571
|
+
|
|
572
|
+
What a deterministic probe's success reads: the single-run
|
|
573
|
+
equivalent of a path's ruin. Shared by the sustainable-income
|
|
574
|
+
search and the earliest-retirement-age solver
|
|
575
|
+
(:mod:`glidepath.core.retirement`, roadmap 9.14); not part of the
|
|
576
|
+
package API.
|
|
577
|
+
"""
|
|
578
|
+
return any(
|
|
579
|
+
person.shortfall > _ZERO
|
|
580
|
+
for snapshot in result.snapshots
|
|
581
|
+
for person in snapshot.persons
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def probe_with_spending(plan: Household, amount: Money, config: RunConfig) -> Household:
|
|
586
|
+
"""The plan with its spending level replaced by a probe amount.
|
|
587
|
+
|
|
588
|
+
An existing spending plan keeps its fact metadata and stage
|
|
589
|
+
multipliers; a plan without one gains a bare probe plan whose
|
|
590
|
+
"fact" is synthesized from ``config.today`` (no clock read —
|
|
591
|
+
planning §4.6). Probe plans never leave the search, so the
|
|
592
|
+
synthetic fact never lands in any result's provenance. Shared by
|
|
593
|
+
the sustainable-income search and the earliest-retirement-age
|
|
594
|
+
solver (:mod:`glidepath.core.retirement`, roadmap 9.14); not part
|
|
595
|
+
of the package API.
|
|
596
|
+
"""
|
|
597
|
+
spending = plan.spending
|
|
598
|
+
if spending is None:
|
|
599
|
+
recorded = datetime.combine(config.today, time.min, tzinfo=UTC)
|
|
600
|
+
probe = SpendingPlan(
|
|
601
|
+
annual_spending_real=Fact(
|
|
602
|
+
value=amount, as_of=config.today, recorded_on=recorded
|
|
603
|
+
)
|
|
604
|
+
)
|
|
605
|
+
else:
|
|
606
|
+
fact = replace(spending.annual_spending_real, value=amount)
|
|
607
|
+
probe = replace(spending, annual_spending_real=fact)
|
|
608
|
+
changes: dict[str, Any] = {"spending": probe}
|
|
609
|
+
return replace(plan, **changes) if changes else plan
|