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,829 @@
1
+ """The projection chart widgets (§4.7, roadmap 8.4, 9.13, 9.14).
2
+
3
+ One sub-tab per chart, each pairing the drawn chart with its numbers
4
+ as a read-only table (9.26), bound to the app layer's
5
+ :class:`~glidepath.app.ChartsViewModel`; the money-basis radio toggle
6
+ and the run-mode control forward their selected option keys back, and
7
+ the Monte Carlo run action forwards the raw seed and path-count text.
8
+ Overlay bands (backtest trajectories, the fan's median) draw as line
9
+ series over any stacked bars, and the Monte Carlo fan chart's nested
10
+ inter-percentile fills draw as area series in the theme's single fan
11
+ hue at stepped alphas (9.24). Bars, overlay lines, and fills all
12
+ answer hover with app-layer tooltip copy over the exact ``Decimal``
13
+ amounts (9.23). The "When can I retire?" card (9.14) forwards
14
+ the raw replacement-rate and success-target text — plus the Monte
15
+ Carlo panel's seed and path text, its basis under that mode — and
16
+ renders the answer, detail, and message the view model carries; the
17
+ "How much can I draw down?" card (9.25) does the same with the raw
18
+ retirement-age text in place of the rate. All
19
+ copy, series labels, and the real/nominal presentation come from the
20
+ app layer — this pane only draws what the view model says (planning
21
+ §4.7).
22
+ """
23
+
24
+ from dataclasses import dataclass
25
+ from typing import TYPE_CHECKING
26
+
27
+ from PySide6.QtCharts import (
28
+ QAreaSeries,
29
+ QBarCategoryAxis,
30
+ QBarSet,
31
+ QChart,
32
+ QChartView,
33
+ QLegend,
34
+ QLineSeries,
35
+ QStackedBarSeries,
36
+ QValueAxis,
37
+ )
38
+ from PySide6.QtCore import QPointF, QRectF, QSize, QSizeF, Qt
39
+ from PySide6.QtGui import (
40
+ QBrush,
41
+ QColor,
42
+ QCursor,
43
+ QImage,
44
+ QPainter,
45
+ QPen,
46
+ QResizeEvent,
47
+ )
48
+ from PySide6.QtWidgets import (
49
+ QGroupBox,
50
+ QHBoxLayout,
51
+ QLabel,
52
+ QLineEdit,
53
+ QProgressBar,
54
+ QPushButton,
55
+ QRadioButton,
56
+ QScrollArea,
57
+ QSplitter,
58
+ QTabWidget,
59
+ QToolTip,
60
+ QVBoxLayout,
61
+ QWidget,
62
+ )
63
+
64
+ from glidepath.app import (
65
+ CHART_VIEW_LABEL,
66
+ TABLE_VIEW_LABEL,
67
+ bar_tooltip,
68
+ chart_table,
69
+ fill_tooltip,
70
+ )
71
+ from glidepath.gui.style import (
72
+ CHART_AXIS_LINE,
73
+ CHART_BAND_INKS,
74
+ CHART_FAN_ALPHAS,
75
+ CHART_FAN_FILL,
76
+ CHART_GRID,
77
+ CHART_LABEL_INK,
78
+ CHART_SERIES,
79
+ CHART_SURFACE,
80
+ CHART_TEXT_INK,
81
+ )
82
+ from glidepath.gui.tableview import fill_table, read_only_table
83
+
84
+ if TYPE_CHECKING:
85
+ from collections.abc import Callable
86
+
87
+ from glidepath.app import (
88
+ BacktestPanelViewModel,
89
+ ChartBand,
90
+ ChartFill,
91
+ ChartSeries,
92
+ ChartSpec,
93
+ ChartsViewModel,
94
+ DrawdownPanelViewModel,
95
+ MonteCarloPanelViewModel,
96
+ RetirementPanelViewModel,
97
+ )
98
+
99
+ _CATEGORY_LABEL_ANGLE = -90
100
+
101
+ _REPORT_CHART_SIZE = QSize(880, 460)
102
+
103
+ _BAR_WIDTH = 0.65
104
+ """Bars fill this share of each category slot — substantial marks
105
+ that still keep a clear gap between neighbouring years."""
106
+
107
+ _BAND_LINE_WIDTH = 2
108
+
109
+
110
+ @dataclass(frozen=True)
111
+ class ChartsPaneCallbacks:
112
+ """The shell handlers a :class:`ChartsPane` forwards actions to.
113
+
114
+ ``run_retirement`` receives the raw replacement-rate and
115
+ success-target text plus the Monte Carlo panel's raw seed and
116
+ path-count text (its basis under that run mode) — the shell
117
+ parses, the pane only captures (planning §4.7); ``run_drawdown``
118
+ is its dual (9.25), forwarding the raw retirement-age text
119
+ instead of the rate. ``run_backtest``
120
+ carries nothing — the run itself has no inputs (9.18) — and
121
+ ``select_backtest_year`` forwards the starting-year picker's raw
122
+ text, presentation state like the basis and mode selections.
123
+ """
124
+
125
+ select_basis: Callable[[str], None]
126
+ select_mode: Callable[[str], None]
127
+ run_monte_carlo: Callable[[str, str], None]
128
+ run_retirement: Callable[[str, str, str, str], None]
129
+ run_drawdown: Callable[[str, str, str, str], None]
130
+ run_backtest: Callable[[], None]
131
+ select_backtest_year: Callable[[str], None]
132
+
133
+
134
+ def _bar_hovered(
135
+ entry: ChartSeries, categories: tuple[str, ...], index: int, *, hovering: bool
136
+ ) -> None:
137
+ """Show or hide one bar segment's tooltip at the pointer."""
138
+ if hovering and 0 <= index < min(len(entry.values), len(categories)):
139
+ QToolTip.showText(
140
+ QCursor.pos(),
141
+ bar_tooltip(categories[index], entry.label, entry.values[index]),
142
+ )
143
+ else:
144
+ QToolTip.hideText()
145
+
146
+
147
+ def _snapped_index(x: float, count: int) -> int | None:
148
+ """The category index a hovered plot x falls on; ``None`` off-chart.
149
+
150
+ Line and area hovers deliver a plot-space point, not a category
151
+ index like the bar hovers — the nearest whole x is the period the
152
+ pointer is over (9.23).
153
+ """
154
+ index = round(x)
155
+ if 0 <= index < count:
156
+ return index
157
+ return None
158
+
159
+
160
+ def _band_hovered(
161
+ band: ChartBand, categories: tuple[str, ...], point: QPointF, *, hovering: bool
162
+ ) -> None:
163
+ """Show or hide one overlay line's tooltip at the pointer (9.23).
164
+
165
+ The copy carries the app layer's exact ``Decimal`` amount for the
166
+ snapped period, never the float plot coordinate.
167
+ """
168
+ index = _snapped_index(point.x(), min(len(band.values), len(categories)))
169
+ if hovering and index is not None:
170
+ QToolTip.showText(
171
+ QCursor.pos(),
172
+ bar_tooltip(categories[index], band.label, band.values[index]),
173
+ )
174
+ else:
175
+ QToolTip.hideText()
176
+
177
+
178
+ def _fill_hovered(
179
+ fill: ChartFill, categories: tuple[str, ...], point: QPointF, *, hovering: bool
180
+ ) -> None:
181
+ """Show or hide one fan fill's interval tooltip (9.23, 9.24)."""
182
+ count = min(len(fill.lower), len(fill.upper), len(categories))
183
+ index = _snapped_index(point.x(), count)
184
+ if hovering and index is not None:
185
+ QToolTip.showText(
186
+ QCursor.pos(),
187
+ fill_tooltip(
188
+ categories[index], fill.label, fill.lower[index], fill.upper[index]
189
+ ),
190
+ )
191
+ else:
192
+ QToolTip.hideText()
193
+
194
+
195
+ def tooltip_bar_set(
196
+ entry: ChartSeries, categories: tuple[str, ...], slot: int = 0
197
+ ) -> QBarSet:
198
+ """One series as a bar set with hover tooltips bound (§4.7).
199
+
200
+ The tooltip copy comes from the app layer's exact ``Decimal``
201
+ amounts, not the float plot coordinates. ``slot`` is the series'
202
+ position in the chart, picking its colour from the fixed-order
203
+ palette; the surface-coloured border keeps a hairline gap between
204
+ stacked segments so neighbours never read as one block.
205
+ """
206
+ bar_set = QBarSet(entry.label)
207
+ for value in entry.values:
208
+ bar_set.append(float(value))
209
+ bar_set.setColor(QColor(CHART_SERIES[slot % len(CHART_SERIES)]))
210
+ bar_set.setBorderColor(QColor(CHART_SURFACE))
211
+ bar_set.hovered.connect(
212
+ lambda status, index: _bar_hovered(entry, categories, index, hovering=status)
213
+ )
214
+ return bar_set
215
+
216
+
217
+ def _apply_chart_chrome(
218
+ qchart: QChart, x_axis: QBarCategoryAxis, y_axis: QValueAxis
219
+ ) -> None:
220
+ """Dress one chart in the theme's chrome (style module docstring).
221
+
222
+ Recessive horizontal-only gridlines, muted axis ink, legend text
223
+ in primary ink with circular series markers, and locale-formatted
224
+ axis numbers so five-figure balances read with grouping
225
+ separators.
226
+ """
227
+ qchart.setBackgroundBrush(QBrush(QColor(CHART_SURFACE)))
228
+ qchart.setBackgroundRoundness(0)
229
+ qchart.setLocalizeNumbers(True)
230
+ legend = qchart.legend()
231
+ legend.setMarkerShape(QLegend.MarkerShape.MarkerShapeCircle)
232
+ legend.setLabelColor(QColor(CHART_TEXT_INK))
233
+ x_axis.setGridLineVisible(False)
234
+ x_axis.setTruncateLabels(False)
235
+ y_axis.setLabelFormat("%.0f")
236
+ y_axis.setGridLinePen(QPen(QColor(CHART_GRID), 1))
237
+ for axis in (x_axis, y_axis):
238
+ axis.setLinePen(QPen(QColor(CHART_AXIS_LINE), 1))
239
+ axis.setLabelsColor(QColor(CHART_LABEL_INK))
240
+ axis.setTitleBrush(QBrush(QColor(CHART_LABEL_INK)))
241
+
242
+
243
+ def _fill_area(
244
+ fill: ChartFill, slot: int, categories: tuple[str, ...], qchart: QChart
245
+ ) -> QAreaSeries:
246
+ """One fan fill as an area series with its hover binding (9.24).
247
+
248
+ The nested fills share the theme's single fan hue; ``slot`` picks
249
+ the alpha step, outermost first, and the overlap deepens the
250
+ stack toward the median. The chart parents the bounding line
251
+ series so they live exactly as long as the area drawn between
252
+ them. Fills are added outermost first, so where they overlap the
253
+ hover lands on the innermost — the narrowest interval containing
254
+ the pointer.
255
+ """
256
+ lower = QLineSeries(qchart)
257
+ upper = QLineSeries(qchart)
258
+ for index, value in enumerate(fill.lower):
259
+ lower.append(float(index), float(value))
260
+ for index, value in enumerate(fill.upper):
261
+ upper.append(float(index), float(value))
262
+ area = QAreaSeries(upper, lower)
263
+ area.setName(fill.label)
264
+ colour = QColor(CHART_FAN_FILL)
265
+ colour.setAlpha(CHART_FAN_ALPHAS[slot % len(CHART_FAN_ALPHAS)])
266
+ area.setBrush(QBrush(colour))
267
+ area.setPen(QPen(Qt.PenStyle.NoPen))
268
+ area.hovered.connect(
269
+ lambda point, status, fill=fill: _fill_hovered(
270
+ fill, categories, point, hovering=status
271
+ )
272
+ )
273
+ return area
274
+
275
+
276
+ def chart_view(
277
+ chart: ChartSpec, categories: tuple[str, ...], parent: QWidget | None = None
278
+ ) -> QChartView:
279
+ """One chart bound to a spec: bars, fan fills, and overlay lines."""
280
+ series = QStackedBarSeries()
281
+ series.setBarWidth(_BAR_WIDTH)
282
+ for slot, entry in enumerate(chart.series):
283
+ series.append(tooltip_bar_set(entry, categories, slot))
284
+
285
+ qchart = QChart()
286
+ qchart.addSeries(series)
287
+
288
+ x_axis = QBarCategoryAxis()
289
+ x_axis.append(list(categories))
290
+ x_axis.setLabelsAngle(_CATEGORY_LABEL_ANGLE)
291
+ qchart.addAxis(x_axis, Qt.AlignmentFlag.AlignBottom)
292
+ series.attachAxis(x_axis)
293
+
294
+ y_axis = QValueAxis()
295
+ y_axis.setTitleText(chart.y_axis_label)
296
+ y_axis.setRange(0.0, float(chart.y_axis_max))
297
+ y_axis.applyNiceNumbers()
298
+ qchart.addAxis(y_axis, Qt.AlignmentFlag.AlignLeft)
299
+ series.attachAxis(y_axis)
300
+
301
+ for slot, fill in enumerate(chart.fills):
302
+ area = _fill_area(fill, slot, categories, qchart)
303
+ qchart.addSeries(area)
304
+ area.attachAxis(x_axis)
305
+ area.attachAxis(y_axis)
306
+
307
+ for slot, band in enumerate(chart.bands):
308
+ line = QLineSeries()
309
+ line.setName(band.label)
310
+ line.setPen(
311
+ QPen(QColor(CHART_BAND_INKS[slot % len(CHART_BAND_INKS)]), _BAND_LINE_WIDTH)
312
+ )
313
+ for index, value in enumerate(band.values):
314
+ line.append(float(index), float(value))
315
+ line.hovered.connect(
316
+ lambda point, status, band=band: _band_hovered(
317
+ band, categories, point, hovering=status
318
+ )
319
+ )
320
+ qchart.addSeries(line)
321
+ line.attachAxis(x_axis)
322
+ line.attachAxis(y_axis)
323
+
324
+ _apply_chart_chrome(qchart, x_axis, y_axis)
325
+ # A single unbanded, unfilled series needs no legend box — the
326
+ # sub-tab title already names it; identity-by-colour only starts
327
+ # at two entries.
328
+ if len(chart.series) + len(chart.bands) + len(chart.fills) == 1:
329
+ qchart.legend().setVisible(False)
330
+ view = QChartView(qchart, parent)
331
+ view.setRenderHint(QPainter.RenderHint.Antialiasing)
332
+ return view
333
+
334
+
335
+ def chart_tab(
336
+ chart: ChartSpec, categories: tuple[str, ...], parent: QWidget | None = None
337
+ ) -> QTabWidget:
338
+ """One chart sub-tab: the drawn chart and its table as inner pages.
339
+
340
+ The table page binds the app layer's :func:`chart_table` cells —
341
+ the same amounts the chart draws, pre-formatted — so every graph
342
+ is also readable as figures (roadmap 9.26).
343
+ """
344
+ tabs = QTabWidget(parent)
345
+ tabs.addTab(chart_view(chart, categories, tabs), CHART_VIEW_LABEL)
346
+ table = read_only_table(tabs)
347
+ spec = chart_table(chart, categories)
348
+ fill_table(table, spec.columns, list(spec.rows))
349
+ tabs.addTab(table, TABLE_VIEW_LABEL)
350
+ return tabs
351
+
352
+
353
+ def chart_image(chart: ChartSpec, categories: tuple[str, ...]) -> QImage:
354
+ """Rasterise one chart spec for the plan report (roadmap 9.19).
355
+
356
+ Renders the same chart the charts tab shows, offscreen at a fixed
357
+ report size. The view is never shown, so Qt only delivers its
358
+ resize on show — a widget render would draw the chart at its
359
+ default size in a corner of the image. Resizing the chart's
360
+ graphics widget directly lays it out synchronously, and the scene
361
+ render then draws the laid-out chart.
362
+ """
363
+ view = chart_view(chart, categories)
364
+ view.chart().resize(QSizeF(_REPORT_CHART_SIZE))
365
+ image = QImage(_REPORT_CHART_SIZE, QImage.Format.Format_ARGB32)
366
+ image.fill(Qt.GlobalColor.white)
367
+ painter = QPainter(image)
368
+ frame = QRectF(QPointF(0, 0), QSizeF(_REPORT_CHART_SIZE))
369
+ view.scene().render(painter, frame, frame)
370
+ painter.end()
371
+ view.deleteLater()
372
+ return image
373
+
374
+
375
+ class ChartsPane(QWidget):
376
+ """The charts tab: basis toggle, run-mode control, chart sub-tabs.
377
+
378
+ The question cards sit in a scrollable pane above the charts, the
379
+ two joined by a draggable vertical splitter that starts with most
380
+ of the height on the chart — the cards' natural height would
381
+ otherwise squash the chart into the remainder of the window.
382
+ """
383
+
384
+ def __init__(
385
+ self,
386
+ callbacks: ChartsPaneCallbacks,
387
+ parent: QWidget | None = None,
388
+ ) -> None:
389
+ """Build the pane and wire the shell callbacks."""
390
+ super().__init__(parent)
391
+ self._callbacks = callbacks
392
+ self._basis_buttons: dict[str, QRadioButton] = {}
393
+ self._mode_buttons: dict[str, QRadioButton] = {}
394
+
395
+ self._basis_box = QGroupBox(self)
396
+ self._basis_layout = QHBoxLayout(self._basis_box)
397
+
398
+ self._build_monte_carlo_box()
399
+ self._build_retirement_box()
400
+ self._build_drawdown_box()
401
+ self._build_backtest_box()
402
+ busy_row = self._build_busy_row()
403
+
404
+ self.allocation_label = QLabel("", self)
405
+ self.allocation_label.setWordWrap(True)
406
+
407
+ self.message_label = QLabel("", self)
408
+ self.message_label.setWordWrap(True)
409
+
410
+ self.chart_tabs = QTabWidget(self)
411
+
412
+ cards = QWidget(self)
413
+ cards_layout = QVBoxLayout(cards)
414
+ top_row = QHBoxLayout()
415
+ top_row.addWidget(self._basis_box)
416
+ top_row.addWidget(self._monte_carlo_box, 1)
417
+ cards_layout.addLayout(top_row)
418
+ cards_layout.addWidget(self._retirement_box)
419
+ cards_layout.addWidget(self._drawdown_box)
420
+ cards_layout.addWidget(self._backtest_box)
421
+ cards_layout.addStretch(1)
422
+
423
+ scroll = QScrollArea(self)
424
+ scroll.setWidgetResizable(True)
425
+ scroll.setWidget(cards)
426
+
427
+ charts = QWidget(self)
428
+ charts_layout = QVBoxLayout(charts)
429
+ charts_layout.setContentsMargins(0, 0, 0, 0)
430
+ charts_layout.addLayout(busy_row)
431
+ charts_layout.addWidget(self.allocation_label)
432
+ charts_layout.addWidget(self.message_label)
433
+ charts_layout.addWidget(self.chart_tabs, 1)
434
+
435
+ self._splitter = QSplitter(Qt.Orientation.Vertical, self)
436
+ self._splitter.addWidget(scroll)
437
+ self._splitter.addWidget(charts)
438
+ self._splitter.setStretchFactor(0, 0)
439
+ self._splitter.setStretchFactor(1, 1)
440
+ self._split_seeded = False
441
+
442
+ layout = QVBoxLayout(self)
443
+ layout.addWidget(self._splitter)
444
+
445
+ def resizeEvent(self, event: QResizeEvent) -> None: # noqa: N802
446
+ """Seed the splitter with the default split on first layout.
447
+
448
+ Sizes set before the pane has a height are discarded by the
449
+ splitter's own first layout, which falls back to the stretch
450
+ factors and squashes the cards to their minimum — so the
451
+ one-third/two-thirds default waits for the first resize that
452
+ delivers a real height. Later resizes leave the split alone,
453
+ preserving any position the user has dragged it to.
454
+ """
455
+ super().resizeEvent(event)
456
+ if not self._split_seeded and self._splitter.height() > 0:
457
+ self._split_seeded = True
458
+ third = self._splitter.height() // 3
459
+ self._splitter.setSizes([third, 2 * third])
460
+
461
+ def refresh(self, view_model: ChartsViewModel) -> None:
462
+ """Re-render the controls, message, and charts from the view model.
463
+
464
+ The selected sub-tab survives the rebuild, and so does each
465
+ sub-tab's chart-or-table page choice, so toggling the basis
466
+ or run mode re-presents exactly the view the user is looking
467
+ at.
468
+ """
469
+ self._basis_box.setTitle(view_model.basis_heading)
470
+ self._sync_basis_buttons(view_model)
471
+ self._sync_monte_carlo(view_model.monte_carlo)
472
+ self._sync_retirement(view_model.retirement)
473
+ self._sync_drawdown(view_model.drawdown)
474
+ self._sync_backtest(view_model.backtest)
475
+ self.allocation_label.setText(view_model.allocation_note)
476
+ self.allocation_label.setVisible(bool(view_model.allocation_note))
477
+ self.message_label.setText(view_model.message)
478
+ self.message_label.setVisible(bool(view_model.message))
479
+ self.chart_tabs.setVisible(bool(view_model.charts))
480
+ selected_index = self.chart_tabs.currentIndex()
481
+ page_choices: list[int] = []
482
+ while self.chart_tabs.count():
483
+ widget = self.chart_tabs.widget(0)
484
+ self.chart_tabs.removeTab(0)
485
+ if widget is not None:
486
+ if isinstance(widget, QTabWidget):
487
+ page_choices.append(widget.currentIndex())
488
+ widget.deleteLater()
489
+ for position, chart in enumerate(view_model.charts):
490
+ tab = chart_tab(chart, view_model.categories, self.chart_tabs)
491
+ if position < len(page_choices):
492
+ tab.setCurrentIndex(page_choices[position])
493
+ self.chart_tabs.addTab(tab, chart.title)
494
+ if 0 <= selected_index < self.chart_tabs.count():
495
+ self.chart_tabs.setCurrentIndex(selected_index)
496
+
497
+ def _build_busy_row(self) -> QHBoxLayout:
498
+ """Create the in-flight busy indicator's widgets (roadmap 9.16).
499
+
500
+ A zero-range progress bar animates indeterminately until a
501
+ range is set; both widgets start hidden until a run is launched.
502
+ """
503
+ self.busy_bar = QProgressBar(self)
504
+ self.busy_bar.setRange(0, 0)
505
+ self.busy_bar.setTextVisible(False)
506
+ self.busy_bar.setMaximumWidth(160)
507
+ self.busy_label = QLabel("", self)
508
+ self.busy_bar.setVisible(False)
509
+ self.busy_label.setVisible(False)
510
+ busy_row = QHBoxLayout()
511
+ busy_row.addWidget(self.busy_bar)
512
+ busy_row.addWidget(self.busy_label, 1)
513
+ return busy_row
514
+
515
+ def _build_monte_carlo_box(self) -> None:
516
+ """Create the run-mode control and Monte Carlo readout (9.13)."""
517
+ self._monte_carlo_box = QGroupBox(self)
518
+ monte_carlo_layout = QVBoxLayout(self._monte_carlo_box)
519
+ self._mode_layout = QHBoxLayout()
520
+ monte_carlo_layout.addLayout(self._mode_layout)
521
+ controls_row = QHBoxLayout()
522
+ self.seed_label = QLabel("", self._monte_carlo_box)
523
+ self.seed_edit = QLineEdit(self._monte_carlo_box)
524
+ self.paths_label = QLabel("", self._monte_carlo_box)
525
+ self.paths_edit = QLineEdit(self._monte_carlo_box)
526
+ self.run_button = QPushButton("", self._monte_carlo_box)
527
+ self.run_button.clicked.connect(self._run_clicked)
528
+ controls_row.addWidget(self.seed_label)
529
+ controls_row.addWidget(self.seed_edit)
530
+ controls_row.addWidget(self.paths_label)
531
+ controls_row.addWidget(self.paths_edit)
532
+ controls_row.addWidget(self.run_button)
533
+ controls_row.addStretch(1)
534
+ monte_carlo_layout.addLayout(controls_row)
535
+ self.metrics_label = QLabel("", self._monte_carlo_box)
536
+ self.metrics_label.setWordWrap(True)
537
+ monte_carlo_layout.addWidget(self.metrics_label)
538
+ self.monte_carlo_message_label = QLabel("", self._monte_carlo_box)
539
+ self.monte_carlo_message_label.setWordWrap(True)
540
+ monte_carlo_layout.addWidget(self.monte_carlo_message_label)
541
+
542
+ def _build_retirement_box(self) -> None:
543
+ """Create the "When can I retire?" card's widgets (9.14)."""
544
+ self._retirement_box = QGroupBox(self)
545
+ retirement_layout = QVBoxLayout(self._retirement_box)
546
+ retirement_controls = QHBoxLayout()
547
+ self.rate_label = QLabel("", self._retirement_box)
548
+ self.rate_edit = QLineEdit(self._retirement_box)
549
+ self.success_label = QLabel("", self._retirement_box)
550
+ self.success_edit = QLineEdit(self._retirement_box)
551
+ self.retirement_button = QPushButton("", self._retirement_box)
552
+ self.retirement_button.clicked.connect(self._retirement_clicked)
553
+ retirement_controls.addWidget(self.rate_label)
554
+ retirement_controls.addWidget(self.rate_edit)
555
+ retirement_controls.addWidget(self.success_label)
556
+ retirement_controls.addWidget(self.success_edit)
557
+ retirement_controls.addWidget(self.retirement_button)
558
+ retirement_controls.addStretch(1)
559
+ retirement_layout.addLayout(retirement_controls)
560
+ self.retirement_answer_label = QLabel("", self._retirement_box)
561
+ self.retirement_answer_label.setObjectName("answerLabel")
562
+ self.retirement_answer_label.setWordWrap(True)
563
+ retirement_layout.addWidget(self.retirement_answer_label)
564
+ self.retirement_detail_label = QLabel("", self._retirement_box)
565
+ self.retirement_detail_label.setWordWrap(True)
566
+ retirement_layout.addWidget(self.retirement_detail_label)
567
+ self.retirement_message_label = QLabel("", self._retirement_box)
568
+ self.retirement_message_label.setWordWrap(True)
569
+ retirement_layout.addWidget(self.retirement_message_label)
570
+
571
+ def _build_drawdown_box(self) -> None:
572
+ """Create the "How much can I draw down?" card's widgets (9.25)."""
573
+ self._drawdown_box = QGroupBox(self)
574
+ drawdown_layout = QVBoxLayout(self._drawdown_box)
575
+ drawdown_controls = QHBoxLayout()
576
+ self.drawdown_age_label = QLabel("", self._drawdown_box)
577
+ self.drawdown_age_edit = QLineEdit(self._drawdown_box)
578
+ self.drawdown_success_label = QLabel("", self._drawdown_box)
579
+ self.drawdown_success_edit = QLineEdit(self._drawdown_box)
580
+ self.drawdown_button = QPushButton("", self._drawdown_box)
581
+ self.drawdown_button.clicked.connect(self._drawdown_clicked)
582
+ drawdown_controls.addWidget(self.drawdown_age_label)
583
+ drawdown_controls.addWidget(self.drawdown_age_edit)
584
+ drawdown_controls.addWidget(self.drawdown_success_label)
585
+ drawdown_controls.addWidget(self.drawdown_success_edit)
586
+ drawdown_controls.addWidget(self.drawdown_button)
587
+ drawdown_controls.addStretch(1)
588
+ drawdown_layout.addLayout(drawdown_controls)
589
+ self.drawdown_answer_label = QLabel("", self._drawdown_box)
590
+ self.drawdown_answer_label.setObjectName("answerLabel")
591
+ self.drawdown_answer_label.setWordWrap(True)
592
+ drawdown_layout.addWidget(self.drawdown_answer_label)
593
+ self.drawdown_detail_label = QLabel("", self._drawdown_box)
594
+ self.drawdown_detail_label.setWordWrap(True)
595
+ drawdown_layout.addWidget(self.drawdown_detail_label)
596
+ self.drawdown_message_label = QLabel("", self._drawdown_box)
597
+ self.drawdown_message_label.setWordWrap(True)
598
+ drawdown_layout.addWidget(self.drawdown_message_label)
599
+
600
+ def _build_backtest_box(self) -> None:
601
+ """Create the historical-backtest card's widgets (9.18)."""
602
+ self._backtest_box = QGroupBox(self)
603
+ backtest_layout = QVBoxLayout(self._backtest_box)
604
+ backtest_controls = QHBoxLayout()
605
+ self.backtest_button = QPushButton("", self._backtest_box)
606
+ self.backtest_button.clicked.connect(self._backtest_clicked)
607
+ backtest_controls.addWidget(self.backtest_button)
608
+ self.backtest_year_label = QLabel("", self._backtest_box)
609
+ self.backtest_year_edit = QLineEdit(self._backtest_box)
610
+ self.backtest_year_edit.setMaximumWidth(80)
611
+ self.backtest_year_edit.editingFinished.connect(self._backtest_year_changed)
612
+ backtest_controls.addWidget(self.backtest_year_label)
613
+ backtest_controls.addWidget(self.backtest_year_edit)
614
+ backtest_controls.addStretch(1)
615
+ backtest_layout.addLayout(backtest_controls)
616
+ self.backtest_year_message_label = QLabel("", self._backtest_box)
617
+ self.backtest_year_message_label.setWordWrap(True)
618
+ backtest_layout.addWidget(self.backtest_year_message_label)
619
+ self.backtest_metrics_label = QLabel("", self._backtest_box)
620
+ self.backtest_metrics_label.setWordWrap(True)
621
+ backtest_layout.addWidget(self.backtest_metrics_label)
622
+ self.backtest_message_label = QLabel("", self._backtest_box)
623
+ self.backtest_message_label.setWordWrap(True)
624
+ backtest_layout.addWidget(self.backtest_message_label)
625
+
626
+ def _run_clicked(self) -> None:
627
+ """Forward the raw seed and path-count text to the shell."""
628
+ self._callbacks.run_monte_carlo(self.seed_edit.text(), self.paths_edit.text())
629
+
630
+ def _backtest_clicked(self) -> None:
631
+ """Ask the shell to run the historical backtest (9.18)."""
632
+ self._callbacks.run_backtest()
633
+
634
+ def _backtest_year_changed(self) -> None:
635
+ """Forward the starting-year picker's raw text to the shell."""
636
+ self._callbacks.select_backtest_year(self.backtest_year_edit.text())
637
+
638
+ def _retirement_clicked(self) -> None:
639
+ """Forward the card's raw text to the shell (roadmap 9.14).
640
+
641
+ The Monte Carlo panel's seed and path text ride along: they
642
+ are the search's basis when the Monte Carlo mode is selected.
643
+ """
644
+ self._callbacks.run_retirement(
645
+ self.rate_edit.text(),
646
+ self.success_edit.text(),
647
+ self.seed_edit.text(),
648
+ self.paths_edit.text(),
649
+ )
650
+
651
+ def _drawdown_clicked(self) -> None:
652
+ """Forward the card's raw text to the shell (roadmap 9.25).
653
+
654
+ The Monte Carlo panel's seed and path text ride along: they
655
+ are the search's basis when the Monte Carlo mode is selected.
656
+ """
657
+ self._callbacks.run_drawdown(
658
+ self.drawdown_age_edit.text(),
659
+ self.drawdown_success_edit.text(),
660
+ self.seed_edit.text(),
661
+ self.paths_edit.text(),
662
+ )
663
+
664
+ def set_monte_carlo_busy(self, *, busy: bool) -> None:
665
+ """Disable the run action while a Monte Carlo run is in flight.
666
+
667
+ The run happens off the GUI thread (the window owns the
668
+ worker); disabling the button prevents a second overlapping
669
+ run from the same inputs.
670
+ """
671
+ self.run_button.setEnabled(not busy)
672
+
673
+ def set_retirement_busy(self, *, busy: bool) -> None:
674
+ """Disable the card's action while a search is in flight (9.14).
675
+
676
+ Same rationale as :meth:`set_monte_carlo_busy`: the search
677
+ runs off the GUI thread and must not overlap itself.
678
+ """
679
+ self.retirement_button.setEnabled(not busy)
680
+
681
+ def set_drawdown_busy(self, *, busy: bool) -> None:
682
+ """Disable the card's action while a search is in flight (9.25).
683
+
684
+ Same rationale as :meth:`set_monte_carlo_busy`: the search
685
+ runs off the GUI thread and must not overlap itself.
686
+ """
687
+ self.drawdown_button.setEnabled(not busy)
688
+
689
+ def set_backtest_busy(self, *, busy: bool) -> None:
690
+ """Disable the card's action while a backtest is in flight (9.18).
691
+
692
+ Same rationale as :meth:`set_monte_carlo_busy`: the windows
693
+ run off the GUI thread and must not overlap themselves.
694
+ """
695
+ self.backtest_button.setEnabled(not busy)
696
+
697
+ def show_busy(self, status: str) -> None:
698
+ """Show the busy animation with a status line naming the run (9.16).
699
+
700
+ Visible from run start until :meth:`clear_busy` — disabled
701
+ buttons alone are easy to miss, and a minutes-long retirement
702
+ search would otherwise look like a hang. ``status`` comes from
703
+ the app layer like all copy (planning §4.7).
704
+ """
705
+ self.busy_label.setText(status)
706
+ self.busy_bar.setVisible(True)
707
+ self.busy_label.setVisible(True)
708
+
709
+ def clear_busy(self) -> None:
710
+ """Hide the busy animation and its status line (9.16).
711
+
712
+ Called on completion, failure, and the staleness-discard path
713
+ alike — a discarded run must never leave a spinner running.
714
+ """
715
+ self.busy_bar.setVisible(False)
716
+ self.busy_label.setVisible(False)
717
+ self.busy_label.setText("")
718
+
719
+ def _sync_basis_buttons(self, view_model: ChartsViewModel) -> None:
720
+ """Create the basis radio buttons once; keep the selection bound."""
721
+ for option in view_model.basis_options:
722
+ button = self._basis_buttons.get(option.key)
723
+ if button is None:
724
+ button = QRadioButton(option.label, self._basis_box)
725
+ button.clicked.connect(
726
+ lambda _checked=False, key=option.key: self._callbacks.select_basis(
727
+ key
728
+ )
729
+ )
730
+ self._basis_layout.addWidget(button)
731
+ self._basis_buttons[option.key] = button
732
+ button.setText(option.label)
733
+ button.setChecked(option.key == view_model.selected_basis_key)
734
+
735
+ def _sync_monte_carlo(self, panel: MonteCarloPanelViewModel) -> None:
736
+ """Re-render the run-mode control and Monte Carlo readout (9.13)."""
737
+ self._monte_carlo_box.setTitle(panel.heading)
738
+ for option in panel.mode_options:
739
+ button = self._mode_buttons.get(option.key)
740
+ if button is None:
741
+ button = QRadioButton(option.label, self._monte_carlo_box)
742
+ button.clicked.connect(
743
+ lambda _checked=False, key=option.key: self._callbacks.select_mode(
744
+ key
745
+ )
746
+ )
747
+ self._mode_layout.addWidget(button)
748
+ self._mode_buttons[option.key] = button
749
+ button.setText(option.label)
750
+ button.setChecked(option.key == panel.selected_mode_key)
751
+ self.seed_label.setText(panel.seed_label)
752
+ self.seed_edit.setText(panel.seed_value)
753
+ self.paths_label.setText(panel.paths_label)
754
+ self.paths_edit.setText(panel.paths_value)
755
+ self.run_button.setText(panel.run_label)
756
+ metrics = "\n".join(f"{row.label}: {row.value}" for row in panel.metrics)
757
+ self.metrics_label.setText(metrics)
758
+ self.monte_carlo_message_label.setText(panel.message)
759
+ for widget in (
760
+ self.seed_label,
761
+ self.seed_edit,
762
+ self.paths_label,
763
+ self.paths_edit,
764
+ self.run_button,
765
+ ):
766
+ widget.setVisible(panel.controls_visible)
767
+ self.metrics_label.setVisible(bool(metrics))
768
+ self.monte_carlo_message_label.setVisible(bool(panel.message))
769
+
770
+ def _sync_backtest(self, panel: BacktestPanelViewModel) -> None:
771
+ """Re-render the historical-backtest card (roadmap 9.18)."""
772
+ self._backtest_box.setTitle(panel.heading)
773
+ self.backtest_button.setText(panel.run_label)
774
+ self.backtest_year_label.setText(panel.year_label)
775
+ self.backtest_year_edit.setText(panel.year_value)
776
+ self.backtest_year_edit.setPlaceholderText(panel.year_placeholder)
777
+ self.backtest_year_label.setToolTip(panel.year_tooltip)
778
+ self.backtest_year_edit.setToolTip(panel.year_tooltip)
779
+ self.backtest_year_message_label.setText(panel.year_message)
780
+ self.backtest_year_message_label.setVisible(bool(panel.year_message))
781
+ metrics = "\n".join(f"{row.label}: {row.value}" for row in panel.metrics)
782
+ self.backtest_metrics_label.setText(metrics)
783
+ self.backtest_metrics_label.setVisible(bool(metrics))
784
+ self.backtest_message_label.setText(panel.message)
785
+ self.backtest_message_label.setVisible(bool(panel.message))
786
+
787
+ def _sync_retirement(self, panel: RetirementPanelViewModel) -> None:
788
+ """Re-render the "When can I retire?" card (roadmap 9.14)."""
789
+ self._retirement_box.setTitle(panel.heading)
790
+ self.rate_label.setText(panel.rate_label)
791
+ self.rate_edit.setText(panel.rate_value)
792
+ self.success_label.setText(panel.success_label)
793
+ self.success_edit.setText(panel.success_value)
794
+ self.retirement_button.setText(panel.run_label)
795
+ self.success_label.setVisible(panel.success_visible)
796
+ self.success_edit.setVisible(panel.success_visible)
797
+ self.retirement_answer_label.setText(panel.answer)
798
+ self.retirement_answer_label.setVisible(bool(panel.answer))
799
+ self.retirement_detail_label.setText(panel.detail)
800
+ self.retirement_detail_label.setVisible(bool(panel.detail))
801
+ self.retirement_message_label.setText(panel.message)
802
+ self.retirement_message_label.setVisible(bool(panel.message))
803
+
804
+ def _sync_drawdown(self, panel: DrawdownPanelViewModel) -> None:
805
+ """Re-render the "How much can I draw down?" card (9.25)."""
806
+ self._drawdown_box.setTitle(panel.heading)
807
+ self.drawdown_age_label.setText(panel.age_label)
808
+ self.drawdown_age_edit.setText(panel.age_value)
809
+ self.drawdown_success_label.setText(panel.success_label)
810
+ self.drawdown_success_edit.setText(panel.success_value)
811
+ self.drawdown_button.setText(panel.run_label)
812
+ self.drawdown_success_label.setVisible(panel.success_visible)
813
+ self.drawdown_success_edit.setVisible(panel.success_visible)
814
+ self.drawdown_answer_label.setText(panel.answer)
815
+ self.drawdown_answer_label.setVisible(bool(panel.answer))
816
+ self.drawdown_detail_label.setText(panel.detail)
817
+ self.drawdown_detail_label.setVisible(bool(panel.detail))
818
+ self.drawdown_message_label.setText(panel.message)
819
+ self.drawdown_message_label.setVisible(bool(panel.message))
820
+
821
+
822
+ __all__ = [
823
+ "ChartsPane",
824
+ "ChartsPaneCallbacks",
825
+ "chart_image",
826
+ "chart_tab",
827
+ "chart_view",
828
+ "tooltip_bar_set",
829
+ ]