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.
Files changed (93) hide show
  1. glidepath/__init__.py +3 -0
  2. glidepath/app/__init__.py +364 -0
  3. glidepath/app/backtest.py +281 -0
  4. glidepath/app/charts.py +759 -0
  5. glidepath/app/copy.py +174 -0
  6. glidepath/app/display.py +148 -0
  7. glidepath/app/drawdown.py +436 -0
  8. glidepath/app/example.py +66 -0
  9. glidepath/app/exports.py +487 -0
  10. glidepath/app/files.py +249 -0
  11. glidepath/app/firstrun.py +114 -0
  12. glidepath/app/forms.py +1750 -0
  13. glidepath/app/inspector.py +506 -0
  14. glidepath/app/labels.py +66 -0
  15. glidepath/app/montecarlo.py +399 -0
  16. glidepath/app/plan.py +354 -0
  17. glidepath/app/retirement.py +446 -0
  18. glidepath/app/scenarios.py +831 -0
  19. glidepath/app/shell.py +185 -0
  20. glidepath/app/tables.py +138 -0
  21. glidepath/core/__init__.py +390 -0
  22. glidepath/core/annuities.py +240 -0
  23. glidepath/core/backtest.py +514 -0
  24. glidepath/core/comparison.py +278 -0
  25. glidepath/core/config.py +82 -0
  26. glidepath/core/contributions.py +337 -0
  27. glidepath/core/engine.py +2811 -0
  28. glidepath/core/entities.py +264 -0
  29. glidepath/core/glide.py +289 -0
  30. glidepath/core/investments.py +175 -0
  31. glidepath/core/money.py +107 -0
  32. glidepath/core/montecarlo.py +609 -0
  33. glidepath/core/pensions.py +298 -0
  34. glidepath/core/periods.py +367 -0
  35. glidepath/core/provenance.py +271 -0
  36. glidepath/core/randomness.py +128 -0
  37. glidepath/core/region.py +46 -0
  38. glidepath/core/reporting.py +231 -0
  39. glidepath/core/results.py +504 -0
  40. glidepath/core/retirement.py +291 -0
  41. glidepath/core/returns.py +312 -0
  42. glidepath/core/scenarios.py +579 -0
  43. glidepath/core/state_pension.py +264 -0
  44. glidepath/core/tax.py +139 -0
  45. glidepath/core/withdrawals.py +461 -0
  46. glidepath/core/wrappers.py +278 -0
  47. glidepath/gui/__init__.py +6 -0
  48. glidepath/gui/assets/icon_128.png +0 -0
  49. glidepath/gui/assets/icon_16.png +0 -0
  50. glidepath/gui/assets/icon_24.png +0 -0
  51. glidepath/gui/assets/icon_256.png +0 -0
  52. glidepath/gui/assets/icon_32.png +0 -0
  53. glidepath/gui/assets/icon_48.png +0 -0
  54. glidepath/gui/assets/icon_64.png +0 -0
  55. glidepath/gui/assets/wordmark.png +0 -0
  56. glidepath/gui/charts.py +829 -0
  57. glidepath/gui/forms.py +359 -0
  58. glidepath/gui/inspector.py +186 -0
  59. glidepath/gui/main.py +51 -0
  60. glidepath/gui/scenarios.py +402 -0
  61. glidepath/gui/style.py +376 -0
  62. glidepath/gui/tableview.py +67 -0
  63. glidepath/gui/widgets.py +989 -0
  64. glidepath/persistence/__init__.py +48 -0
  65. glidepath/persistence/assumptions.py +112 -0
  66. glidepath/persistence/decode.py +747 -0
  67. glidepath/persistence/document.py +101 -0
  68. glidepath/persistence/encode.py +433 -0
  69. glidepath/persistence/migrations.py +158 -0
  70. glidepath/persistence/values.py +298 -0
  71. glidepath/py.typed +0 -0
  72. glidepath/regions/__init__.py +7 -0
  73. glidepath/regions/uk/__init__.py +189 -0
  74. glidepath/regions/uk/ages.py +156 -0
  75. glidepath/regions/uk/contributions.py +717 -0
  76. glidepath/regions/uk/data/age_rules.toml +78 -0
  77. glidepath/regions/uk/data/assumptions_default.toml +170 -0
  78. glidepath/regions/uk/data/returns_history.toml +150 -0
  79. glidepath/regions/uk/data/tax_year_2026_27.toml +98 -0
  80. glidepath/regions/uk/extension.py +479 -0
  81. glidepath/regions/uk/loader.py +704 -0
  82. glidepath/regions/uk/region.py +160 -0
  83. glidepath/regions/uk/schema.py +563 -0
  84. glidepath/regions/uk/state_pension.py +129 -0
  85. glidepath/regions/uk/tax.py +466 -0
  86. glidepath/regions/uk/wrappers.py +283 -0
  87. glidepath/regions/uk/years.py +92 -0
  88. glidepath-0.2.0.dist-info/METADATA +189 -0
  89. glidepath-0.2.0.dist-info/RECORD +93 -0
  90. glidepath-0.2.0.dist-info/WHEEL +4 -0
  91. glidepath-0.2.0.dist-info/entry_points.txt +3 -0
  92. glidepath-0.2.0.dist-info/licenses/LICENSE +21 -0
  93. 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)