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
glidepath/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """glidepath: a desktop retirement and investment planner (UK-first)."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,364 @@
1
+ """UI-agnostic application layer (roadmap 8.1 to 8.3; planning §4.7).
2
+
3
+ View models, user-facing copy (including the §1 disclaimer), display
4
+ formatting, form parsing, session state, and first-run state for any
5
+ UI shell. Everything here is plain typed Python over the scenario
6
+ layer and engine — no Qt imports (guard test), no assumption of a
7
+ desktop — so a future web shell can reuse it unchanged.
8
+ """
9
+
10
+ from glidepath.app.backtest import (
11
+ BACKTEST_NO_PLAN_MESSAGE,
12
+ BACKTEST_RUNNING_MESSAGE,
13
+ BACKTEST_STALE_MESSAGE,
14
+ NO_BACKTEST_MESSAGE,
15
+ BacktestMetric,
16
+ BacktestPanelViewModel,
17
+ build_backtest_panel,
18
+ state_with_backtest,
19
+ )
20
+ from glidepath.app.charts import (
21
+ CHART_VIEW_LABEL,
22
+ DEFAULT_CHART_BASIS,
23
+ MONTE_CARLO_CHART_TITLE,
24
+ NO_PROJECTION_MESSAGE,
25
+ RUN_FAILED_PREFIX,
26
+ TABLE_VIEW_LABEL,
27
+ ChartBand,
28
+ ChartBasisOption,
29
+ ChartFill,
30
+ ChartSeries,
31
+ ChartSpec,
32
+ ChartsViewModel,
33
+ ChartTable,
34
+ bar_tooltip,
35
+ basis_from_key,
36
+ build_charts_view_model,
37
+ chart_table,
38
+ fill_tooltip,
39
+ )
40
+ from glidepath.app.copy import (
41
+ ABOUT_TITLE,
42
+ APP_NAME,
43
+ DATE_PICKER_TOOLTIP,
44
+ DISCLAIMER_ACCEPT_LABEL,
45
+ DISCLAIMER_BODY,
46
+ DISCLAIMER_DECLINE_LABEL,
47
+ DISCLAIMER_TITLE,
48
+ HELP_GUIDE_INTRO,
49
+ HELP_GUIDE_SECTIONS,
50
+ HELP_GUIDE_TITLE,
51
+ HELP_MENU_LABEL,
52
+ )
53
+ from glidepath.app.display import (
54
+ format_date,
55
+ format_money,
56
+ format_percent,
57
+ format_recorded,
58
+ format_value,
59
+ )
60
+ from glidepath.app.drawdown import (
61
+ DRAWDOWN_AGE_MESSAGE,
62
+ DRAWDOWN_NO_PLAN_MESSAGE,
63
+ DRAWDOWN_RUNNING_MESSAGE,
64
+ DRAWDOWN_STALE_MESSAGE,
65
+ DRAWDOWN_SUCCESS_MESSAGE,
66
+ NO_DRAWDOWN_MESSAGE,
67
+ DrawdownAnswer,
68
+ DrawdownPanelViewModel,
69
+ DrawdownRequest,
70
+ build_drawdown_panel,
71
+ state_with_drawdown,
72
+ )
73
+ from glidepath.app.example import example_facts_form_data
74
+ from glidepath.app.exports import (
75
+ NOTHING_TO_EXPORT_MESSAGE,
76
+ REPORT_EXPORT_FAILED_PREFIX,
77
+ REPORT_NOT_WRITTEN_MESSAGE,
78
+ ExportOutcome,
79
+ PlanReport,
80
+ ReportRequest,
81
+ build_plan_report,
82
+ cash_flow_csv,
83
+ chart_resource_name,
84
+ export_cash_flow_csv,
85
+ plan_display_name,
86
+ report_exported_message,
87
+ )
88
+ from glidepath.app.files import (
89
+ NOTHING_TO_SAVE_MESSAGE,
90
+ UNSAVED_CHANGES_PROMPT,
91
+ UNSAVED_CHANGES_TITLE,
92
+ LoadOutcome,
93
+ SaveOutcome,
94
+ document_from_state,
95
+ has_unsaved_changes,
96
+ load_plan_state,
97
+ save_plan_state,
98
+ )
99
+ from glidepath.app.firstrun import (
100
+ FirstRunState,
101
+ default_state_path,
102
+ load_state,
103
+ record_disclaimer_acknowledged,
104
+ record_last_plan_path,
105
+ )
106
+ from glidepath.app.forms import (
107
+ ENTITY_ID_KEY,
108
+ ChoiceOption,
109
+ FactsFormData,
110
+ FactsFormResult,
111
+ FactsFormViewModel,
112
+ FieldKind,
113
+ FieldSpec,
114
+ FormError,
115
+ PlanEntityIds,
116
+ SectionSpec,
117
+ build_facts_form_view_model,
118
+ facts_form_data_from_household,
119
+ form_cannot_represent,
120
+ format_form_errors,
121
+ parse_facts_form,
122
+ plan_entity_ids,
123
+ )
124
+ from glidepath.app.inspector import (
125
+ AssumptionRow,
126
+ DecisionRow,
127
+ FactRow,
128
+ InspectorViewModel,
129
+ RollForwardRow,
130
+ StructureRow,
131
+ build_inspector_view_model,
132
+ )
133
+ from glidepath.app.montecarlo import (
134
+ DEFAULT_RUN_MODE,
135
+ MONTE_CARLO_NO_PLAN_MESSAGE,
136
+ MONTE_CARLO_PATHS_MESSAGE,
137
+ MONTE_CARLO_RUNNING_MESSAGE,
138
+ MONTE_CARLO_SEED_MESSAGE,
139
+ MONTE_CARLO_STALE_MESSAGE,
140
+ NO_MONTE_CARLO_MESSAGE,
141
+ MonteCarloMetric,
142
+ MonteCarloPanelViewModel,
143
+ RunModeOption,
144
+ monte_carlo_running_status,
145
+ path_pool,
146
+ run_mode_from_key,
147
+ run_mode_key,
148
+ state_with_monte_carlo,
149
+ )
150
+ from glidepath.app.plan import (
151
+ OVERRIDE_SOURCE,
152
+ OverrideOutcome,
153
+ PlanState,
154
+ facts_saved_message,
155
+ initial_plan_state,
156
+ state_marked_saved,
157
+ state_with_household,
158
+ state_with_override,
159
+ state_with_scenarios,
160
+ )
161
+ from glidepath.app.retirement import (
162
+ NO_RETIREMENT_MESSAGE,
163
+ RETIREMENT_NO_INCOME_MESSAGE,
164
+ RETIREMENT_NO_PLAN_MESSAGE,
165
+ RETIREMENT_RATE_MESSAGE,
166
+ RETIREMENT_RUNNING_MESSAGE,
167
+ RETIREMENT_STALE_MESSAGE,
168
+ RETIREMENT_SUCCESS_MESSAGE,
169
+ RetirementAnswer,
170
+ RetirementPanelViewModel,
171
+ RetirementRequest,
172
+ build_retirement_panel,
173
+ state_with_retirement,
174
+ )
175
+ from glidepath.app.scenarios import (
176
+ DEFAULT_COMPARISON_METRIC_KEY,
177
+ ComparisonMetricOption,
178
+ OverrideRow,
179
+ ScenarioItem,
180
+ ScenariosViewModel,
181
+ TargetOption,
182
+ build_scenarios_view_model,
183
+ metric_from_key,
184
+ scenario_target_options,
185
+ state_with_scenario_added,
186
+ state_with_scenario_override,
187
+ state_without_scenario,
188
+ state_without_scenario_override,
189
+ )
190
+ from glidepath.app.shell import (
191
+ AboutViewModel,
192
+ DisclaimerViewModel,
193
+ FileMenuViewModel,
194
+ HelpGuideViewModel,
195
+ HelpSectionViewModel,
196
+ ShellViewModel,
197
+ build_shell_view_model,
198
+ should_show_disclaimer,
199
+ )
200
+ from glidepath.core import ReportBasis
201
+
202
+ __all__ = [
203
+ "ABOUT_TITLE",
204
+ "APP_NAME",
205
+ "BACKTEST_NO_PLAN_MESSAGE",
206
+ "BACKTEST_RUNNING_MESSAGE",
207
+ "BACKTEST_STALE_MESSAGE",
208
+ "CHART_VIEW_LABEL",
209
+ "DATE_PICKER_TOOLTIP",
210
+ "DEFAULT_CHART_BASIS",
211
+ "DEFAULT_COMPARISON_METRIC_KEY",
212
+ "DEFAULT_RUN_MODE",
213
+ "DISCLAIMER_ACCEPT_LABEL",
214
+ "DISCLAIMER_BODY",
215
+ "DISCLAIMER_DECLINE_LABEL",
216
+ "DISCLAIMER_TITLE",
217
+ "DRAWDOWN_AGE_MESSAGE",
218
+ "DRAWDOWN_NO_PLAN_MESSAGE",
219
+ "DRAWDOWN_RUNNING_MESSAGE",
220
+ "DRAWDOWN_STALE_MESSAGE",
221
+ "DRAWDOWN_SUCCESS_MESSAGE",
222
+ "ENTITY_ID_KEY",
223
+ "HELP_GUIDE_INTRO",
224
+ "HELP_GUIDE_SECTIONS",
225
+ "HELP_GUIDE_TITLE",
226
+ "HELP_MENU_LABEL",
227
+ "MONTE_CARLO_CHART_TITLE",
228
+ "MONTE_CARLO_NO_PLAN_MESSAGE",
229
+ "MONTE_CARLO_PATHS_MESSAGE",
230
+ "MONTE_CARLO_RUNNING_MESSAGE",
231
+ "MONTE_CARLO_SEED_MESSAGE",
232
+ "MONTE_CARLO_STALE_MESSAGE",
233
+ "NOTHING_TO_EXPORT_MESSAGE",
234
+ "NOTHING_TO_SAVE_MESSAGE",
235
+ "NO_BACKTEST_MESSAGE",
236
+ "NO_DRAWDOWN_MESSAGE",
237
+ "NO_MONTE_CARLO_MESSAGE",
238
+ "NO_PROJECTION_MESSAGE",
239
+ "NO_RETIREMENT_MESSAGE",
240
+ "OVERRIDE_SOURCE",
241
+ "REPORT_EXPORT_FAILED_PREFIX",
242
+ "REPORT_NOT_WRITTEN_MESSAGE",
243
+ "RETIREMENT_NO_INCOME_MESSAGE",
244
+ "RETIREMENT_NO_PLAN_MESSAGE",
245
+ "RETIREMENT_RATE_MESSAGE",
246
+ "RETIREMENT_RUNNING_MESSAGE",
247
+ "RETIREMENT_STALE_MESSAGE",
248
+ "RETIREMENT_SUCCESS_MESSAGE",
249
+ "RUN_FAILED_PREFIX",
250
+ "TABLE_VIEW_LABEL",
251
+ "UNSAVED_CHANGES_PROMPT",
252
+ "UNSAVED_CHANGES_TITLE",
253
+ "AboutViewModel",
254
+ "AssumptionRow",
255
+ "BacktestMetric",
256
+ "BacktestPanelViewModel",
257
+ "ChartBand",
258
+ "ChartBasisOption",
259
+ "ChartFill",
260
+ "ChartSeries",
261
+ "ChartSpec",
262
+ "ChartTable",
263
+ "ChartsViewModel",
264
+ "ChoiceOption",
265
+ "ComparisonMetricOption",
266
+ "DecisionRow",
267
+ "DisclaimerViewModel",
268
+ "DrawdownAnswer",
269
+ "DrawdownPanelViewModel",
270
+ "DrawdownRequest",
271
+ "ExportOutcome",
272
+ "FactRow",
273
+ "FactsFormData",
274
+ "FactsFormResult",
275
+ "FactsFormViewModel",
276
+ "FieldKind",
277
+ "FieldSpec",
278
+ "FileMenuViewModel",
279
+ "FirstRunState",
280
+ "FormError",
281
+ "HelpGuideViewModel",
282
+ "HelpSectionViewModel",
283
+ "InspectorViewModel",
284
+ "LoadOutcome",
285
+ "MonteCarloMetric",
286
+ "MonteCarloPanelViewModel",
287
+ "OverrideOutcome",
288
+ "OverrideRow",
289
+ "PlanEntityIds",
290
+ "PlanReport",
291
+ "PlanState",
292
+ "ReportBasis",
293
+ "ReportRequest",
294
+ "RetirementAnswer",
295
+ "RetirementPanelViewModel",
296
+ "RetirementRequest",
297
+ "RollForwardRow",
298
+ "RunModeOption",
299
+ "SaveOutcome",
300
+ "ScenarioItem",
301
+ "ScenariosViewModel",
302
+ "SectionSpec",
303
+ "ShellViewModel",
304
+ "StructureRow",
305
+ "TargetOption",
306
+ "bar_tooltip",
307
+ "basis_from_key",
308
+ "build_backtest_panel",
309
+ "build_charts_view_model",
310
+ "build_drawdown_panel",
311
+ "build_facts_form_view_model",
312
+ "build_inspector_view_model",
313
+ "build_plan_report",
314
+ "build_retirement_panel",
315
+ "build_scenarios_view_model",
316
+ "build_shell_view_model",
317
+ "cash_flow_csv",
318
+ "chart_resource_name",
319
+ "chart_table",
320
+ "default_state_path",
321
+ "document_from_state",
322
+ "example_facts_form_data",
323
+ "export_cash_flow_csv",
324
+ "facts_form_data_from_household",
325
+ "facts_saved_message",
326
+ "fill_tooltip",
327
+ "form_cannot_represent",
328
+ "format_date",
329
+ "format_form_errors",
330
+ "format_money",
331
+ "format_percent",
332
+ "format_recorded",
333
+ "format_value",
334
+ "has_unsaved_changes",
335
+ "initial_plan_state",
336
+ "load_plan_state",
337
+ "load_state",
338
+ "metric_from_key",
339
+ "monte_carlo_running_status",
340
+ "parse_facts_form",
341
+ "path_pool",
342
+ "plan_display_name",
343
+ "plan_entity_ids",
344
+ "record_disclaimer_acknowledged",
345
+ "record_last_plan_path",
346
+ "report_exported_message",
347
+ "run_mode_from_key",
348
+ "run_mode_key",
349
+ "save_plan_state",
350
+ "scenario_target_options",
351
+ "should_show_disclaimer",
352
+ "state_marked_saved",
353
+ "state_with_backtest",
354
+ "state_with_drawdown",
355
+ "state_with_household",
356
+ "state_with_monte_carlo",
357
+ "state_with_override",
358
+ "state_with_retirement",
359
+ "state_with_scenario_added",
360
+ "state_with_scenario_override",
361
+ "state_with_scenarios",
362
+ "state_without_scenario",
363
+ "state_without_scenario_override",
364
+ ]
@@ -0,0 +1,281 @@
1
+ """Historical backtest in the shell: transition and panel (roadmap 9.18).
2
+
3
+ The core window runner (:func:`~glidepath.core.run_windows`) surfaced
4
+ per planning §4.7 as a charts-screen card next to the Monte Carlo
5
+ panel: an explicit Run action replays the plan over every rolling
6
+ window of the shipped historical return series
7
+ (``returns_history.toml``, the §5.3 provenance pattern), and the
8
+ readout mirrors the Monte Carlo metrics — success rate over windows,
9
+ the worst starting year, ending-pot percentiles — so the two surfaces
10
+ read side by side. Results are reproducible: no randomness is
11
+ involved, and every plan-changing transition routes through
12
+ :func:`~glidepath.app.plan.replanned_state`, which drops the held
13
+ result, so a stale backtest can never be shown against a changed
14
+ plan.
15
+ """
16
+
17
+ from dataclasses import dataclass, replace
18
+ from typing import TYPE_CHECKING, Any, Final
19
+
20
+ from glidepath.app.display import format_money, format_percent
21
+ from glidepath.app.montecarlo import BAND_SPECS, SUCCESS_RATE_LABEL
22
+ from glidepath.app.plan import PlanState, region_for, replanned_state
23
+ from glidepath.core import Money, RunConfig, run_windows
24
+ from glidepath.regions.uk import load_returns_history
25
+
26
+ if TYPE_CHECKING:
27
+ from datetime import date
28
+ from decimal import Decimal
29
+
30
+ from glidepath.core import BacktestResult, WindowOutcome
31
+
32
+ BACKTEST_HEADING: Final = "Historical backtest"
33
+
34
+ RUN_BACKTEST_LABEL: Final = "Run backtest"
35
+
36
+ NO_BACKTEST_MESSAGE: Final = (
37
+ "No backtest yet — press Run backtest to replay the plan over every"
38
+ " historical starting year."
39
+ )
40
+
41
+ BACKTEST_NO_PLAN_MESSAGE: Final = (
42
+ "Save facts on the Facts tab before running a backtest."
43
+ )
44
+
45
+ BACKTEST_FAILED_PREFIX: Final = "The backtest failed: "
46
+
47
+ BACKTEST_RUNNING_MESSAGE: Final = "Running historical backtest…"
48
+
49
+ BACKTEST_STALE_MESSAGE: Final = (
50
+ "Backtest result discarded — the plan changed while it ran."
51
+ )
52
+
53
+ WINDOWS_LABEL: Final = "Windows"
54
+
55
+ WORST_WINDOW_LABEL: Final = "Worst starting year"
56
+
57
+ BEST_WINDOW_LABEL: Final = "Best starting year"
58
+
59
+ BACKTEST_YEAR_LABEL: Final = "Show starting year"
60
+
61
+ BACKTEST_YEAR_TOOLTIP_NO_RUN: Final = (
62
+ "Run the backtest first, then enter a historical starting year to"
63
+ " draw its balance path on the balances chart."
64
+ )
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class BacktestMetric:
69
+ """One backtest readout row: a label and its formatted value."""
70
+
71
+ label: str
72
+ value: str
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class BacktestPanelViewModel:
77
+ """The historical-backtest card on the charts screen (roadmap 9.18).
78
+
79
+ ``message`` carries the no-run or failure copy; it is blank
80
+ whenever ``metrics`` is populated. The card's one input is the
81
+ starting-year picker: ``year_value`` echoes the raw text the shell
82
+ captured (presentation state, not part of any run),
83
+ ``year_placeholder`` shows the valid span greyed inside the empty
84
+ box (what input is expected), ``year_tooltip`` explains what an
85
+ entry will draw (what the control is for), and ``year_message``
86
+ says why a non-blank entry drew no trajectory — it never displaces
87
+ the metrics.
88
+ """
89
+
90
+ heading: str
91
+ run_label: str
92
+ year_label: str
93
+ year_value: str
94
+ year_placeholder: str
95
+ year_tooltip: str
96
+ year_message: str
97
+ metrics: tuple[BacktestMetric, ...]
98
+ message: str
99
+
100
+
101
+ def state_with_backtest(state: PlanState, *, today: date) -> PlanState:
102
+ """The state after replaying the plan over every historical window.
103
+
104
+ Anything unusable — no plan, a horizon the series cannot cover, an
105
+ engine rejection — folds into ``backtest_error`` on the returned
106
+ state, never raising at a shell (the :mod:`glidepath.app.plan`
107
+ rule). Before the windows run, the base projection (and the
108
+ scenario runs with it) is recomputed at the same ``today`` through
109
+ :func:`~glidepath.app.plan.replanned_state` — the 9.13 rule: the
110
+ backtest bands and the deterministic chart they overlay must share
111
+ one anchor date. The run is serial by design: a full backtest is
112
+ around a second of work (each window is one deterministic engine
113
+ pass), well under the process-pool threshold the Monte Carlo
114
+ transition uses.
115
+ """
116
+ if state.household is None:
117
+ return _with_backtest_error(state, BACKTEST_NO_PLAN_MESSAGE)
118
+ base = replanned_state(
119
+ state.assumptions,
120
+ state.household,
121
+ state.scenarios,
122
+ today=today,
123
+ modified=state.modified,
124
+ )
125
+ config = RunConfig(today=today)
126
+ try:
127
+ series = load_returns_history().series
128
+ result = run_windows(
129
+ state.household,
130
+ state.assumptions,
131
+ region_for(state.assumptions),
132
+ config,
133
+ series=series,
134
+ )
135
+ except Exception as exc:
136
+ # Broad by design, mirroring the Monte Carlo transition: an
137
+ # escape past the shell's worker thread would leave the
138
+ # in-flight guard held (buttons disabled, spinner running)
139
+ # forever, so every failure folds into the state (§4.7).
140
+ return _with_backtest_error(base, BACKTEST_FAILED_PREFIX + str(exc))
141
+ changes: dict[str, Any] = {"backtest": result, "backtest_error": None}
142
+ return replace(base, **changes) if changes else base
143
+
144
+
145
+ def selected_window(
146
+ result: BacktestResult, year_text: str
147
+ ) -> tuple[WindowOutcome | None, str]:
148
+ """The window the raw starting-year text picks, or why none does.
149
+
150
+ A blank entry picks nothing silently; anything else must name one
151
+ of the result's starting years. The message names the valid span,
152
+ so a miss teaches the range.
153
+ """
154
+ text = year_text.strip()
155
+ if not text:
156
+ return None, ""
157
+ first = result.outcomes[0].start_year
158
+ last = result.outcomes[-1].start_year
159
+ miss = f"No window starts in {text} — starting years run {first} to {last}."
160
+ try:
161
+ year = int(text, 10)
162
+ except ValueError:
163
+ return None, miss
164
+ outcome = next(
165
+ (entry for entry in result.outcomes if entry.start_year == year), None
166
+ )
167
+ if outcome is None:
168
+ return None, miss
169
+ return outcome, ""
170
+
171
+
172
+ def build_backtest_panel(
173
+ state: PlanState,
174
+ *,
175
+ ending_pot_deflator: Decimal | None,
176
+ basis_suffix: str,
177
+ year_text: str = "",
178
+ ) -> BacktestPanelViewModel:
179
+ """The historical-backtest card for the charts screen (9.18).
180
+
181
+ ``ending_pot_deflator`` is the final period's balance deflator in
182
+ the screen's basis (1 under nominal) — every window shares the
183
+ run's one assumed CPI path (planning §5.2), so the deterministic
184
+ report's deflator presents the window pots too; ``None`` (no
185
+ projection to read it from) leaves the metrics unbuilt.
186
+ ``basis_suffix`` names the basis on the money labels. ``year_text``
187
+ is the starting-year picker's raw text; with a held result it is
188
+ vetted through :func:`selected_window` so the card can say why a
189
+ miss drew nothing.
190
+ """
191
+ result = state.backtest
192
+ metrics: tuple[BacktestMetric, ...] = ()
193
+ message = ""
194
+ year_placeholder = ""
195
+ year_tooltip = BACKTEST_YEAR_TOOLTIP_NO_RUN
196
+ year_message = ""
197
+ if state.backtest_error is not None:
198
+ message = state.backtest_error
199
+ elif result is None or ending_pot_deflator is None:
200
+ message = NO_BACKTEST_MESSAGE
201
+ else:
202
+ metrics = _metrics(result, ending_pot_deflator, basis_suffix)
203
+ first = result.outcomes[0].start_year
204
+ last = result.outcomes[-1].start_year
205
+ year_placeholder = f"{first}-{last}"
206
+ year_tooltip = (
207
+ "Draw one historical starting year's balance path on the"
208
+ " balances chart, alongside the worst and best starting"
209
+ f" years. Enter a year from {first} to {last}."
210
+ )
211
+ _, year_message = selected_window(result, year_text)
212
+ return BacktestPanelViewModel(
213
+ heading=BACKTEST_HEADING,
214
+ run_label=RUN_BACKTEST_LABEL,
215
+ year_label=BACKTEST_YEAR_LABEL,
216
+ year_value=year_text,
217
+ year_placeholder=year_placeholder,
218
+ year_tooltip=year_tooltip,
219
+ year_message=year_message,
220
+ metrics=metrics,
221
+ message=message,
222
+ )
223
+
224
+
225
+ def _window_value(
226
+ outcome: WindowOutcome, ending_pot_deflator: Decimal, basis_suffix: str
227
+ ) -> str:
228
+ """One starting year with how its window ended.
229
+
230
+ A ruined window names the plan-calendar year the money ran out —
231
+ the year the charts' x axis carries — and a surviving one its
232
+ ending pot, so the row reads against the chart either way.
233
+ """
234
+ shortfall = outcome.first_shortfall_period
235
+ if shortfall is not None:
236
+ return f"{outcome.start_year} — money ran out in {shortfall.start.year}"
237
+ presented = Money(outcome.ending_balance.amount / ending_pot_deflator).quantized()
238
+ return (
239
+ f"{outcome.start_year} — ending pot {format_money(presented)} ({basis_suffix})"
240
+ )
241
+
242
+
243
+ def _metrics(
244
+ result: BacktestResult, ending_pot_deflator: Decimal, basis_suffix: str
245
+ ) -> tuple[BacktestMetric, ...]:
246
+ """The backtest readout rows, mirroring the Monte Carlo metrics."""
247
+ first = result.outcomes[0].start_year
248
+ last = result.outcomes[-1].start_year
249
+ rows = [
250
+ BacktestMetric(
251
+ label=SUCCESS_RATE_LABEL, value=format_percent(result.success_rate)
252
+ ),
253
+ BacktestMetric(
254
+ label=WINDOWS_LABEL,
255
+ value=f"{result.window_count} starting years ({first} to {last})",
256
+ ),
257
+ BacktestMetric(
258
+ label=WORST_WINDOW_LABEL,
259
+ value=_window_value(result.worst_window, ending_pot_deflator, basis_suffix),
260
+ ),
261
+ BacktestMetric(
262
+ label=BEST_WINDOW_LABEL,
263
+ value=_window_value(result.best_window, ending_pot_deflator, basis_suffix),
264
+ ),
265
+ ]
266
+ for spec in BAND_SPECS:
267
+ pot = result.ending_pot_percentile(spec.percentile)
268
+ presented = Money(pot.amount / ending_pot_deflator).quantized()
269
+ rows.append(
270
+ BacktestMetric(
271
+ label=f"{spec.ending_pot_label} ({basis_suffix})",
272
+ value=format_money(presented),
273
+ )
274
+ )
275
+ return tuple(rows)
276
+
277
+
278
+ def _with_backtest_error(state: PlanState, message: str) -> PlanState:
279
+ """The state carrying a backtest failure, any held result dropped."""
280
+ changes: dict[str, Any] = {"backtest": None, "backtest_error": message}
281
+ return replace(state, **changes) if changes else state