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