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,989 @@
1
+ """The shell's widgets: disclaimer, main window, and its tabs (§1, §4.7).
2
+
3
+ The main window owns the immutable app-layer session state and swaps
4
+ it through the pure transitions in :mod:`glidepath.app`; widgets only
5
+ render view models and forward raw user input back.
6
+ """
7
+
8
+ import contextlib
9
+ from datetime import UTC, date, datetime
10
+ from pathlib import Path
11
+ from typing import TYPE_CHECKING
12
+
13
+ from PySide6.QtCore import (
14
+ QObject,
15
+ QRunnable,
16
+ QStandardPaths,
17
+ Qt,
18
+ QThreadPool,
19
+ QUrl,
20
+ Signal,
21
+ )
22
+ from PySide6.QtGui import QCloseEvent, QKeySequence, QPdfWriter, QTextDocument
23
+ from PySide6.QtWidgets import (
24
+ QDialog,
25
+ QDialogButtonBox,
26
+ QFileDialog,
27
+ QGroupBox,
28
+ QLabel,
29
+ QMainWindow,
30
+ QMessageBox,
31
+ QPushButton,
32
+ QScrollArea,
33
+ QTabWidget,
34
+ QVBoxLayout,
35
+ QWidget,
36
+ )
37
+
38
+ from glidepath.app import (
39
+ BACKTEST_RUNNING_MESSAGE,
40
+ BACKTEST_STALE_MESSAGE,
41
+ DEFAULT_CHART_BASIS,
42
+ DEFAULT_COMPARISON_METRIC_KEY,
43
+ DEFAULT_RUN_MODE,
44
+ DRAWDOWN_RUNNING_MESSAGE,
45
+ DRAWDOWN_STALE_MESSAGE,
46
+ MONTE_CARLO_STALE_MESSAGE,
47
+ NOTHING_TO_EXPORT_MESSAGE,
48
+ REPORT_EXPORT_FAILED_PREFIX,
49
+ REPORT_NOT_WRITTEN_MESSAGE,
50
+ RETIREMENT_RUNNING_MESSAGE,
51
+ RETIREMENT_STALE_MESSAGE,
52
+ UNSAVED_CHANGES_PROMPT,
53
+ UNSAVED_CHANGES_TITLE,
54
+ AboutViewModel,
55
+ DisclaimerViewModel,
56
+ DrawdownRequest,
57
+ FactsFormData,
58
+ HelpGuideViewModel,
59
+ PlanReport,
60
+ PlanState,
61
+ ReportRequest,
62
+ RetirementRequest,
63
+ ShellViewModel,
64
+ basis_from_key,
65
+ build_charts_view_model,
66
+ build_inspector_view_model,
67
+ build_plan_report,
68
+ build_scenarios_view_model,
69
+ chart_resource_name,
70
+ example_facts_form_data,
71
+ export_cash_flow_csv,
72
+ facts_form_data_from_household,
73
+ facts_saved_message,
74
+ format_form_errors,
75
+ has_unsaved_changes,
76
+ initial_plan_state,
77
+ load_plan_state,
78
+ metric_from_key,
79
+ monte_carlo_running_status,
80
+ parse_facts_form,
81
+ plan_display_name,
82
+ plan_entity_ids,
83
+ record_last_plan_path,
84
+ report_exported_message,
85
+ run_mode_from_key,
86
+ save_plan_state,
87
+ state_marked_saved,
88
+ state_with_backtest,
89
+ state_with_drawdown,
90
+ state_with_household,
91
+ state_with_monte_carlo,
92
+ state_with_override,
93
+ state_with_retirement,
94
+ state_with_scenario_added,
95
+ state_with_scenario_override,
96
+ state_without_scenario,
97
+ state_without_scenario_override,
98
+ )
99
+ from glidepath.gui.charts import ChartsPane, ChartsPaneCallbacks, chart_image
100
+ from glidepath.gui.forms import FactsEntryPane
101
+ from glidepath.gui.inspector import InspectorPane
102
+ from glidepath.gui.scenarios import ScenariosPane, ScenariosPaneCallbacks
103
+ from glidepath.gui.style import wordmark_pixmap
104
+
105
+ if TYPE_CHECKING:
106
+ from collections.abc import Callable
107
+
108
+ _REPORT_DPI = 96
109
+ """The PDF paint device's resolution.
110
+
111
+ Matches the CSS reference pixel, so the report HTML's pixel-sized
112
+ chart images print at the size the app layer specified.
113
+ """
114
+
115
+
116
+ def _today() -> date:
117
+ """The user's calendar day, as the run and form defaults use it (§4.8)."""
118
+ return datetime.now(tz=UTC).astimezone().date()
119
+
120
+
121
+ class DisclaimerDialog(QDialog):
122
+ """Modal first-run disclaimer; accepting is required to proceed (§1)."""
123
+
124
+ def __init__(
125
+ self, view_model: DisclaimerViewModel, parent: QWidget | None = None
126
+ ) -> None:
127
+ """Bind the disclaimer view model to the dialog."""
128
+ super().__init__(parent)
129
+ self.setWindowTitle(view_model.title)
130
+ self.setModal(True)
131
+
132
+ body = QLabel(view_model.body, self)
133
+ body.setWordWrap(True)
134
+
135
+ buttons = QDialogButtonBox(self)
136
+ self.accept_button = QPushButton(view_model.accept_label, self)
137
+ self.decline_button = QPushButton(view_model.decline_label, self)
138
+ buttons.addButton(self.accept_button, QDialogButtonBox.ButtonRole.AcceptRole)
139
+ buttons.addButton(self.decline_button, QDialogButtonBox.ButtonRole.RejectRole)
140
+ buttons.accepted.connect(self.accept)
141
+ buttons.rejected.connect(self.reject)
142
+
143
+ layout = QVBoxLayout(self)
144
+ layout.addWidget(body)
145
+ layout.addWidget(buttons)
146
+
147
+
148
+ class AboutDialog(QDialog):
149
+ """The About box: the wordmark above the disclaimer copy (§1).
150
+
151
+ A stock ``QMessageBox`` would put the wordmark beside the text and
152
+ squeeze the disclaimer into a narrow column; stacking them keeps
153
+ the copy readable.
154
+ """
155
+
156
+ def __init__(
157
+ self, view_model: AboutViewModel, parent: QWidget | None = None
158
+ ) -> None:
159
+ """Bind the about view model to the dialog."""
160
+ super().__init__(parent)
161
+ self.setWindowTitle(view_model.title)
162
+
163
+ self.wordmark_label = QLabel(self)
164
+ self.wordmark_label.setPixmap(
165
+ wordmark_pixmap().scaledToWidth(
166
+ 320, Qt.TransformationMode.SmoothTransformation
167
+ )
168
+ )
169
+ self.wordmark_label.setAlignment(Qt.AlignmentFlag.AlignHCenter)
170
+
171
+ self.body_label = QLabel(view_model.body, self)
172
+ self.body_label.setWordWrap(True)
173
+
174
+ buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok, self)
175
+ buttons.accepted.connect(self.accept)
176
+
177
+ layout = QVBoxLayout(self)
178
+ layout.addWidget(self.wordmark_label)
179
+ layout.addWidget(self.body_label)
180
+ layout.addWidget(buttons)
181
+ # A wrapped label only wraps against a bound, and its size hint
182
+ # understates the wrapped height — fix the width and take the
183
+ # height from the layout's height-for-width so no line is cut.
184
+ self.setFixedSize(440, layout.totalHeightForWidth(440))
185
+
186
+
187
+ class HelpGuideDialog(QDialog):
188
+ """The how-to-use guide: the intro, then one card per section."""
189
+
190
+ def __init__(
191
+ self, view_model: HelpGuideViewModel, parent: QWidget | None = None
192
+ ) -> None:
193
+ """Bind the help guide view model to the dialog."""
194
+ super().__init__(parent)
195
+ self.setWindowTitle(view_model.title)
196
+
197
+ self.intro_label = QLabel(view_model.intro, self)
198
+ self.intro_label.setWordWrap(True)
199
+
200
+ content = QWidget()
201
+ content_layout = QVBoxLayout(content)
202
+ cards = []
203
+ for section in view_model.sections:
204
+ card = QGroupBox(section.heading, content)
205
+ body = QLabel(section.body, card)
206
+ body.setWordWrap(True)
207
+ card_layout = QVBoxLayout(card)
208
+ card_layout.addWidget(body)
209
+ content_layout.addWidget(card)
210
+ cards.append(card)
211
+ self.section_cards = tuple(cards)
212
+ content_layout.addStretch(1)
213
+
214
+ scroll = QScrollArea(self)
215
+ scroll.setWidgetResizable(True)
216
+ scroll.setWidget(content)
217
+
218
+ buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close, self)
219
+ buttons.rejected.connect(self.reject)
220
+
221
+ layout = QVBoxLayout(self)
222
+ layout.addWidget(self.intro_label)
223
+ layout.addWidget(scroll, 1)
224
+ layout.addWidget(buttons)
225
+ self.resize(560, 640)
226
+
227
+
228
+ class _TransitionSignals(QObject):
229
+ """Delivers a finished session state back to the GUI thread."""
230
+
231
+ finished = Signal(object)
232
+
233
+
234
+ class _TransitionWorker(QRunnable):
235
+ """Runs a slow state transition off the GUI thread (9.13, 9.14).
236
+
237
+ A Monte Carlo run or a retirement-age search projects the plan
238
+ many times over at the recorded per-path cost (planning §4.6) —
239
+ seconds to minutes, far too long to block the event loop. The
240
+ transition itself is pure app-layer code; only the delivery back
241
+ to the window touches Qt, via a queued signal.
242
+ """
243
+
244
+ def __init__(self, task: Callable[[], PlanState]) -> None:
245
+ """Hold the pure transition to run and the delivery signal."""
246
+ super().__init__()
247
+ self._task = task
248
+ self.signals = _TransitionSignals()
249
+
250
+ def run(self) -> None:
251
+ """Compute the next session state and emit it."""
252
+ self.signals.finished.emit(self._task())
253
+
254
+
255
+ class MainWindow(QMainWindow):
256
+ """The application shell: facts entry, the inspector, and the Help menu."""
257
+
258
+ def __init__(
259
+ self, view_model: ShellViewModel, settings_path: Path | None = None
260
+ ) -> None:
261
+ """Bind the shell view model to the window and start a session.
262
+
263
+ ``settings_path`` is the per-user settings file recording the
264
+ last plan path for the next launch; ``None`` (tests, embedded
265
+ shells) records nothing.
266
+ """
267
+ super().__init__()
268
+ self._view_model = view_model
269
+ self._about_view_model = view_model.about
270
+ self._settings_path = settings_path
271
+ self._plan_path: Path | None = None
272
+ self._state = initial_plan_state()
273
+ self._charts_basis = DEFAULT_CHART_BASIS
274
+ self._charts_mode = DEFAULT_RUN_MODE
275
+ self._comparison_basis = DEFAULT_CHART_BASIS
276
+ self._comparison_metric = DEFAULT_COMPARISON_METRIC_KEY
277
+ self.monte_carlo_pool = QThreadPool(self)
278
+ self.monte_carlo_pool.setMaxThreadCount(1)
279
+ self._monte_carlo_worker: _TransitionWorker | None = None
280
+ self._monte_carlo_input: PlanState | None = None
281
+ self._retirement_worker: _TransitionWorker | None = None
282
+ self._retirement_input: PlanState | None = None
283
+ self._drawdown_worker: _TransitionWorker | None = None
284
+ self._drawdown_input: PlanState | None = None
285
+ self._backtest_worker: _TransitionWorker | None = None
286
+ self._backtest_input: PlanState | None = None
287
+ self._backtest_year = ""
288
+ self.setWindowTitle(view_model.window_title)
289
+ self.resize(1120, 780)
290
+
291
+ self.facts_pane = FactsEntryPane(
292
+ view_model.facts_form,
293
+ self._handle_facts_submitted,
294
+ self._handle_cleared,
295
+ )
296
+ self.charts_pane = ChartsPane(
297
+ ChartsPaneCallbacks(
298
+ select_basis=self._handle_charts_basis,
299
+ select_mode=self._handle_charts_mode,
300
+ run_monte_carlo=self._handle_monte_carlo_run,
301
+ run_retirement=self._handle_retirement_run,
302
+ run_drawdown=self._handle_drawdown_run,
303
+ run_backtest=self._handle_backtest_run,
304
+ select_backtest_year=self._handle_backtest_year,
305
+ )
306
+ )
307
+ self.scenarios_pane = ScenariosPane(
308
+ ScenariosPaneCallbacks(
309
+ add_scenario=self._handle_scenario_added,
310
+ remove_scenario=self._handle_scenario_removed,
311
+ set_override=self._handle_scenario_override,
312
+ remove_override=self._handle_scenario_override_removed,
313
+ select_basis=self._handle_comparison_basis,
314
+ select_metric=self._handle_comparison_metric,
315
+ )
316
+ )
317
+ self.inspector_pane = InspectorPane(self._handle_override)
318
+ tabs = QTabWidget(self)
319
+ tabs.addTab(self.facts_pane, view_model.facts_tab_label)
320
+ tabs.addTab(self.charts_pane, view_model.charts_tab_label)
321
+ tabs.addTab(self.scenarios_pane, view_model.scenarios_tab_label)
322
+ tabs.addTab(self.inspector_pane, view_model.inspector_tab_label)
323
+ self.setCentralWidget(tabs)
324
+ self._load_example()
325
+ self._build_menus(view_model)
326
+
327
+ def _build_menus(self, view_model: ShellViewModel) -> None:
328
+ """Populate the menu bar from the shell view model.
329
+
330
+ The "&" mnemonics and keyboard shortcuts are toolkit
331
+ mechanics, not copy — the labels themselves come from the app
332
+ layer (§4.7). Standard keys follow platform conventions
333
+ (issue #135); the exports have no standard key, so they take
334
+ explicit accelerators.
335
+ """
336
+ file_menu = self.menuBar().addMenu(f"&{view_model.file_menu.menu_label}")
337
+ self.open_action = file_menu.addAction(view_model.file_menu.open_label)
338
+ self.open_action.setShortcut(QKeySequence.StandardKey.Open)
339
+ self.open_action.triggered.connect(self.open_plan_dialog)
340
+ self.save_action = file_menu.addAction(view_model.file_menu.save_label)
341
+ self.save_action.setShortcut(QKeySequence.StandardKey.Save)
342
+ self.save_action.triggered.connect(self.save_plan)
343
+ self.save_as_action = file_menu.addAction(view_model.file_menu.save_as_label)
344
+ self.save_as_action.setShortcut(QKeySequence.StandardKey.SaveAs)
345
+ self.save_as_action.triggered.connect(self.save_plan_as_dialog)
346
+ file_menu.addSeparator()
347
+ self.export_cash_flow_action = file_menu.addAction(
348
+ view_model.file_menu.export_cash_flow_label
349
+ )
350
+ self.export_cash_flow_action.setShortcut(QKeySequence("Ctrl+E"))
351
+ self.export_cash_flow_action.triggered.connect(self.export_cash_flow_dialog)
352
+ self.export_report_action = file_menu.addAction(
353
+ view_model.file_menu.export_report_label
354
+ )
355
+ self.export_report_action.setShortcut(QKeySequence("Ctrl+Shift+E"))
356
+ self.export_report_action.triggered.connect(self.export_report_dialog)
357
+ file_menu.addSeparator()
358
+ self.quit_action = file_menu.addAction(view_model.file_menu.quit_label)
359
+ # Not StandardKey.Quit: Windows resolves it to the rare
360
+ # Key_Exit multimedia key rather than an accelerator. The
361
+ # literal gives Ctrl+Q, and macOS maps Ctrl to Command, so the
362
+ # binding lands on each platform's convention anyway.
363
+ self.quit_action.setShortcut(QKeySequence("Ctrl+Q"))
364
+ self.quit_action.triggered.connect(self.close)
365
+ help_menu = self.menuBar().addMenu(f"&{view_model.help_menu_label}")
366
+ self.help_guide_action = help_menu.addAction(view_model.help_guide.title)
367
+ self.help_guide_action.setShortcut(QKeySequence.StandardKey.HelpContents)
368
+ self.help_guide_action.triggered.connect(self.show_help_guide)
369
+ self.about_action = help_menu.addAction(view_model.about.title)
370
+ self.about_action.triggered.connect(self.show_about)
371
+
372
+ def open_plan(self, path: Path) -> bool:
373
+ """Load the plan at ``path`` into the session; True when it loaded.
374
+
375
+ On success every pane re-renders from the loaded state and the
376
+ facts form repopulates from the loaded household, so an edit
377
+ starts from what the file says. On failure the session is left
378
+ untouched and the status bar explains.
379
+ """
380
+ outcome = load_plan_state(path, today=_today())
381
+ self.statusBar().showMessage(outcome.message)
382
+ if outcome.state is None:
383
+ return False
384
+ self._state = outcome.state
385
+ self._plan_path = path
386
+ self._remember_plan_path(path)
387
+ household = outcome.state.household
388
+ if household is not None:
389
+ self.facts_pane.set_form_data(facts_form_data_from_household(household))
390
+ self.facts_pane.status_label.setText(outcome.message)
391
+ self._refresh_result_panes()
392
+ return True
393
+
394
+ def save_plan(self) -> None:
395
+ """Save to the session's plan file, or ask for one the first time."""
396
+ if self._plan_path is None:
397
+ self.save_plan_as_dialog()
398
+ return
399
+ self._write_plan(self._plan_path)
400
+
401
+ def _dialog_dir(self) -> str:
402
+ """Where file dialogs open (never the app's install directory).
403
+
404
+ Beside the session's plan file when there is one, else the
405
+ user's documents folder.
406
+ """
407
+ if self._plan_path is not None:
408
+ return str(self._plan_path.parent)
409
+ return QStandardPaths.writableLocation(
410
+ QStandardPaths.StandardLocation.DocumentsLocation
411
+ )
412
+
413
+ def open_plan_dialog(self) -> None:
414
+ """Ask for a plan file and load it."""
415
+ menu = self._view_model.file_menu
416
+ filename, _selected = QFileDialog.getOpenFileName(
417
+ self, menu.open_dialog_title, self._dialog_dir(), menu.file_filter
418
+ )
419
+ if filename:
420
+ self.open_plan(Path(filename))
421
+
422
+ def save_plan_as_dialog(self) -> None:
423
+ """Ask where to save the plan and write it there."""
424
+ menu = self._view_model.file_menu
425
+ filename, _selected = QFileDialog.getSaveFileName(
426
+ self, menu.save_dialog_title, self._dialog_dir(), menu.file_filter
427
+ )
428
+ if not filename:
429
+ return
430
+ if not filename.endswith(menu.file_suffix):
431
+ filename += menu.file_suffix
432
+ self._write_plan(Path(filename))
433
+
434
+ def export_cash_flow_dialog(self) -> None:
435
+ """Ask where to export the cash-flow CSV and write it there (9.19).
436
+
437
+ The CSV presents the active run in the charts screen's money
438
+ basis; the app layer builds and writes it — the shell only
439
+ contributes the dialog.
440
+ """
441
+ menu = self._view_model.file_menu
442
+ filename, _selected = QFileDialog.getSaveFileName(
443
+ self,
444
+ menu.export_cash_flow_dialog_title,
445
+ self._dialog_dir(),
446
+ menu.export_cash_flow_filter,
447
+ )
448
+ if not filename:
449
+ return
450
+ if not filename.endswith(menu.export_cash_flow_suffix):
451
+ filename += menu.export_cash_flow_suffix
452
+ outcome = export_cash_flow_csv(
453
+ self._state,
454
+ Path(filename),
455
+ basis=self._charts_basis,
456
+ plan_name=plan_display_name(self._plan_path),
457
+ )
458
+ self.statusBar().showMessage(outcome.message)
459
+
460
+ def export_report_dialog(self) -> None:
461
+ """Ask where to export the plan report and print it there (9.19).
462
+
463
+ The report presents the charts screen's basis and run mode and
464
+ the scenarios screen's comparison selections — exactly what is
465
+ on screen. The app layer builds the document; the shell
466
+ contributes the chart images and the PDF paint device (§4.7).
467
+ """
468
+ menu = self._view_model.file_menu
469
+ filename, _selected = QFileDialog.getSaveFileName(
470
+ self,
471
+ menu.export_report_dialog_title,
472
+ self._dialog_dir(),
473
+ menu.export_report_filter,
474
+ )
475
+ if not filename:
476
+ return
477
+ if not filename.endswith(menu.export_report_suffix):
478
+ filename += menu.export_report_suffix
479
+ report = build_plan_report(
480
+ self._state,
481
+ ReportRequest(
482
+ plan_name=plan_display_name(self._plan_path),
483
+ basis=self._charts_basis,
484
+ mode=self._charts_mode,
485
+ comparison_basis=self._comparison_basis,
486
+ comparison_metric_key=self._comparison_metric,
487
+ backtest_year=self._backtest_year,
488
+ ),
489
+ )
490
+ if report is None:
491
+ self.statusBar().showMessage(NOTHING_TO_EXPORT_MESSAGE)
492
+ return
493
+ self.statusBar().showMessage(self._write_report_pdf(report, Path(filename)))
494
+
495
+ def _write_report_pdf(self, report: PlanReport, path: Path) -> str:
496
+ """Print the report to a PDF at ``path``; the status line to show.
497
+
498
+ Every chart spec renders to an image registered under the
499
+ resource name the report's HTML references, then the laid-out
500
+ document prints to the PDF device. The device reports no write
501
+ status (a failed open only logs a Qt warning), so the document
502
+ prints to a sibling ``.part`` file that must come out non-empty
503
+ before it replaces ``path`` — over-writing a stale export can
504
+ therefore never report success while leaving the old file in
505
+ place. A failure folds into the returned message, matching the
506
+ app-layer transitions' rule.
507
+ """
508
+ document = QTextDocument()
509
+ for index, chart in enumerate(report.charts):
510
+ document.addResource(
511
+ QTextDocument.ResourceType.ImageResource,
512
+ QUrl(chart_resource_name(index)),
513
+ chart_image(chart, report.categories),
514
+ )
515
+ document.setHtml(report.html)
516
+ partial = path.with_name(f"{path.name}.part")
517
+ try:
518
+ writer = QPdfWriter(str(partial))
519
+ writer.setResolution(_REPORT_DPI)
520
+ document.print_(writer)
521
+ # The writer must release its file handle before the
522
+ # replace, or Windows refuses to move the finished file.
523
+ del writer
524
+ if not partial.exists() or partial.stat().st_size == 0:
525
+ return REPORT_NOT_WRITTEN_MESSAGE
526
+ partial.replace(path)
527
+ except OSError as exc:
528
+ return f"{REPORT_EXPORT_FAILED_PREFIX}{exc}"
529
+ finally:
530
+ with contextlib.suppress(OSError):
531
+ partial.unlink(missing_ok=True)
532
+ return report_exported_message(path)
533
+
534
+ def _write_plan(self, path: Path) -> None:
535
+ """Write the session's plan to ``path`` and report the outcome."""
536
+ outcome = save_plan_state(self._state, path)
537
+ self.statusBar().showMessage(outcome.message)
538
+ if outcome.saved:
539
+ self._state = state_marked_saved(self._state)
540
+ self._plan_path = path
541
+ self._remember_plan_path(path)
542
+
543
+ def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802
544
+ """Ask before a close discards unsaved plan edits (issue #136).
545
+
546
+ Save runs the normal save flow — asking for a path when the
547
+ session has none — and the window closes only once the changes
548
+ are actually saved: a failed or cancelled save keeps it open
549
+ rather than silently discarding the edits it just promised to
550
+ keep.
551
+ """
552
+ if not has_unsaved_changes(self._state):
553
+ event.accept()
554
+ return
555
+ choice = QMessageBox.question(
556
+ self,
557
+ UNSAVED_CHANGES_TITLE,
558
+ UNSAVED_CHANGES_PROMPT,
559
+ QMessageBox.StandardButton.Save
560
+ | QMessageBox.StandardButton.Discard
561
+ | QMessageBox.StandardButton.Cancel,
562
+ QMessageBox.StandardButton.Save,
563
+ )
564
+ if choice == QMessageBox.StandardButton.Save:
565
+ self.save_plan()
566
+ if has_unsaved_changes(self._state):
567
+ event.ignore()
568
+ return
569
+ event.accept()
570
+ return
571
+ if choice == QMessageBox.StandardButton.Discard:
572
+ event.accept()
573
+ return
574
+ event.ignore()
575
+
576
+ def _remember_plan_path(self, path: Path) -> None:
577
+ """Record the plan path for the next launch, best-effort.
578
+
579
+ An unwritable config directory must not break the save or load
580
+ that just succeeded — worst case the next launch starts on the
581
+ example again.
582
+ """
583
+ if self._settings_path is None:
584
+ return
585
+ with contextlib.suppress(OSError):
586
+ record_last_plan_path(self._settings_path, path)
587
+
588
+ def _load_example(self) -> None:
589
+ """Open with the example plan on screen and projected (§4.9).
590
+
591
+ The example is raw form text through the normal submission
592
+ path — guaranteed parseable by test — with the status line
593
+ explaining it is an example, not the user's data. It is
594
+ shipped demo data, not a user edit, so closing straight after
595
+ launch must not ask about saving it (issue #136).
596
+ """
597
+ self.facts_pane.set_form_data(example_facts_form_data())
598
+ self._handle_facts_submitted(self.facts_pane.form_data())
599
+ self._state = state_marked_saved(self._state)
600
+ self.facts_pane.status_label.setText(self._view_model.facts_form.example_note)
601
+
602
+ def _handle_cleared(self) -> str:
603
+ """Reset the session to no plan and re-render the result panes.
604
+
605
+ The session's plan file detaches too: after a clear, the next
606
+ Save asks where to write rather than overwriting the file the
607
+ cleared plan came from.
608
+ """
609
+ self._state = initial_plan_state()
610
+ self._plan_path = None
611
+ self._refresh_result_panes()
612
+ return self._view_model.facts_form.cleared_note
613
+
614
+ def _handle_facts_submitted(self, data: FactsFormData) -> str:
615
+ """Parse a facts submission; on success, re-project and refresh."""
616
+ now = datetime.now(tz=UTC)
617
+ # Provenance timestamps stay UTC; "today" is the user's calendar
618
+ # day, which differs from the UTC date around midnight. The form
619
+ # defaults blank as_of dates from the same local day the run
620
+ # uses, so a defaulted balance date is never future-dated (§4.8).
621
+ today = now.astimezone().date()
622
+ result = parse_facts_form(
623
+ data, recorded_on=now, today=today, previous=self._state.household
624
+ )
625
+ if result.household is None:
626
+ return format_form_errors(self._view_model.facts_form, result.errors)
627
+ self._state = state_with_household(self._state, result.household, today=today)
628
+ # Rows typed fresh mint their entity ids at parse time; seeding
629
+ # them back means the next resubmission edits the same entities
630
+ # instead of minting again and orphaning overrides (§4.3).
631
+ self.facts_pane.set_entity_ids(plan_entity_ids(result.household))
632
+ self._refresh_result_panes()
633
+ return facts_saved_message(self._state)
634
+
635
+ def _handle_override(self, key: str, raw_value: str) -> str | None:
636
+ """Apply an in-place assumption override; report a rejection."""
637
+ now = datetime.now(tz=UTC)
638
+ outcome = state_with_override(
639
+ self._state, key, raw_value, recorded_on=now, today=now.astimezone().date()
640
+ )
641
+ if outcome.error is not None:
642
+ return outcome.error
643
+ self._state = outcome.state
644
+ self._refresh_result_panes()
645
+ return None
646
+
647
+ def _handle_charts_basis(self, key: str) -> None:
648
+ """Re-present the charts in the basis the user selected."""
649
+ self._charts_basis = basis_from_key(key)
650
+ self._refresh_charts_pane()
651
+
652
+ def _handle_charts_mode(self, key: str) -> None:
653
+ """Re-present the charts in the run mode the user selected (9.13)."""
654
+ self._charts_mode = run_mode_from_key(key)
655
+ self._refresh_charts_pane()
656
+
657
+ def _transition_in_flight(self) -> bool:
658
+ """Whether a slow run (Monte Carlo, retirement, drawdown, backtest) is running.
659
+
660
+ The transitions share one guard: each computes from the
661
+ session state captured at start, so a second launched while
662
+ the first runs could only ever be discarded as stale after
663
+ minutes of wasted compute.
664
+ """
665
+ return (
666
+ self._monte_carlo_worker is not None
667
+ or self._retirement_worker is not None
668
+ or self._drawdown_worker is not None
669
+ or self._backtest_worker is not None
670
+ )
671
+
672
+ def _set_transitions_busy(self, *, busy: bool) -> None:
673
+ """Disable (or re-enable) every slow-run action together."""
674
+ self.charts_pane.set_monte_carlo_busy(busy=busy)
675
+ self.charts_pane.set_retirement_busy(busy=busy)
676
+ self.charts_pane.set_drawdown_busy(busy=busy)
677
+ self.charts_pane.set_backtest_busy(busy=busy)
678
+
679
+ def _handle_monte_carlo_run(self, seed_text: str, paths_text: str) -> None:
680
+ """Start a Monte Carlo run off the GUI thread (9.13).
681
+
682
+ The window stays responsive while the paths run; the finished
683
+ state arrives back on the GUI thread through the worker's
684
+ queued signal. Both slow-run actions are disabled meanwhile,
685
+ and a request while either is in flight is ignored.
686
+ """
687
+ if self._transition_in_flight():
688
+ return
689
+ state = self._state
690
+ today = _today()
691
+ worker = _TransitionWorker(
692
+ lambda: state_with_monte_carlo(state, seed_text, paths_text, today=today)
693
+ )
694
+ worker.signals.finished.connect(self._handle_monte_carlo_finished)
695
+ self._monte_carlo_worker = worker
696
+ self._monte_carlo_input = state
697
+ self._set_transitions_busy(busy=True)
698
+ status = monte_carlo_running_status(paths_text)
699
+ self.statusBar().showMessage(status)
700
+ self.charts_pane.show_busy(status)
701
+ self.monte_carlo_pool.start(worker)
702
+
703
+ def _handle_monte_carlo_finished(self, state: PlanState) -> None:
704
+ """Adopt a finished Monte Carlo run unless the plan moved on.
705
+
706
+ The run was computed from the session state captured at start;
707
+ if a facts save, override, or scenario edit replaced the state
708
+ meanwhile, the result describes a plan no longer on screen and
709
+ is discarded (the status bar says so).
710
+ """
711
+ self._monte_carlo_worker = None
712
+ self._set_transitions_busy(busy=False)
713
+ self.charts_pane.clear_busy()
714
+ stale = self._state is not self._monte_carlo_input
715
+ self._monte_carlo_input = None
716
+ if stale:
717
+ self.statusBar().showMessage(MONTE_CARLO_STALE_MESSAGE)
718
+ return
719
+ self._state = state
720
+ self.statusBar().clearMessage()
721
+ self._refresh_result_panes()
722
+
723
+ def _handle_retirement_run(
724
+ self, rate_text: str, success_text: str, seed_text: str, paths_text: str
725
+ ) -> None:
726
+ """Start a retirement-age search off the GUI thread (9.14).
727
+
728
+ The search runs the plan once per candidate age — same
729
+ threading rules as the Monte Carlo run: the window stays
730
+ responsive, both slow-run actions disable meanwhile, and a
731
+ request while either is in flight is ignored. The screen's
732
+ current run mode is the search's basis; the seed and path text
733
+ come from the Monte Carlo panel's own controls.
734
+ """
735
+ if self._transition_in_flight():
736
+ return
737
+ state = self._state
738
+ today = _today()
739
+ request = RetirementRequest(
740
+ mode=self._charts_mode,
741
+ rate_text=rate_text,
742
+ seed_text=seed_text,
743
+ paths_text=paths_text,
744
+ success_text=success_text,
745
+ )
746
+ worker = _TransitionWorker(
747
+ lambda: state_with_retirement(state, request, today=today)
748
+ )
749
+ worker.signals.finished.connect(self._handle_retirement_finished)
750
+ self._retirement_worker = worker
751
+ self._retirement_input = state
752
+ self._set_transitions_busy(busy=True)
753
+ self.statusBar().showMessage(RETIREMENT_RUNNING_MESSAGE)
754
+ self.charts_pane.show_busy(RETIREMENT_RUNNING_MESSAGE)
755
+ self.monte_carlo_pool.start(worker)
756
+
757
+ def _handle_retirement_finished(self, state: PlanState) -> None:
758
+ """Adopt a finished search unless the plan moved on (9.14).
759
+
760
+ Same staleness rule as the Monte Carlo delivery: an answer
761
+ computed from a session state that was replaced mid-search
762
+ describes a plan no longer on screen and is discarded.
763
+ """
764
+ self._retirement_worker = None
765
+ self._set_transitions_busy(busy=False)
766
+ self.charts_pane.clear_busy()
767
+ stale = self._state is not self._retirement_input
768
+ self._retirement_input = None
769
+ if stale:
770
+ self.statusBar().showMessage(RETIREMENT_STALE_MESSAGE)
771
+ return
772
+ self._state = state
773
+ self.statusBar().clearMessage()
774
+ self._refresh_result_panes()
775
+
776
+ def _handle_drawdown_run(
777
+ self, age_text: str, success_text: str, seed_text: str, paths_text: str
778
+ ) -> None:
779
+ """Start a sustainable-income search off the GUI thread (9.25).
780
+
781
+ The search runs the plan once per probed spending level — same
782
+ threading rules as the Monte Carlo run: the window stays
783
+ responsive, every slow-run action disables meanwhile, and a
784
+ request while any is in flight is ignored. The screen's
785
+ current run mode is the search's basis; the seed and path text
786
+ come from the Monte Carlo panel's own controls.
787
+ """
788
+ if self._transition_in_flight():
789
+ return
790
+ state = self._state
791
+ today = _today()
792
+ request = DrawdownRequest(
793
+ mode=self._charts_mode,
794
+ age_text=age_text,
795
+ seed_text=seed_text,
796
+ paths_text=paths_text,
797
+ success_text=success_text,
798
+ )
799
+ worker = _TransitionWorker(
800
+ lambda: state_with_drawdown(state, request, today=today)
801
+ )
802
+ worker.signals.finished.connect(self._handle_drawdown_finished)
803
+ self._drawdown_worker = worker
804
+ self._drawdown_input = state
805
+ self._set_transitions_busy(busy=True)
806
+ self.statusBar().showMessage(DRAWDOWN_RUNNING_MESSAGE)
807
+ self.charts_pane.show_busy(DRAWDOWN_RUNNING_MESSAGE)
808
+ self.monte_carlo_pool.start(worker)
809
+
810
+ def _handle_drawdown_finished(self, state: PlanState) -> None:
811
+ """Adopt a finished search unless the plan moved on (9.25).
812
+
813
+ Same staleness rule as the Monte Carlo delivery: an answer
814
+ computed from a session state that was replaced mid-search
815
+ describes a plan no longer on screen and is discarded.
816
+ """
817
+ self._drawdown_worker = None
818
+ self._set_transitions_busy(busy=False)
819
+ self.charts_pane.clear_busy()
820
+ stale = self._state is not self._drawdown_input
821
+ self._drawdown_input = None
822
+ if stale:
823
+ self.statusBar().showMessage(DRAWDOWN_STALE_MESSAGE)
824
+ return
825
+ self._state = state
826
+ self.statusBar().clearMessage()
827
+ self._refresh_result_panes()
828
+
829
+ def _handle_backtest_year(self, text: str) -> None:
830
+ """Re-present the charts with the picked starting year (9.18).
831
+
832
+ Presentation state like the basis and mode selections — no
833
+ run happens; the trajectory comes from the held result. Qt
834
+ fires ``editingFinished`` on every focus-out, so an unchanged
835
+ text skips the rebuild.
836
+ """
837
+ if text == self._backtest_year:
838
+ return
839
+ self._backtest_year = text
840
+ self._refresh_charts_pane()
841
+
842
+ def _handle_backtest_run(self) -> None:
843
+ """Start a historical backtest off the GUI thread (9.18).
844
+
845
+ One deterministic window per historical starting year — same
846
+ threading rules as the Monte Carlo run: the window stays
847
+ responsive, every slow-run action disables meanwhile, and a
848
+ request while any is in flight is ignored.
849
+ """
850
+ if self._transition_in_flight():
851
+ return
852
+ state = self._state
853
+ today = _today()
854
+ worker = _TransitionWorker(lambda: state_with_backtest(state, today=today))
855
+ worker.signals.finished.connect(self._handle_backtest_finished)
856
+ self._backtest_worker = worker
857
+ self._backtest_input = state
858
+ self._set_transitions_busy(busy=True)
859
+ self.statusBar().showMessage(BACKTEST_RUNNING_MESSAGE)
860
+ self.charts_pane.show_busy(BACKTEST_RUNNING_MESSAGE)
861
+ self.monte_carlo_pool.start(worker)
862
+
863
+ def _handle_backtest_finished(self, state: PlanState) -> None:
864
+ """Adopt a finished backtest unless the plan moved on (9.18).
865
+
866
+ Same staleness rule as the Monte Carlo delivery: a result
867
+ computed from a session state that was replaced mid-run
868
+ describes a plan no longer on screen and is discarded.
869
+ """
870
+ self._backtest_worker = None
871
+ self._set_transitions_busy(busy=False)
872
+ self.charts_pane.clear_busy()
873
+ stale = self._state is not self._backtest_input
874
+ self._backtest_input = None
875
+ if stale:
876
+ self.statusBar().showMessage(BACKTEST_STALE_MESSAGE)
877
+ return
878
+ self._state = state
879
+ self.statusBar().clearMessage()
880
+ self._refresh_result_panes()
881
+
882
+ def _refresh_charts_pane(self) -> None:
883
+ """Re-render the charts tab in its selected basis and run mode."""
884
+ self.charts_pane.refresh(
885
+ build_charts_view_model(
886
+ self._state,
887
+ basis=self._charts_basis,
888
+ mode=self._charts_mode,
889
+ backtest_year=self._backtest_year,
890
+ )
891
+ )
892
+
893
+ def _handle_scenario_added(self, name: str) -> str | None:
894
+ """Add a scenario; report a rejection."""
895
+ outcome = state_with_scenario_added(self._state, name, today=_today())
896
+ if outcome.error is not None:
897
+ return outcome.error
898
+ self._state = outcome.state
899
+ self._refresh_result_panes()
900
+ return None
901
+
902
+ def _handle_scenario_removed(self, name: str) -> None:
903
+ """Remove a scenario and re-render the comparison."""
904
+ self._state = state_without_scenario(self._state, name, today=_today())
905
+ self._refresh_result_panes()
906
+
907
+ def _handle_scenario_override(
908
+ self, scenario: str, target_key: str, raw_value: str
909
+ ) -> str | None:
910
+ """Set one scenario override; report a rejection."""
911
+ outcome = state_with_scenario_override(
912
+ self._state, scenario, target_key, raw_value, today=_today()
913
+ )
914
+ if outcome.error is not None:
915
+ return outcome.error
916
+ self._state = outcome.state
917
+ self._refresh_result_panes()
918
+ return None
919
+
920
+ def _handle_scenario_override_removed(self, scenario: str, target_key: str) -> None:
921
+ """Remove one scenario override and re-render the comparison."""
922
+ self._state = state_without_scenario_override(
923
+ self._state, scenario, target_key, today=_today()
924
+ )
925
+ self._refresh_result_panes()
926
+
927
+ def _handle_comparison_basis(self, key: str) -> None:
928
+ """Re-present the comparison in the basis the user selected."""
929
+ self._comparison_basis = basis_from_key(key)
930
+ self._refresh_scenarios_pane()
931
+
932
+ def _handle_comparison_metric(self, key: str) -> None:
933
+ """Re-present the comparison on the metric the user selected."""
934
+ self._comparison_metric = metric_from_key(key)
935
+ self._refresh_scenarios_pane()
936
+
937
+ def _refresh_scenarios_pane(self) -> None:
938
+ """Re-render the scenario manager and comparison report."""
939
+ self.scenarios_pane.refresh(
940
+ build_scenarios_view_model(
941
+ self._state,
942
+ basis=self._comparison_basis,
943
+ metric_key=self._comparison_metric,
944
+ )
945
+ )
946
+
947
+ def _refresh_result_panes(self) -> None:
948
+ """Re-render every pane that reads the session's projection."""
949
+ self._refresh_charts_pane()
950
+ self._refresh_scenarios_pane()
951
+ self.inspector_pane.refresh(build_inspector_view_model(self._state))
952
+
953
+ def about_dialog(self) -> AboutDialog:
954
+ """The About dialog, bound to the shell's about view model."""
955
+ return AboutDialog(self._about_view_model, self)
956
+
957
+ def show_about(self) -> None:
958
+ """Show the About dialog; it repeats the disclaimer (§1)."""
959
+ dialog = self.about_dialog()
960
+ dialog.exec()
961
+ # Parented dialogs outlive exec(); release each one instead of
962
+ # accruing a child per menu click until the window closes.
963
+ dialog.deleteLater()
964
+
965
+ def help_guide_dialog(self) -> HelpGuideDialog:
966
+ """The how-to-use guide, bound to the shell's guide view model."""
967
+ return HelpGuideDialog(self._view_model.help_guide, self)
968
+
969
+ def show_help_guide(self) -> None:
970
+ """Show the how-to-use guide."""
971
+ dialog = self.help_guide_dialog()
972
+ dialog.exec()
973
+ dialog.deleteLater()
974
+
975
+
976
+ def prompt_disclaimer(view_model: DisclaimerViewModel) -> bool:
977
+ """Run the disclaimer dialog modally; True means the user accepted."""
978
+ dialog = DisclaimerDialog(view_model)
979
+ return dialog.exec() == QDialog.DialogCode.Accepted
980
+
981
+
982
+ __all__ = [
983
+ "AboutDialog",
984
+ "AboutViewModel",
985
+ "DisclaimerDialog",
986
+ "HelpGuideDialog",
987
+ "MainWindow",
988
+ "prompt_disclaimer",
989
+ ]