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,514 @@
|
|
|
1
|
+
"""Historical backtesting over rolling windows (roadmap 9.18; planning §5.2).
|
|
2
|
+
|
|
3
|
+
:func:`run_windows` projects a plan once per rolling window of an
|
|
4
|
+
annual historical return series, as a complement to Monte Carlo:
|
|
5
|
+
rolling windows preserve the sequence-of-returns and regime behaviour
|
|
6
|
+
(crashes followed by recoveries, sustained inflation episodes) that
|
|
7
|
+
independent lognormal draws cannot reproduce, and they answer the
|
|
8
|
+
question "would this plan have survived starting in 1973?". Every
|
|
9
|
+
window is an ordinary deterministic :func:`~glidepath.core.engine.run`
|
|
10
|
+
— the same one step function, only the return model differs (the §5.2
|
|
11
|
+
design invariant) — so the whole backtest is reproducible with no
|
|
12
|
+
randomness involved.
|
|
13
|
+
|
|
14
|
+
The series carries **nominal** observed rates together with each
|
|
15
|
+
year's realised CPI; what a window replays is the year's **real**
|
|
16
|
+
return, recomposed with the run's assumed CPI
|
|
17
|
+
(:class:`HistoricalWindowModel`). Recomposition keeps the
|
|
18
|
+
single-inflation-truth rule intact — the engine's nominal ledger, its
|
|
19
|
+
tax-band dynamics, and the reporting deflators all follow the run's
|
|
20
|
+
one assumed CPI path, exactly as under Monte Carlo (where CPI is also
|
|
21
|
+
deterministic across paths) — and makes windows mutually comparable
|
|
22
|
+
in today's money. The accepted cost is shared with Monte Carlo:
|
|
23
|
+
nominal-anchored mechanics (frozen tax bands) interact with the
|
|
24
|
+
assumed CPI, not each window's historical inflation.
|
|
25
|
+
|
|
26
|
+
Success reporting mirrors the Monte Carlo metrics (planning §5.2):
|
|
27
|
+
success rate over windows, worst-window identification, and
|
|
28
|
+
ending-pot/balance percentiles, so the two surfaces can sit side by
|
|
29
|
+
side in the GUI.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from dataclasses import dataclass
|
|
33
|
+
from datetime import date
|
|
34
|
+
from decimal import Decimal
|
|
35
|
+
from typing import TYPE_CHECKING
|
|
36
|
+
|
|
37
|
+
from glidepath.core.config import EngineError, RunMode
|
|
38
|
+
from glidepath.core.engine import run
|
|
39
|
+
from glidepath.core.investments import AssetReturns
|
|
40
|
+
from glidepath.core.money import Money, Rate
|
|
41
|
+
from glidepath.core.montecarlo import (
|
|
42
|
+
PathParallelism,
|
|
43
|
+
_check_percentile,
|
|
44
|
+
_interpolated_percentile,
|
|
45
|
+
_merged_provenance,
|
|
46
|
+
_path_chunks,
|
|
47
|
+
_path_outcome,
|
|
48
|
+
)
|
|
49
|
+
from glidepath.core.provenance import AssumptionKey, decimal_assumption_value
|
|
50
|
+
from glidepath.core.returns import PeriodReturns, nominal_rate
|
|
51
|
+
|
|
52
|
+
if TYPE_CHECKING:
|
|
53
|
+
from glidepath.core.config import RunConfig
|
|
54
|
+
from glidepath.core.entities import Household
|
|
55
|
+
from glidepath.core.periods import Period
|
|
56
|
+
from glidepath.core.provenance import AssumptionSet, TrackedAssumptions
|
|
57
|
+
from glidepath.core.region import Region
|
|
58
|
+
from glidepath.core.results import RunProvenance
|
|
59
|
+
from glidepath.core.returns import ReturnModelFactory
|
|
60
|
+
|
|
61
|
+
_ONE = Decimal(1)
|
|
62
|
+
_MINUS_ONE = Decimal(-1)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True, slots=True)
|
|
66
|
+
class HistoricalYear:
|
|
67
|
+
"""One year's observed nominal rates and realised CPI inflation.
|
|
68
|
+
|
|
69
|
+
``equity``, ``bonds`` and ``cash`` are nominal annual total
|
|
70
|
+
returns (GBP terms for the shipped UK series); ``cpi`` is the
|
|
71
|
+
year's realised CPI inflation. Real rates are derived, never
|
|
72
|
+
stored (``(1 + nominal) / (1 + cpi) - 1``), so the shipped data
|
|
73
|
+
file stays directly checkable against its sources.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
year: int
|
|
77
|
+
equity: Decimal
|
|
78
|
+
bonds: Decimal
|
|
79
|
+
cash: Decimal
|
|
80
|
+
cpi: Decimal
|
|
81
|
+
|
|
82
|
+
def __post_init__(self) -> None:
|
|
83
|
+
"""Reject rates at or below -100%.
|
|
84
|
+
|
|
85
|
+
A -100% observation would zero a growth factor (or the price
|
|
86
|
+
level) and cannot be recomposed into a real rate.
|
|
87
|
+
"""
|
|
88
|
+
for name in ("equity", "bonds", "cash", "cpi"):
|
|
89
|
+
if getattr(self, name) <= _MINUS_ONE:
|
|
90
|
+
msg = f"HistoricalYear.{name} must be greater than -1"
|
|
91
|
+
raise ValueError(msg)
|
|
92
|
+
|
|
93
|
+
def real_rate(self, nominal: Decimal) -> Decimal:
|
|
94
|
+
"""Deflate a nominal rate by this year's realised CPI."""
|
|
95
|
+
return (_ONE + nominal) / (_ONE + self.cpi) - _ONE
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass(frozen=True, slots=True)
|
|
99
|
+
class HistoricalSeries:
|
|
100
|
+
"""A contiguous run of :class:`HistoricalYear` observations.
|
|
101
|
+
|
|
102
|
+
The region ships one as a §5.3 data file
|
|
103
|
+
(``returns_history.toml``) with ``verified_on`` + sources in its
|
|
104
|
+
meta; the runner records the series it actually replayed on the
|
|
105
|
+
:class:`BacktestResult`, so the manifest names the real input
|
|
106
|
+
whatever series a caller supplies.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
years: tuple[HistoricalYear, ...]
|
|
110
|
+
|
|
111
|
+
def __post_init__(self) -> None:
|
|
112
|
+
"""Reject an empty or non-contiguous series."""
|
|
113
|
+
if not self.years:
|
|
114
|
+
msg = "HistoricalSeries needs at least one year"
|
|
115
|
+
raise ValueError(msg)
|
|
116
|
+
for previous, current in zip(self.years, self.years[1:], strict=False):
|
|
117
|
+
if current.year != previous.year + 1:
|
|
118
|
+
msg = (
|
|
119
|
+
"HistoricalSeries years must be contiguous and ascending:"
|
|
120
|
+
f" {previous.year} is followed by {current.year}"
|
|
121
|
+
)
|
|
122
|
+
raise ValueError(msg)
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def first_year(self) -> int:
|
|
126
|
+
"""The first observed calendar year."""
|
|
127
|
+
return self.years[0].year
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def last_year(self) -> int:
|
|
131
|
+
"""The last observed calendar year."""
|
|
132
|
+
return self.years[-1].year
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def length(self) -> int:
|
|
136
|
+
"""How many consecutive years the series observes."""
|
|
137
|
+
return len(self.years)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass(frozen=True, slots=True)
|
|
141
|
+
class HistoricalWindowModel:
|
|
142
|
+
"""Return model replaying one rolling window of a historical series.
|
|
143
|
+
|
|
144
|
+
The engine's period at ordinal *n* (counted from the period
|
|
145
|
+
containing ``today``, whose start year is ``anchor_year``) reads
|
|
146
|
+
the series observation at index ``window + n``: window 0 replays
|
|
147
|
+
the series from its first year, window 1 from its second, and so
|
|
148
|
+
on. Each observation's **real** rates are recomposed with the
|
|
149
|
+
run's assumed CPI (module docstring), read through the tracked
|
|
150
|
+
view so the CPI key lands in the run's provenance. Pure (planning
|
|
151
|
+
§4.6): the returns depend only on the frozen series, the window
|
|
152
|
+
offset, and the assumption view.
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
series: HistoricalSeries
|
|
156
|
+
window: int
|
|
157
|
+
anchor_year: int
|
|
158
|
+
assumptions: TrackedAssumptions
|
|
159
|
+
|
|
160
|
+
def __post_init__(self) -> None:
|
|
161
|
+
"""Reject a window offset outside the series."""
|
|
162
|
+
if not 0 <= self.window < self.series.length:
|
|
163
|
+
msg = (
|
|
164
|
+
f"window must lie within the series (0 to"
|
|
165
|
+
f" {self.series.length - 1}), got {self.window}"
|
|
166
|
+
)
|
|
167
|
+
raise ValueError(msg)
|
|
168
|
+
|
|
169
|
+
def returns_for(self, period: Period, _path: int, /) -> PeriodReturns:
|
|
170
|
+
"""The window's returns for ``period``, recomposed with assumed CPI.
|
|
171
|
+
|
|
172
|
+
Raises:
|
|
173
|
+
EngineError: If the period falls before the run anchor or
|
|
174
|
+
past the end of the series — the plan's horizon needs
|
|
175
|
+
more years than the series holds beyond this window's
|
|
176
|
+
start.
|
|
177
|
+
"""
|
|
178
|
+
ordinal = period.start.year - self.anchor_year
|
|
179
|
+
if ordinal < 0:
|
|
180
|
+
msg = (
|
|
181
|
+
f"period starting {period.start} precedes the run anchor"
|
|
182
|
+
f" year {self.anchor_year}"
|
|
183
|
+
)
|
|
184
|
+
raise EngineError(msg)
|
|
185
|
+
index = self.window + ordinal
|
|
186
|
+
if index >= self.series.length:
|
|
187
|
+
start = self.series.first_year + self.window
|
|
188
|
+
msg = (
|
|
189
|
+
f"the historical series ends in {self.series.last_year}:"
|
|
190
|
+
f" a window starting in {start} cannot cover the run's"
|
|
191
|
+
f" period at ordinal {ordinal} — the horizon needs more"
|
|
192
|
+
" years than the series holds"
|
|
193
|
+
)
|
|
194
|
+
raise EngineError(msg)
|
|
195
|
+
observed = self.series.years[index]
|
|
196
|
+
cpi = decimal_assumption_value(
|
|
197
|
+
self.assumptions.get(AssumptionKey.INFLATION_CPI)
|
|
198
|
+
)
|
|
199
|
+
equity, bonds, cash = (
|
|
200
|
+
nominal_rate(observed.real_rate(nominal), cpi)
|
|
201
|
+
for nominal in (observed.equity, observed.bonds, observed.cash)
|
|
202
|
+
)
|
|
203
|
+
return PeriodReturns(
|
|
204
|
+
assets=AssetReturns(equity=equity, bonds=bonds, cash=cash),
|
|
205
|
+
cpi=Rate(cpi),
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def historical_window_factory(
|
|
210
|
+
series: HistoricalSeries, window: int, anchor_year: int
|
|
211
|
+
) -> ReturnModelFactory:
|
|
212
|
+
"""A return-model factory for one window — re-run any single window.
|
|
213
|
+
|
|
214
|
+
The factory a :func:`run_windows` window *w* injects is exactly
|
|
215
|
+
``historical_window_factory(series, w, anchor_year)``, so any
|
|
216
|
+
window is individually re-runnable through
|
|
217
|
+
:func:`~glidepath.core.engine.run` (planning §4.6).
|
|
218
|
+
"""
|
|
219
|
+
|
|
220
|
+
def factory(tracked: TrackedAssumptions) -> HistoricalWindowModel:
|
|
221
|
+
"""Build the window's model over the run's tracked view."""
|
|
222
|
+
return HistoricalWindowModel(
|
|
223
|
+
series=series, window=window, anchor_year=anchor_year, assumptions=tracked
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
return factory
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@dataclass(frozen=True, slots=True)
|
|
230
|
+
class WindowOutcome:
|
|
231
|
+
"""One rolling window, reduced to its success signals.
|
|
232
|
+
|
|
233
|
+
The mirror of :class:`~glidepath.core.montecarlo.PathOutcome`:
|
|
234
|
+
``start_year`` is the calendar year the window starts replaying
|
|
235
|
+
(the "would this plan have survived starting in 1973?" year),
|
|
236
|
+
``first_shortfall_period`` the first projected period whose need
|
|
237
|
+
went unmet (``None`` when the window succeeds), and
|
|
238
|
+
``closing_balances`` the household's nominal closing balance per
|
|
239
|
+
period. Every window projects the same plan over the same
|
|
240
|
+
periods, so shortfall periods and balances are directly
|
|
241
|
+
comparable across windows.
|
|
242
|
+
"""
|
|
243
|
+
|
|
244
|
+
window: int
|
|
245
|
+
start_year: int
|
|
246
|
+
first_shortfall_period: Period | None
|
|
247
|
+
ending_balance: Money
|
|
248
|
+
closing_balances: tuple[Money, ...] = ()
|
|
249
|
+
|
|
250
|
+
def __post_init__(self) -> None:
|
|
251
|
+
"""Reject a negative window index or inconsistent balances."""
|
|
252
|
+
if self.window < 0:
|
|
253
|
+
msg = f"WindowOutcome.window must be non-negative, got {self.window}"
|
|
254
|
+
raise ValueError(msg)
|
|
255
|
+
if self.closing_balances and self.closing_balances[-1] != self.ending_balance:
|
|
256
|
+
msg = "WindowOutcome.closing_balances must end at ending_balance"
|
|
257
|
+
raise ValueError(msg)
|
|
258
|
+
|
|
259
|
+
@property
|
|
260
|
+
def ruined(self) -> bool:
|
|
261
|
+
"""Whether any period's need went unmet in this window."""
|
|
262
|
+
return self.first_shortfall_period is not None
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
@dataclass(frozen=True, slots=True)
|
|
266
|
+
class BacktestResult:
|
|
267
|
+
"""Every window's outcome with the success metrics over them (§5.2).
|
|
268
|
+
|
|
269
|
+
Mirrors :class:`~glidepath.core.montecarlo.MonteCarloResult` so
|
|
270
|
+
the GUI can present the two side by side. ``config``,
|
|
271
|
+
``provenance``, and ``series`` are the §4.6 manifest side:
|
|
272
|
+
:func:`run_windows` accepts any :class:`HistoricalSeries`, so the
|
|
273
|
+
result must carry the series it actually replayed — two backtests
|
|
274
|
+
over different series are otherwise indistinguishable from their
|
|
275
|
+
manifests — and any window is re-runnable from this result plus
|
|
276
|
+
the plan.
|
|
277
|
+
"""
|
|
278
|
+
|
|
279
|
+
outcomes: tuple[WindowOutcome, ...]
|
|
280
|
+
config: RunConfig
|
|
281
|
+
provenance: RunProvenance
|
|
282
|
+
series: HistoricalSeries
|
|
283
|
+
|
|
284
|
+
def __post_init__(self) -> None:
|
|
285
|
+
"""Require at least one window."""
|
|
286
|
+
if not self.outcomes:
|
|
287
|
+
msg = "BacktestResult needs at least one window outcome"
|
|
288
|
+
raise ValueError(msg)
|
|
289
|
+
|
|
290
|
+
@property
|
|
291
|
+
def window_count(self) -> int:
|
|
292
|
+
"""How many rolling windows were projected."""
|
|
293
|
+
return len(self.outcomes)
|
|
294
|
+
|
|
295
|
+
@property
|
|
296
|
+
def failure_rate(self) -> Decimal:
|
|
297
|
+
"""The fraction of windows reporting a period with unmet need."""
|
|
298
|
+
ruined = sum(1 for outcome in self.outcomes if outcome.ruined)
|
|
299
|
+
return Decimal(ruined) / Decimal(self.window_count)
|
|
300
|
+
|
|
301
|
+
@property
|
|
302
|
+
def success_rate(self) -> Decimal:
|
|
303
|
+
"""The complement of :attr:`failure_rate`."""
|
|
304
|
+
return _ONE - self.failure_rate
|
|
305
|
+
|
|
306
|
+
@property
|
|
307
|
+
def worst_window(self) -> WindowOutcome:
|
|
308
|
+
"""The worst starting year's outcome.
|
|
309
|
+
|
|
310
|
+
A ruined window always ranks worse than a surviving one;
|
|
311
|
+
among ruined windows the one falling short earliest is worst
|
|
312
|
+
(every window projects the same periods, so shortfall dates
|
|
313
|
+
compare directly), then the lower ending balance; among
|
|
314
|
+
surviving windows, the lowest ending balance. Ties resolve to
|
|
315
|
+
the earliest starting year, so the answer is deterministic.
|
|
316
|
+
"""
|
|
317
|
+
|
|
318
|
+
def badness(outcome: WindowOutcome) -> tuple[int, date, Money, int]:
|
|
319
|
+
shortfall = outcome.first_shortfall_period
|
|
320
|
+
ruin_start = date.max if shortfall is None else shortfall.start
|
|
321
|
+
return (
|
|
322
|
+
0 if outcome.ruined else 1,
|
|
323
|
+
ruin_start,
|
|
324
|
+
outcome.ending_balance,
|
|
325
|
+
outcome.start_year,
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
return min(self.outcomes, key=badness)
|
|
329
|
+
|
|
330
|
+
@property
|
|
331
|
+
def best_window(self) -> WindowOutcome:
|
|
332
|
+
"""The best starting year's outcome — :attr:`worst_window` mirrored.
|
|
333
|
+
|
|
334
|
+
A surviving window always ranks better than a ruined one;
|
|
335
|
+
among surviving windows the higher ending balance is better;
|
|
336
|
+
among ruined windows, falling short later, then the higher
|
|
337
|
+
ending balance. Ties resolve to the earliest starting year, so
|
|
338
|
+
the answer is deterministic.
|
|
339
|
+
"""
|
|
340
|
+
|
|
341
|
+
def goodness(outcome: WindowOutcome) -> tuple[int, date, Money, int]:
|
|
342
|
+
shortfall = outcome.first_shortfall_period
|
|
343
|
+
ruin_start = date.max if shortfall is None else shortfall.start
|
|
344
|
+
return (
|
|
345
|
+
0 if outcome.ruined else 1,
|
|
346
|
+
ruin_start,
|
|
347
|
+
outcome.ending_balance,
|
|
348
|
+
-outcome.start_year,
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
return max(self.outcomes, key=goodness)
|
|
352
|
+
|
|
353
|
+
def ending_pot_percentile(self, percentile: Decimal) -> Money:
|
|
354
|
+
"""The ending-balance percentile over windows, in [0, 100].
|
|
355
|
+
|
|
356
|
+
The same order-statistic interpolation as the Monte Carlo
|
|
357
|
+
metrics (planning §5.2), so the two surfaces read alike.
|
|
358
|
+
|
|
359
|
+
Raises:
|
|
360
|
+
ValueError: If ``percentile`` lies outside [0, 100].
|
|
361
|
+
"""
|
|
362
|
+
return _interpolated_percentile(
|
|
363
|
+
[outcome.ending_balance for outcome in self.outcomes], percentile
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
def balance_percentile(self, percentile: Decimal) -> tuple[Money, ...]:
|
|
367
|
+
"""The per-period closing-balance percentile over windows.
|
|
368
|
+
|
|
369
|
+
One entry per projected period, in period order — the same
|
|
370
|
+
order-statistic interpolation as the Monte Carlo bands; CPI is
|
|
371
|
+
the run's one assumed path, so deflating each entry by the
|
|
372
|
+
period's price level presents the band in today's money
|
|
373
|
+
(module docstring). :func:`run_windows` outcomes always cover
|
|
374
|
+
the same periods, but hand-built ones may not, so the
|
|
375
|
+
alignment is validated like the Monte Carlo counterpart's.
|
|
376
|
+
|
|
377
|
+
Raises:
|
|
378
|
+
ValueError: If ``percentile`` lies outside [0, 100], or
|
|
379
|
+
the outcomes carry differing period counts.
|
|
380
|
+
"""
|
|
381
|
+
_check_percentile(percentile)
|
|
382
|
+
lengths = {len(outcome.closing_balances) for outcome in self.outcomes}
|
|
383
|
+
if len(lengths) != 1:
|
|
384
|
+
msg = "balance_percentile needs every window to cover the same periods"
|
|
385
|
+
raise ValueError(msg)
|
|
386
|
+
return tuple(
|
|
387
|
+
_interpolated_percentile(
|
|
388
|
+
[outcome.closing_balances[index] for outcome in self.outcomes],
|
|
389
|
+
percentile,
|
|
390
|
+
)
|
|
391
|
+
for index in range(lengths.pop())
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def run_windows(
|
|
396
|
+
plan: Household,
|
|
397
|
+
assumptions: AssumptionSet,
|
|
398
|
+
region: Region,
|
|
399
|
+
config: RunConfig,
|
|
400
|
+
*,
|
|
401
|
+
series: HistoricalSeries,
|
|
402
|
+
parallelism: PathParallelism | None = None,
|
|
403
|
+
) -> BacktestResult:
|
|
404
|
+
"""Project ``plan`` over every rolling window of ``series`` (§5.2).
|
|
405
|
+
|
|
406
|
+
Window *w* is ``run(plan, assumptions, region, config)`` with the
|
|
407
|
+
return model :func:`historical_window_factory` builds for *w* —
|
|
408
|
+
a deterministic projection whose per-period returns come from the
|
|
409
|
+
series at offset *w*, mapped through the plan's own glide-path
|
|
410
|
+
allocation by the one step function. The window count is however
|
|
411
|
+
many complete horizons the series holds: a plan projecting *N*
|
|
412
|
+
periods over an *M*-year series runs ``M - N + 1`` windows, the
|
|
413
|
+
first starting at the series' first year. Results are
|
|
414
|
+
reproducible: no randomness is involved, and the provenance is
|
|
415
|
+
the union across windows in first-read order.
|
|
416
|
+
|
|
417
|
+
With ``parallelism`` the remaining windows run as contiguous
|
|
418
|
+
chunks on its executor after window 0 sizes the horizon; windows
|
|
419
|
+
are pure functions of their offset, and chunk results recombine
|
|
420
|
+
in window order, so the result is identical to the serial run.
|
|
421
|
+
|
|
422
|
+
Raises:
|
|
423
|
+
EngineError: If ``config.mode`` is not ``DETERMINISTIC`` (a
|
|
424
|
+
backtest replays history; there is nothing to seed), the
|
|
425
|
+
series is shorter than the plan's horizon, or any
|
|
426
|
+
window's projection is rejected by the engine.
|
|
427
|
+
"""
|
|
428
|
+
if config.mode is not RunMode.DETERMINISTIC:
|
|
429
|
+
msg = "run_windows requires RunMode.DETERMINISTIC (planning §5.2)"
|
|
430
|
+
raise EngineError(msg)
|
|
431
|
+
anchor_year = region.calendar.period_containing(config.today).start.year
|
|
432
|
+
first_outcomes, first_provenance = _run_window_range(
|
|
433
|
+
plan, assumptions, region, config, series, anchor_year=anchor_year, chunk=(0, 1)
|
|
434
|
+
)
|
|
435
|
+
period_count = len(first_outcomes[0].closing_balances)
|
|
436
|
+
window_count = series.length - period_count + 1
|
|
437
|
+
remaining = window_count - 1
|
|
438
|
+
if remaining == 0:
|
|
439
|
+
outcomes = first_outcomes
|
|
440
|
+
provenance = first_provenance
|
|
441
|
+
elif parallelism is None or parallelism.workers == 1 or remaining == 1:
|
|
442
|
+
rest, rest_provenance = _run_window_range(
|
|
443
|
+
plan,
|
|
444
|
+
assumptions,
|
|
445
|
+
region,
|
|
446
|
+
config,
|
|
447
|
+
series,
|
|
448
|
+
anchor_year=anchor_year,
|
|
449
|
+
chunk=(1, window_count),
|
|
450
|
+
)
|
|
451
|
+
outcomes = first_outcomes + rest
|
|
452
|
+
provenance = _merged_provenance([first_provenance, rest_provenance])
|
|
453
|
+
else:
|
|
454
|
+
futures = [
|
|
455
|
+
parallelism.executor.submit(
|
|
456
|
+
_run_window_range,
|
|
457
|
+
plan,
|
|
458
|
+
assumptions,
|
|
459
|
+
region,
|
|
460
|
+
config,
|
|
461
|
+
series,
|
|
462
|
+
anchor_year=anchor_year,
|
|
463
|
+
chunk=(start + 1, stop + 1),
|
|
464
|
+
)
|
|
465
|
+
for start, stop in _path_chunks(remaining, parallelism.workers)
|
|
466
|
+
]
|
|
467
|
+
chunk_results = [future.result() for future in futures]
|
|
468
|
+
outcomes = first_outcomes + tuple(
|
|
469
|
+
outcome for chunk_outcomes, _ in chunk_results for outcome in chunk_outcomes
|
|
470
|
+
)
|
|
471
|
+
provenance = _merged_provenance(
|
|
472
|
+
[first_provenance]
|
|
473
|
+
+ [chunk_provenance for _, chunk_provenance in chunk_results]
|
|
474
|
+
)
|
|
475
|
+
return BacktestResult(
|
|
476
|
+
outcomes=outcomes, config=config, provenance=provenance, series=series
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _run_window_range(
|
|
481
|
+
plan: Household,
|
|
482
|
+
assumptions: AssumptionSet,
|
|
483
|
+
region: Region,
|
|
484
|
+
config: RunConfig,
|
|
485
|
+
series: HistoricalSeries,
|
|
486
|
+
*,
|
|
487
|
+
anchor_year: int,
|
|
488
|
+
chunk: tuple[int, int],
|
|
489
|
+
) -> tuple[tuple[WindowOutcome, ...], RunProvenance]:
|
|
490
|
+
"""Project the ``(start, stop)`` half-open ``chunk`` of window offsets.
|
|
491
|
+
|
|
492
|
+
The unit of work one executor worker runs — a module-level
|
|
493
|
+
function over picklable arguments returning only reduced outcomes
|
|
494
|
+
and the chunk's provenance union, the exact shape of the Monte
|
|
495
|
+
Carlo runner's chunk worker (planning §5.2).
|
|
496
|
+
"""
|
|
497
|
+
start, stop = chunk
|
|
498
|
+
outcomes: list[WindowOutcome] = []
|
|
499
|
+
provenances: list[RunProvenance] = []
|
|
500
|
+
for window in range(start, stop):
|
|
501
|
+
factory = historical_window_factory(series, window, anchor_year)
|
|
502
|
+
result = run(plan, assumptions, region, config, return_model_factory=factory)
|
|
503
|
+
reduced = _path_outcome(window, result)
|
|
504
|
+
outcomes.append(
|
|
505
|
+
WindowOutcome(
|
|
506
|
+
window=window,
|
|
507
|
+
start_year=series.first_year + window,
|
|
508
|
+
first_shortfall_period=reduced.first_shortfall_period,
|
|
509
|
+
ending_balance=reduced.ending_balance,
|
|
510
|
+
closing_balances=reduced.closing_balances,
|
|
511
|
+
)
|
|
512
|
+
)
|
|
513
|
+
provenances.append(result.provenance)
|
|
514
|
+
return tuple(outcomes), _merged_provenance(provenances)
|