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.
- glidepath/__init__.py +3 -0
- glidepath/app/__init__.py +364 -0
- glidepath/app/backtest.py +281 -0
- glidepath/app/charts.py +759 -0
- glidepath/app/copy.py +174 -0
- glidepath/app/display.py +148 -0
- glidepath/app/drawdown.py +436 -0
- glidepath/app/example.py +66 -0
- glidepath/app/exports.py +487 -0
- glidepath/app/files.py +249 -0
- glidepath/app/firstrun.py +114 -0
- glidepath/app/forms.py +1750 -0
- glidepath/app/inspector.py +506 -0
- glidepath/app/labels.py +66 -0
- glidepath/app/montecarlo.py +399 -0
- glidepath/app/plan.py +354 -0
- glidepath/app/retirement.py +446 -0
- glidepath/app/scenarios.py +831 -0
- glidepath/app/shell.py +185 -0
- glidepath/app/tables.py +138 -0
- glidepath/core/__init__.py +390 -0
- glidepath/core/annuities.py +240 -0
- glidepath/core/backtest.py +514 -0
- glidepath/core/comparison.py +278 -0
- glidepath/core/config.py +82 -0
- glidepath/core/contributions.py +337 -0
- glidepath/core/engine.py +2811 -0
- glidepath/core/entities.py +264 -0
- glidepath/core/glide.py +289 -0
- glidepath/core/investments.py +175 -0
- glidepath/core/money.py +107 -0
- glidepath/core/montecarlo.py +609 -0
- glidepath/core/pensions.py +298 -0
- glidepath/core/periods.py +367 -0
- glidepath/core/provenance.py +271 -0
- glidepath/core/randomness.py +128 -0
- glidepath/core/region.py +46 -0
- glidepath/core/reporting.py +231 -0
- glidepath/core/results.py +504 -0
- glidepath/core/retirement.py +291 -0
- glidepath/core/returns.py +312 -0
- glidepath/core/scenarios.py +579 -0
- glidepath/core/state_pension.py +264 -0
- glidepath/core/tax.py +139 -0
- glidepath/core/withdrawals.py +461 -0
- glidepath/core/wrappers.py +278 -0
- glidepath/gui/__init__.py +6 -0
- glidepath/gui/assets/icon_128.png +0 -0
- glidepath/gui/assets/icon_16.png +0 -0
- glidepath/gui/assets/icon_24.png +0 -0
- glidepath/gui/assets/icon_256.png +0 -0
- glidepath/gui/assets/icon_32.png +0 -0
- glidepath/gui/assets/icon_48.png +0 -0
- glidepath/gui/assets/icon_64.png +0 -0
- glidepath/gui/assets/wordmark.png +0 -0
- glidepath/gui/charts.py +829 -0
- glidepath/gui/forms.py +359 -0
- glidepath/gui/inspector.py +186 -0
- glidepath/gui/main.py +51 -0
- glidepath/gui/scenarios.py +402 -0
- glidepath/gui/style.py +376 -0
- glidepath/gui/tableview.py +67 -0
- glidepath/gui/widgets.py +989 -0
- glidepath/persistence/__init__.py +48 -0
- glidepath/persistence/assumptions.py +112 -0
- glidepath/persistence/decode.py +747 -0
- glidepath/persistence/document.py +101 -0
- glidepath/persistence/encode.py +433 -0
- glidepath/persistence/migrations.py +158 -0
- glidepath/persistence/values.py +298 -0
- glidepath/py.typed +0 -0
- glidepath/regions/__init__.py +7 -0
- glidepath/regions/uk/__init__.py +189 -0
- glidepath/regions/uk/ages.py +156 -0
- glidepath/regions/uk/contributions.py +717 -0
- glidepath/regions/uk/data/age_rules.toml +78 -0
- glidepath/regions/uk/data/assumptions_default.toml +170 -0
- glidepath/regions/uk/data/returns_history.toml +150 -0
- glidepath/regions/uk/data/tax_year_2026_27.toml +98 -0
- glidepath/regions/uk/extension.py +479 -0
- glidepath/regions/uk/loader.py +704 -0
- glidepath/regions/uk/region.py +160 -0
- glidepath/regions/uk/schema.py +563 -0
- glidepath/regions/uk/state_pension.py +129 -0
- glidepath/regions/uk/tax.py +466 -0
- glidepath/regions/uk/wrappers.py +283 -0
- glidepath/regions/uk/years.py +92 -0
- glidepath-0.2.0.dist-info/METADATA +189 -0
- glidepath-0.2.0.dist-info/RECORD +93 -0
- glidepath-0.2.0.dist-info/WHEEL +4 -0
- glidepath-0.2.0.dist-info/entry_points.txt +3 -0
- glidepath-0.2.0.dist-info/licenses/LICENSE +21 -0
- glidepath-0.2.0.dist-info/licenses/LICENSE-DATA +28 -0
glidepath/gui/charts.py
ADDED
|
@@ -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
|
+
]
|