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,402 @@
1
+ """The scenario manager widgets (§4.7, roadmap 8.5).
2
+
3
+ Scenario list, per-scenario override table (a scenario's overrides
4
+ *are* its diff, planning §4.3), and the comparison report as a table
5
+ and grouped bar chart. Thin by policy: every heading, prompt, status
6
+ line, and pre-formatted cell comes from the app layer's
7
+ :class:`~glidepath.app.ScenariosViewModel`; this pane binds them to
8
+ widgets and forwards raw user input back through its callbacks.
9
+ """
10
+
11
+ from dataclasses import dataclass
12
+ from typing import TYPE_CHECKING
13
+
14
+ from PySide6.QtCharts import (
15
+ QBarCategoryAxis,
16
+ QBarSeries,
17
+ QChart,
18
+ QChartView,
19
+ QValueAxis,
20
+ )
21
+ from PySide6.QtCore import QSignalBlocker, Qt
22
+ from PySide6.QtGui import QPainter
23
+ from PySide6.QtWidgets import (
24
+ QComboBox,
25
+ QGroupBox,
26
+ QHBoxLayout,
27
+ QInputDialog,
28
+ QLabel,
29
+ QListWidget,
30
+ QPushButton,
31
+ QRadioButton,
32
+ QTabWidget,
33
+ QVBoxLayout,
34
+ QWidget,
35
+ )
36
+
37
+ from glidepath.gui.charts import tooltip_bar_set
38
+ from glidepath.gui.tableview import fill_table, read_only_table
39
+
40
+ if TYPE_CHECKING:
41
+ from collections.abc import Callable
42
+
43
+ from glidepath.app import ChartSpec, OverrideRow, ScenarioItem, ScenariosViewModel
44
+
45
+ _CATEGORY_LABEL_ANGLE = -90
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class ScenariosPaneCallbacks:
50
+ """The shell handlers a :class:`ScenariosPane` forwards actions to.
51
+
52
+ Handlers returning ``str | None`` report a rejection message for
53
+ the status line; the rest cannot fail.
54
+ """
55
+
56
+ add_scenario: Callable[[str], str | None]
57
+ remove_scenario: Callable[[str], None]
58
+ set_override: Callable[[str, str, str], str | None]
59
+ remove_override: Callable[[str, str], None]
60
+ select_basis: Callable[[str], None]
61
+ select_metric: Callable[[str], None]
62
+
63
+
64
+ class ScenariosPane(QWidget):
65
+ """The scenarios tab: manager, override diff list, and comparison."""
66
+
67
+ def __init__(
68
+ self, callbacks: ScenariosPaneCallbacks, parent: QWidget | None = None
69
+ ) -> None:
70
+ """Build the manager and comparison surfaces and wire callbacks."""
71
+ super().__init__(parent)
72
+ self._callbacks = callbacks
73
+ self._view_model: ScenariosViewModel | None = None
74
+ self._basis_buttons: dict[str, QRadioButton] = {}
75
+ self._override_rows: tuple[OverrideRow, ...] = ()
76
+
77
+ manager_row = QHBoxLayout()
78
+ manager_row.addWidget(self._built_scenarios_box(), 1)
79
+ manager_row.addWidget(self._built_overrides_box(), 2)
80
+
81
+ self.message_label = QLabel("", self)
82
+ self.message_label.setWordWrap(True)
83
+ self.status_label = QLabel("", self)
84
+ self.status_label.setWordWrap(True)
85
+
86
+ layout = QVBoxLayout(self)
87
+ layout.addLayout(manager_row, 1)
88
+ layout.addWidget(self._built_comparison_box(), 1)
89
+ layout.addWidget(self.message_label)
90
+ layout.addWidget(self.status_label)
91
+
92
+ def _built_scenarios_box(self) -> QGroupBox:
93
+ """The scenario list with its add/remove buttons."""
94
+ self._scenarios_box = QGroupBox(self)
95
+ self.scenario_list = QListWidget(self._scenarios_box)
96
+ self.scenario_list.currentTextChanged.connect(self._render_selected_scenario)
97
+ self.add_scenario_button = QPushButton("", self._scenarios_box)
98
+ self.add_scenario_button.clicked.connect(self._add_scenario)
99
+ self.remove_scenario_button = QPushButton("", self._scenarios_box)
100
+ self.remove_scenario_button.clicked.connect(self._remove_scenario)
101
+ buttons = QHBoxLayout()
102
+ buttons.addWidget(self.add_scenario_button)
103
+ buttons.addWidget(self.remove_scenario_button)
104
+ layout = QVBoxLayout(self._scenarios_box)
105
+ layout.addWidget(self.scenario_list)
106
+ layout.addLayout(buttons)
107
+ return self._scenarios_box
108
+
109
+ def _built_overrides_box(self) -> QGroupBox:
110
+ """The selected scenario's override table and its buttons."""
111
+ self._overrides_box = QGroupBox(self)
112
+ self.scenario_status_label = QLabel("", self._overrides_box)
113
+ self.scenario_status_label.setWordWrap(True)
114
+ self.overrides_table = read_only_table(self._overrides_box)
115
+ self.overrides_table.cellDoubleClicked.connect(self._edit_override)
116
+ self.add_override_button = QPushButton("", self._overrides_box)
117
+ self.add_override_button.clicked.connect(self._add_override)
118
+ self.remove_override_button = QPushButton("", self._overrides_box)
119
+ self.remove_override_button.clicked.connect(self._remove_override)
120
+ buttons = QHBoxLayout()
121
+ buttons.addWidget(self.add_override_button)
122
+ buttons.addWidget(self.remove_override_button)
123
+ layout = QVBoxLayout(self._overrides_box)
124
+ layout.addWidget(self.scenario_status_label)
125
+ layout.addWidget(self.overrides_table)
126
+ layout.addLayout(buttons)
127
+ return self._overrides_box
128
+
129
+ def _built_comparison_box(self) -> QGroupBox:
130
+ """The comparison report: basis toggle, metric picker, table, chart."""
131
+ self._comparison_box = QGroupBox(self)
132
+ self._basis_box = QGroupBox(self._comparison_box)
133
+ self._basis_layout = QHBoxLayout(self._basis_box)
134
+ self.metric_label = QLabel("", self._comparison_box)
135
+ self.metric_combo = QComboBox(self._comparison_box)
136
+ self.metric_combo.activated.connect(self._metric_selected)
137
+ controls = QHBoxLayout()
138
+ controls.addWidget(self._basis_box)
139
+ controls.addWidget(self.metric_label)
140
+ controls.addWidget(self.metric_combo, 1)
141
+ self.comparison_tabs = QTabWidget(self._comparison_box)
142
+ self.comparison_table = read_only_table(self.comparison_tabs)
143
+ self._chart_container = QWidget(self.comparison_tabs)
144
+ self._chart_layout = QVBoxLayout(self._chart_container)
145
+ self.comparison_tabs.addTab(self.comparison_table, "")
146
+ self.comparison_tabs.addTab(self._chart_container, "")
147
+ layout = QVBoxLayout(self._comparison_box)
148
+ layout.addLayout(controls)
149
+ layout.addWidget(self.comparison_tabs, 1)
150
+ return self._comparison_box
151
+
152
+ def refresh(self, view_model: ScenariosViewModel) -> None:
153
+ """Re-render the manager and comparison from a fresh view model.
154
+
155
+ The selected scenario and comparison sub-tab survive the
156
+ rebuild, so edits keep the user where they were.
157
+ """
158
+ self._view_model = view_model
159
+ self._refresh_manager(view_model)
160
+ self._refresh_comparison(view_model)
161
+ self.message_label.setText(view_model.message)
162
+ self.message_label.setVisible(bool(view_model.message))
163
+
164
+ def selected_scenario_name(self) -> str:
165
+ """The name of the scenario selected in the list, or blank."""
166
+ item = self.scenario_list.currentItem()
167
+ return item.text() if item is not None else ""
168
+
169
+ def _refresh_manager(self, view_model: ScenariosViewModel) -> None:
170
+ """Re-render the scenario list and the selected override table."""
171
+ self._scenarios_box.setTitle(view_model.scenarios_heading)
172
+ self.add_scenario_button.setText(view_model.add_scenario_label)
173
+ self.remove_scenario_button.setText(view_model.remove_scenario_label)
174
+ self._overrides_box.setTitle(view_model.overrides_heading)
175
+ self.add_override_button.setText(view_model.add_override_label)
176
+ self.remove_override_button.setText(view_model.remove_override_label)
177
+ selected = self.selected_scenario_name()
178
+ with QSignalBlocker(self.scenario_list):
179
+ self.scenario_list.clear()
180
+ self.scenario_list.addItems([item.name for item in view_model.scenarios])
181
+ names = [item.name for item in view_model.scenarios]
182
+ if names:
183
+ row = names.index(selected) if selected in names else 0
184
+ self.scenario_list.setCurrentRow(row)
185
+ self._render_selected_scenario()
186
+
187
+ def _selected_item(self) -> ScenarioItem | None:
188
+ """The view-model entry behind the selected scenario, if any."""
189
+ if self._view_model is None:
190
+ return None
191
+ name = self.selected_scenario_name()
192
+ for item in self._view_model.scenarios:
193
+ if item.name == name:
194
+ return item
195
+ return None
196
+
197
+ def _render_selected_scenario(self, _text: str = "") -> None:
198
+ """Fill the override table for the scenario selected in the list."""
199
+ if self._view_model is None:
200
+ return
201
+ item = self._selected_item()
202
+ self._override_rows = item.overrides if item is not None else ()
203
+ self.scenario_status_label.setText(item.status if item is not None else "")
204
+ fill_table(
205
+ self.overrides_table,
206
+ self._view_model.overrides_columns,
207
+ [
208
+ (row.target_label, row.value, row.note, row.status)
209
+ for row in self._override_rows
210
+ ],
211
+ )
212
+
213
+ def _refresh_comparison(self, view_model: ScenariosViewModel) -> None:
214
+ """Re-render the basis toggle, metric picker, table, and chart."""
215
+ self._comparison_box.setTitle(view_model.comparison_heading)
216
+ self._basis_box.setTitle(view_model.basis_heading)
217
+ self._sync_basis_buttons(view_model)
218
+ self._sync_metric_combo(view_model)
219
+ self.comparison_tabs.setTabText(0, view_model.comparison_table_label)
220
+ self.comparison_tabs.setTabText(1, view_model.comparison_chart_label)
221
+ fill_table(
222
+ self.comparison_table,
223
+ view_model.comparison_columns,
224
+ list(view_model.comparison_rows),
225
+ )
226
+ while self._chart_layout.count():
227
+ entry = self._chart_layout.takeAt(0)
228
+ widget = entry.widget() if entry is not None else None
229
+ if widget is not None:
230
+ widget.deleteLater()
231
+ if view_model.chart is not None:
232
+ self._chart_layout.addWidget(
233
+ self._chart_view(view_model.chart, view_model.chart_categories)
234
+ )
235
+ self.comparison_tabs.setVisible(view_model.chart is not None)
236
+
237
+ def _sync_basis_buttons(self, view_model: ScenariosViewModel) -> None:
238
+ """Create the basis radio buttons once; keep the selection bound."""
239
+ for option in view_model.basis_options:
240
+ button = self._basis_buttons.get(option.key)
241
+ if button is None:
242
+ button = QRadioButton(option.label, self._basis_box)
243
+ button.clicked.connect(
244
+ lambda _checked=False, key=option.key: self._callbacks.select_basis(
245
+ key
246
+ )
247
+ )
248
+ self._basis_layout.addWidget(button)
249
+ self._basis_buttons[option.key] = button
250
+ button.setText(option.label)
251
+ button.setChecked(option.key == view_model.selected_basis_key)
252
+
253
+ def _sync_metric_combo(self, view_model: ScenariosViewModel) -> None:
254
+ """Rebuild the metric picker; keep the selection bound."""
255
+ self.metric_label.setText(view_model.metric_heading)
256
+ with QSignalBlocker(self.metric_combo):
257
+ self.metric_combo.clear()
258
+ for option in view_model.metric_options:
259
+ self.metric_combo.addItem(option.label, userData=option.key)
260
+ keys = [option.key for option in view_model.metric_options]
261
+ if view_model.selected_metric_key in keys:
262
+ self.metric_combo.setCurrentIndex(
263
+ keys.index(view_model.selected_metric_key)
264
+ )
265
+
266
+ def _metric_selected(self, index: int) -> None:
267
+ """Forward a user-chosen metric's key to the shell."""
268
+ key = self.metric_combo.itemData(index)
269
+ if key is not None:
270
+ self._callbacks.select_metric(str(key))
271
+
272
+ def _add_scenario(self) -> None:
273
+ """Prompt for a scenario name and forward it to the shell."""
274
+ if self._view_model is None: # pragma: no cover - buttons imply a model
275
+ return
276
+ text, accepted = QInputDialog.getText(
277
+ self, self._view_model.name_prompt_title, self._view_model.name_prompt
278
+ )
279
+ if not accepted:
280
+ return
281
+ error = self._callbacks.add_scenario(text)
282
+ self.status_label.setText(error or "")
283
+
284
+ def _remove_scenario(self) -> None:
285
+ """Remove the selected scenario."""
286
+ name = self.selected_scenario_name()
287
+ if name:
288
+ self._callbacks.remove_scenario(name)
289
+ self.status_label.setText("")
290
+
291
+ def _add_override(self) -> None:
292
+ """Pick a target from the whitelist, prompt for a value, forward."""
293
+ view_model = self._view_model
294
+ scenario = self.selected_scenario_name()
295
+ if view_model is None or not scenario or not view_model.target_options:
296
+ return
297
+ labels = [option.label for option in view_model.target_options]
298
+ label, accepted = QInputDialog.getItem(
299
+ self,
300
+ view_model.target_prompt_title,
301
+ view_model.target_prompt,
302
+ labels,
303
+ 0,
304
+ editable=False,
305
+ )
306
+ if not accepted:
307
+ return
308
+ option = view_model.target_options[labels.index(label)]
309
+ text, accepted = self._value_prompt(
310
+ option.edit_text, structured=option.structured, choices=option.choices
311
+ )
312
+ if not accepted:
313
+ return
314
+ error = self._callbacks.set_override(scenario, option.key, text)
315
+ self.status_label.setText(error or "")
316
+
317
+ def _edit_override(self, row_index: int, _column: int) -> None:
318
+ """Re-prompt the value of a double-clicked override row."""
319
+ scenario = self.selected_scenario_name()
320
+ if not scenario or row_index >= len(self._override_rows):
321
+ return
322
+ row = self._override_rows[row_index]
323
+ text, accepted = self._value_prompt(
324
+ row.edit_text, structured=row.structured, choices=row.choices
325
+ )
326
+ if not accepted:
327
+ return
328
+ error = self._callbacks.set_override(scenario, row.target_key, text)
329
+ self.status_label.setText(error or "")
330
+
331
+ def _remove_override(self) -> None:
332
+ """Remove the override behind the selected table row."""
333
+ scenario = self.selected_scenario_name()
334
+ row_index = self.overrides_table.currentRow()
335
+ if not scenario or not 0 <= row_index < len(self._override_rows):
336
+ return
337
+ row = self._override_rows[row_index]
338
+ self._callbacks.remove_override(scenario, row.target_key)
339
+ self.status_label.setText("")
340
+
341
+ def _value_prompt(
342
+ self, edit_text: str, *, structured: bool, choices: tuple[str, ...]
343
+ ) -> tuple[str, bool]:
344
+ """The value dialog a target's shape calls for; text and accepted."""
345
+ view_model = self._view_model
346
+ if view_model is None: # pragma: no cover - prompts imply a model
347
+ return "", False
348
+ if choices:
349
+ current = choices.index(edit_text) if edit_text in choices else 0
350
+ return QInputDialog.getItem(
351
+ self,
352
+ view_model.value_prompt_title,
353
+ view_model.value_prompt,
354
+ list(choices),
355
+ current,
356
+ editable=False,
357
+ )
358
+ if structured:
359
+ return QInputDialog.getMultiLineText(
360
+ self,
361
+ view_model.value_prompt_title,
362
+ view_model.table_value_prompt,
363
+ edit_text,
364
+ )
365
+ return QInputDialog.getText(
366
+ self,
367
+ view_model.value_prompt_title,
368
+ view_model.value_prompt,
369
+ text=edit_text,
370
+ )
371
+
372
+ def _chart_view(self, chart: ChartSpec, categories: tuple[str, ...]) -> QChartView:
373
+ """One grouped bar chart bound to a chart spec — runs side by side."""
374
+ series = QBarSeries()
375
+ for entry in chart.series:
376
+ series.append(tooltip_bar_set(entry, categories))
377
+
378
+ qchart = QChart()
379
+ qchart.addSeries(series)
380
+
381
+ x_axis = QBarCategoryAxis()
382
+ x_axis.append(list(categories))
383
+ x_axis.setLabelsAngle(_CATEGORY_LABEL_ANGLE)
384
+ qchart.addAxis(x_axis, Qt.AlignmentFlag.AlignBottom)
385
+ series.attachAxis(x_axis)
386
+
387
+ y_axis = QValueAxis()
388
+ y_axis.setTitleText(chart.y_axis_label)
389
+ y_axis.setRange(0.0, float(chart.y_axis_max))
390
+ y_axis.applyNiceNumbers()
391
+ qchart.addAxis(y_axis, Qt.AlignmentFlag.AlignLeft)
392
+ series.attachAxis(y_axis)
393
+
394
+ view = QChartView(qchart, self._chart_container)
395
+ view.setRenderHint(QPainter.RenderHint.Antialiasing)
396
+ return view
397
+
398
+
399
+ __all__ = [
400
+ "ScenariosPane",
401
+ "ScenariosPaneCallbacks",
402
+ ]