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,399 @@
1
+ """Monte Carlo in the shell: run mode, transition, panel (roadmap 9.13).
2
+
3
+ The Phase 7 core (:func:`~glidepath.core.run_paths`) surfaced per
4
+ planning §4.7: a run-mode control (deterministic | Monte Carlo with
5
+ paths + seed), the success-metrics readout, and the fan-chart specs
6
+ the Monte Carlo chart tab draws (the fan itself is composed in
7
+ :mod:`glidepath.app.charts`, roadmap 9.24). The Monte Carlo run is an
8
+ explicit user action — even at the recorded 9.15 per-path cost
9
+ (planning §4.6) it is
10
+ far too slow to re-run on every keystroke — and every plan-changing
11
+ transition routes through :func:`~glidepath.app.plan.replanned_state`, which
12
+ drops the held result, so a stale Monte Carlo surface can never be
13
+ shown against a changed plan. Same seed + inputs reproduce identical
14
+ results (§4.6): the transition passes the parsed seed straight to the
15
+ seeded path runner.
16
+ """
17
+
18
+ import os
19
+ from concurrent.futures import ProcessPoolExecutor
20
+ from contextlib import contextmanager
21
+ from dataclasses import dataclass, replace
22
+ from decimal import Decimal
23
+ from typing import TYPE_CHECKING, Any, Final
24
+
25
+ from glidepath.app.display import format_money, format_percent
26
+ from glidepath.app.plan import PlanState, region_for, replanned_state
27
+ from glidepath.core import Money, PathParallelism, RunConfig, RunMode, run_paths
28
+
29
+ if TYPE_CHECKING:
30
+ from collections.abc import Iterator, Mapping
31
+ from datetime import date
32
+
33
+ from glidepath.core import MonteCarloResult
34
+
35
+ DEFAULT_RUN_MODE: Final = RunMode.DETERMINISTIC
36
+
37
+ RUN_MODE_HEADING: Final = "Run mode"
38
+
39
+ SEED_LABEL: Final = "Seed"
40
+
41
+ PATHS_LABEL: Final = "Paths"
42
+
43
+ RUN_MONTE_CARLO_LABEL: Final = "Run Monte Carlo"
44
+
45
+ DEFAULT_SEED_VALUE: Final = "1"
46
+
47
+ DEFAULT_PATHS_VALUE: Final = "100"
48
+
49
+ MAX_PATHS: Final = 10_000
50
+
51
+ PARALLEL_PATHS_MIN: Final = 100
52
+ """Fewest path-projections a run must total before a pool pays off.
53
+
54
+ Spawning worker processes costs around a second (each re-imports the
55
+ package); below this many paths the serial run finishes comparably
56
+ fast, so small runs — and the app-layer test suite — stay in-process.
57
+ """
58
+
59
+ MAX_POOL_WORKERS: Final = 61
60
+ """Most workers a pool may hold — Windows' wait-handle ceiling.
61
+
62
+ ``ProcessPoolExecutor`` rejects more than 61 workers on Windows (the
63
+ 64-object ``WaitForMultipleObjects`` limit minus bookkeeping handles);
64
+ without the cap, every qualifying run on a 63+-logical-CPU Windows
65
+ machine would fail at pool construction. Capped on every platform so
66
+ the policy stays uniform.
67
+ """
68
+
69
+ NO_MONTE_CARLO_MESSAGE: Final = (
70
+ "No Monte Carlo run yet — choose the paths and seed, then press Run Monte Carlo."
71
+ )
72
+
73
+ MONTE_CARLO_NO_PLAN_MESSAGE: Final = (
74
+ "Save facts on the Facts tab before running Monte Carlo."
75
+ )
76
+
77
+ MONTE_CARLO_SEED_MESSAGE: Final = "Monte Carlo needs a whole-number seed."
78
+
79
+ MONTE_CARLO_PATHS_MESSAGE: Final = (
80
+ "Monte Carlo needs a whole-number path count between 1 and 10,000."
81
+ )
82
+
83
+ MONTE_CARLO_FAILED_PREFIX: Final = "The Monte Carlo run failed: "
84
+
85
+ MONTE_CARLO_RUNNING_MESSAGE: Final = "Running Monte Carlo…"
86
+
87
+
88
+ def monte_carlo_running_status(paths_text: str) -> str:
89
+ """The busy status line for a Monte Carlo run just started (9.16).
90
+
91
+ Names the path count when the raw text the shell captured parses
92
+ to a usable one — "Running Monte Carlo — 1,000 paths…" — and falls
93
+ back to the plain running message otherwise; the transition itself
94
+ reports unusable input when it delivers (planning §4.7: the shell
95
+ never parses).
96
+ """
97
+ try:
98
+ paths = int(paths_text.strip(), 10)
99
+ except ValueError:
100
+ return MONTE_CARLO_RUNNING_MESSAGE
101
+ if not 1 <= paths <= MAX_PATHS:
102
+ return MONTE_CARLO_RUNNING_MESSAGE
103
+ return f"Running Monte Carlo — {paths:,} paths…"
104
+
105
+
106
+ MONTE_CARLO_STALE_MESSAGE: Final = (
107
+ "Monte Carlo result discarded — the plan changed while it ran."
108
+ )
109
+
110
+ SUCCESS_RATE_LABEL: Final = "Success rate"
111
+
112
+ RUIN_LABEL: Final = "Probability of ruin"
113
+
114
+ _MODE_BY_KEY: Final[Mapping[str, RunMode]] = {
115
+ "deterministic": RunMode.DETERMINISTIC,
116
+ "monte_carlo": RunMode.MONTE_CARLO,
117
+ }
118
+
119
+ _KEY_BY_MODE: Final[Mapping[RunMode, str]] = {
120
+ mode: key for key, mode in _MODE_BY_KEY.items()
121
+ }
122
+
123
+ _MODE_LABELS: Final[Mapping[str, str]] = {
124
+ "deterministic": "Deterministic",
125
+ "monte_carlo": "Monte Carlo",
126
+ }
127
+
128
+ _UNKNOWN_MODE_MESSAGE: Final = "unknown run mode key"
129
+
130
+
131
+ @dataclass(frozen=True)
132
+ class BandSpec:
133
+ """One ending-pot percentile the Monte Carlo readout reports (§5.2)."""
134
+
135
+ ending_pot_label: str
136
+ percentile: Decimal
137
+
138
+
139
+ BAND_SPECS: Final[tuple[BandSpec, ...]] = (
140
+ BandSpec(
141
+ ending_pot_label="Ending pot, 10th percentile",
142
+ percentile=Decimal(10),
143
+ ),
144
+ BandSpec(
145
+ ending_pot_label="Ending pot, median",
146
+ percentile=Decimal(50),
147
+ ),
148
+ BandSpec(
149
+ ending_pot_label="Ending pot, 90th percentile",
150
+ percentile=Decimal(90),
151
+ ),
152
+ )
153
+ """The 10/50/90 ending-pot metrics of roadmap 9.13."""
154
+
155
+
156
+ @dataclass(frozen=True)
157
+ class FanSpec:
158
+ """One inter-percentile fill of the Monte Carlo fan chart (9.24).
159
+
160
+ ``label`` is the legend and tooltip copy; ``lower``/``upper`` are
161
+ the percentiles bounding the fill, so the fill is a genuine
162
+ interval statement — the central share of paths that closed
163
+ between them every period.
164
+ """
165
+
166
+ label: str
167
+ lower: Decimal
168
+ upper: Decimal
169
+
170
+
171
+ FAN_SPECS: Final[tuple[FanSpec, ...]] = (
172
+ FanSpec(label="5th-95th percentile", lower=Decimal(5), upper=Decimal(95)),
173
+ FanSpec(label="15th-85th percentile", lower=Decimal(15), upper=Decimal(85)),
174
+ FanSpec(label="25th-75th percentile", lower=Decimal(25), upper=Decimal(75)),
175
+ FanSpec(label="35th-65th percentile", lower=Decimal(35), upper=Decimal(65)),
176
+ )
177
+ """The fan's nested fills, outermost first — shells step the fill
178
+ colour deeper inward, so central probability mass reads as depth."""
179
+
180
+
181
+ FAN_MEDIAN_LABEL: Final = "Median"
182
+ """The fan chart's single overlay line — the per-period median."""
183
+
184
+
185
+ @dataclass(frozen=True)
186
+ class RunModeOption:
187
+ """One run-mode choice for the charts screen (roadmap 9.13)."""
188
+
189
+ key: str
190
+ label: str
191
+
192
+
193
+ @dataclass(frozen=True)
194
+ class MonteCarloMetric:
195
+ """One success-metric readout row: a label and its formatted value."""
196
+
197
+ label: str
198
+ value: str
199
+
200
+
201
+ @dataclass(frozen=True)
202
+ class MonteCarloPanelViewModel:
203
+ """The run-mode control and Monte Carlo readout (roadmap 9.13).
204
+
205
+ ``seed_value`` and ``paths_value`` echo the held result's actual
206
+ inputs (the §4.6 manifest side) or the defaults before any run.
207
+ ``controls_visible`` hides the seed, paths, run action, metrics,
208
+ and message under the deterministic mode. ``message`` carries the
209
+ no-run or failure copy; it is blank whenever ``metrics`` is
210
+ populated.
211
+ """
212
+
213
+ heading: str
214
+ mode_options: tuple[RunModeOption, ...]
215
+ selected_mode_key: str
216
+ seed_label: str
217
+ seed_value: str
218
+ paths_label: str
219
+ paths_value: str
220
+ run_label: str
221
+ controls_visible: bool
222
+ metrics: tuple[MonteCarloMetric, ...]
223
+ message: str
224
+
225
+
226
+ @contextmanager
227
+ def path_pool(total_paths: int) -> Iterator[PathParallelism | None]:
228
+ """A worker-process pool for ``total_paths`` path-projections, or ``None``.
229
+
230
+ The app layer's parallelism policy (planning §5.2): paths are pure
231
+ CPU-bound ``Decimal`` work, so a run big enough to amortize process
232
+ startup (``PARALLEL_PATHS_MIN``) gets a process pool sized to the
233
+ machine — every available core but one, so the GUI thread keeps a
234
+ core, capped at ``MAX_POOL_WORKERS`` — and anything smaller runs
235
+ serially (``None``). One pool
236
+ serves a whole transition: a retirement-age search passes it to
237
+ every candidate's paths rather than re-spawning per age.
238
+ """
239
+ spare_cores = max(1, (os.process_cpu_count() or 1) - 1)
240
+ workers = min(spare_cores, total_paths, MAX_POOL_WORKERS)
241
+ if workers <= 1 or total_paths < PARALLEL_PATHS_MIN:
242
+ yield None
243
+ return
244
+ with ProcessPoolExecutor(max_workers=workers) as executor:
245
+ yield PathParallelism(executor=executor, workers=workers)
246
+
247
+
248
+ def run_mode_from_key(key: str) -> RunMode:
249
+ """The run mode a shell-selected option key denotes.
250
+
251
+ Raises:
252
+ ValueError: If ``key`` is not a known run-mode option key.
253
+ """
254
+ mode = _MODE_BY_KEY.get(key)
255
+ if mode is None:
256
+ raise ValueError(_UNKNOWN_MODE_MESSAGE)
257
+ return mode
258
+
259
+
260
+ def run_mode_key(mode: RunMode) -> str:
261
+ """The option key denoting ``mode`` — the inverse of ``run_mode_from_key``."""
262
+ return _KEY_BY_MODE[mode]
263
+
264
+
265
+ def run_mode_options() -> tuple[RunModeOption, ...]:
266
+ """The run-mode choices the charts screen offers (roadmap 9.13)."""
267
+ return tuple(
268
+ RunModeOption(key=key, label=_MODE_LABELS[key]) for key in _MODE_BY_KEY
269
+ )
270
+
271
+
272
+ def state_with_monte_carlo(
273
+ state: PlanState, seed_text: str, paths_text: str, *, today: date
274
+ ) -> PlanState:
275
+ """The state after running the plan over seeded Monte Carlo paths.
276
+
277
+ ``seed_text`` and ``paths_text`` arrive as the shell captured
278
+ them; anything unusable — no plan, an unparseable seed or path
279
+ count, an engine rejection — folds into ``monte_carlo_error`` on
280
+ the returned state, never raising at a shell (the
281
+ :mod:`glidepath.app.plan` rule). Before the paths run, the base
282
+ projection (and the scenario runs with it) is recomputed at the
283
+ same ``today`` through :func:`~glidepath.app.plan.replanned_state`
284
+ — the :func:`~glidepath.app.plan.state_with_scenarios` rule: in a
285
+ session left open across a date boundary, the bands and the
286
+ deterministic chart they overlay must share one anchor date. Same
287
+ seed + inputs reproduce identical results (§4.6).
288
+ """
289
+ if state.household is None:
290
+ return _with_monte_carlo_error(state, MONTE_CARLO_NO_PLAN_MESSAGE)
291
+ try:
292
+ seed = int(seed_text.strip(), 10)
293
+ except ValueError:
294
+ return _with_monte_carlo_error(state, MONTE_CARLO_SEED_MESSAGE)
295
+ try:
296
+ paths = int(paths_text.strip(), 10)
297
+ except ValueError:
298
+ return _with_monte_carlo_error(state, MONTE_CARLO_PATHS_MESSAGE)
299
+ if not 1 <= paths <= MAX_PATHS:
300
+ return _with_monte_carlo_error(state, MONTE_CARLO_PATHS_MESSAGE)
301
+ base = replanned_state(
302
+ state.assumptions,
303
+ state.household,
304
+ state.scenarios,
305
+ today=today,
306
+ modified=state.modified,
307
+ )
308
+ config = RunConfig(today=today, mode=RunMode.MONTE_CARLO, seed=seed)
309
+ try:
310
+ with path_pool(paths) as parallelism:
311
+ result = run_paths(
312
+ state.household,
313
+ state.assumptions,
314
+ region_for(state.assumptions),
315
+ config,
316
+ paths=paths,
317
+ parallelism=parallelism,
318
+ )
319
+ except Exception as exc:
320
+ # Broad by design: beyond the engine's ValueErrors, the process
321
+ # pool can raise OSError at spawn, pickling TypeErrors, or a
322
+ # BrokenExecutor — an escape here would leave the shell's
323
+ # in-flight guard held (buttons disabled, spinner running)
324
+ # forever, so every failure folds into the state (§4.7).
325
+ return _with_monte_carlo_error(base, MONTE_CARLO_FAILED_PREFIX + str(exc))
326
+ changes: dict[str, Any] = {"monte_carlo": result, "monte_carlo_error": None}
327
+ return replace(base, **changes) if changes else base
328
+
329
+
330
+ def build_monte_carlo_panel(
331
+ state: PlanState,
332
+ mode: RunMode,
333
+ *,
334
+ ending_pot_deflator: Decimal | None,
335
+ basis_suffix: str,
336
+ ) -> MonteCarloPanelViewModel:
337
+ """The run-mode control and readout for the charts screen (9.13).
338
+
339
+ ``ending_pot_deflator`` is the final period's balance deflator in
340
+ the screen's basis (1 under nominal) — CPI is deterministic across
341
+ paths (planning §5.2), so the deterministic report's deflator
342
+ presents the Monte Carlo pots too; ``None`` (no projection to read
343
+ it from) leaves the metrics unbuilt. ``basis_suffix`` names the
344
+ basis on the ending-pot labels.
345
+ """
346
+ result = state.monte_carlo
347
+ metrics: tuple[MonteCarloMetric, ...] = ()
348
+ message = ""
349
+ controls_visible = mode is RunMode.MONTE_CARLO
350
+ if controls_visible:
351
+ if state.monte_carlo_error is not None:
352
+ message = state.monte_carlo_error
353
+ elif result is None or ending_pot_deflator is None:
354
+ message = NO_MONTE_CARLO_MESSAGE
355
+ else:
356
+ metrics = _metrics(result, ending_pot_deflator, basis_suffix)
357
+ return MonteCarloPanelViewModel(
358
+ heading=RUN_MODE_HEADING,
359
+ mode_options=run_mode_options(),
360
+ selected_mode_key=run_mode_key(mode),
361
+ seed_label=SEED_LABEL,
362
+ seed_value=DEFAULT_SEED_VALUE if result is None else str(result.config.seed),
363
+ paths_label=PATHS_LABEL,
364
+ paths_value=DEFAULT_PATHS_VALUE if result is None else str(result.path_count),
365
+ run_label=RUN_MONTE_CARLO_LABEL,
366
+ controls_visible=controls_visible,
367
+ metrics=metrics,
368
+ message=message,
369
+ )
370
+
371
+
372
+ def _metrics(
373
+ result: MonteCarloResult, ending_pot_deflator: Decimal, basis_suffix: str
374
+ ) -> tuple[MonteCarloMetric, ...]:
375
+ """The success-metrics readout rows (planning §5.2)."""
376
+ rows = [
377
+ MonteCarloMetric(
378
+ label=SUCCESS_RATE_LABEL, value=format_percent(result.success_rate)
379
+ ),
380
+ MonteCarloMetric(
381
+ label=RUIN_LABEL, value=format_percent(result.probability_of_ruin)
382
+ ),
383
+ ]
384
+ for spec in BAND_SPECS:
385
+ pot = result.ending_pot_percentile(spec.percentile)
386
+ presented = Money(pot.amount / ending_pot_deflator).quantized()
387
+ rows.append(
388
+ MonteCarloMetric(
389
+ label=f"{spec.ending_pot_label} ({basis_suffix})",
390
+ value=format_money(presented),
391
+ )
392
+ )
393
+ return tuple(rows)
394
+
395
+
396
+ def _with_monte_carlo_error(state: PlanState, message: str) -> PlanState:
397
+ """The state carrying a Monte Carlo failure, any held result dropped."""
398
+ changes: dict[str, Any] = {"monte_carlo": None, "monte_carlo_error": message}
399
+ return replace(state, **changes) if changes else state