glidepath 0.4.0__tar.gz → 0.6.0__tar.gz
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-0.4.0 → glidepath-0.6.0}/PKG-INFO +12 -4
- {glidepath-0.4.0 → glidepath-0.6.0}/README.md +11 -3
- {glidepath-0.4.0 → glidepath-0.6.0}/pyproject.toml +6 -2
- glidepath-0.6.0/src/glidepath/__init__.py +11 -0
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/app/__init__.py +16 -0
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/app/backtest.py +3 -3
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/app/copy.py +49 -13
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/app/display.py +33 -7
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/app/drawdown.py +46 -7
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/app/forms.py +276 -12
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/app/montecarlo.py +5 -3
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/app/outlook.py +125 -30
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/app/plan.py +33 -2
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/app/retirement.py +2 -2
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/core/__init__.py +4 -0
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/core/engine.py +28 -9
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/core/entities.py +12 -0
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/core/results.py +7 -0
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/core/withdrawals.py +65 -0
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/gui/forms.py +322 -53
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/gui/style.py +17 -0
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/gui/widgets.py +7 -3
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/persistence/decode.py +25 -0
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/persistence/document.py +6 -1
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/persistence/encode.py +14 -0
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/persistence/migrations.py +16 -0
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/persistence/values.py +9 -0
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/regions/uk/__init__.py +2 -0
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/regions/uk/data/age_rules.toml +1 -1
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/regions/uk/data/assumptions_default.toml +1 -1
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/regions/uk/data/returns_history.toml +1 -1
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/regions/uk/data/tax_year_2026_27.toml +10 -2
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/regions/uk/extension.py +2 -0
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/regions/uk/loader.py +16 -10
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/regions/uk/schema.py +39 -10
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/regions/uk/tax.py +70 -28
- glidepath-0.4.0/src/glidepath/__init__.py +0 -3
- {glidepath-0.4.0 → glidepath-0.6.0}/LICENSE +0 -0
- {glidepath-0.4.0 → glidepath-0.6.0}/LICENSE-DATA +0 -0
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/app/charts.py +0 -0
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/app/example.py +0 -0
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/app/exports.py +0 -0
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/app/files.py +0 -0
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/app/firstrun.py +0 -0
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/app/inspector.py +0 -0
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/app/labels.py +0 -0
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/app/scenarios.py +0 -0
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/app/shell.py +0 -0
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/app/tables.py +0 -0
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/core/annuities.py +0 -0
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/core/backtest.py +0 -0
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/core/comparison.py +0 -0
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/core/config.py +0 -0
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/core/contributions.py +0 -0
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/core/glide.py +0 -0
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/core/investments.py +0 -0
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/core/money.py +0 -0
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/core/montecarlo.py +0 -0
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/core/pensions.py +0 -0
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/core/periods.py +0 -0
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/core/provenance.py +0 -0
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/core/randomness.py +0 -0
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/core/region.py +0 -0
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/core/reporting.py +0 -0
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/core/retirement.py +0 -0
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/core/returns.py +0 -0
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/core/scenarios.py +0 -0
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/core/state_pension.py +0 -0
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/core/tax.py +0 -0
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/core/wrappers.py +0 -0
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/gui/__init__.py +0 -0
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/gui/assets/icon_128.png +0 -0
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/gui/assets/icon_16.png +0 -0
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/gui/assets/icon_24.png +0 -0
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/gui/assets/icon_256.png +0 -0
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/gui/assets/icon_32.png +0 -0
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/gui/assets/icon_48.png +0 -0
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/gui/assets/icon_64.png +0 -0
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/gui/assets/wordmark.png +0 -0
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/gui/charts.py +0 -0
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/gui/inspector.py +0 -0
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/gui/main.py +0 -0
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/gui/scenarios.py +0 -0
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/gui/tableview.py +0 -0
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/persistence/__init__.py +0 -0
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/persistence/assumptions.py +0 -0
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/py.typed +0 -0
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/regions/__init__.py +0 -0
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/regions/uk/ages.py +0 -0
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/regions/uk/contributions.py +0 -0
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/regions/uk/region.py +0 -0
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/regions/uk/state_pension.py +0 -0
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/regions/uk/wrappers.py +0 -0
- {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/regions/uk/years.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: glidepath
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
4
|
Summary: Desktop retirement and investment planner (UK-first, region-extensible).
|
|
5
5
|
Keywords: retirement,pension,financial-planning,investment,uk,sipp,isa,monte-carlo
|
|
6
6
|
Author: James Williams
|
|
@@ -32,10 +32,12 @@ Description-Content-Type: text/markdown
|
|
|
32
32
|
<img src="https://raw.githubusercontent.com/williajm/glidepath/main/src/glidepath/gui/assets/wordmark.png" alt="glidepath" width="420">
|
|
33
33
|
</p>
|
|
34
34
|
|
|
35
|
+
[](https://pypi.org/project/glidepath/)
|
|
35
36
|
[](https://github.com/williajm/glidepath/actions/workflows/ci.yml)
|
|
36
37
|
[](https://sonarcloud.io/summary/new_code?id=williajm_glidepath)
|
|
37
38
|
[](https://sonarcloud.io/summary/new_code?id=williajm_glidepath)
|
|
38
|
-
[](https://scorecard.dev/viewer/?uri=github.com/williajm/glidepath)
|
|
40
|
+
[](https://pypi.org/project/glidepath/)
|
|
39
41
|
[](https://github.com/astral-sh/uv)
|
|
40
42
|
[](https://github.com/astral-sh/ruff)
|
|
41
43
|
[](https://mypy-lang.org/)
|
|
@@ -201,8 +203,14 @@ Releases are `vX.Y.Z` tags on `main`. Each one is published to
|
|
|
201
203
|
trusted publishing with PEP 740 attestations, and as a GitHub Release
|
|
202
204
|
carrying its notes from
|
|
203
205
|
[`CHANGELOG.md`](https://github.com/williajm/glidepath/blob/main/CHANGELOG.md)
|
|
204
|
-
with the same artifacts attached
|
|
205
|
-
|
|
206
|
+
with the same artifacts attached, each carrying signed build
|
|
207
|
+
provenance — verify a downloaded file with
|
|
208
|
+
`gh attestation verify <file> -R williajm/glidepath`. The signed
|
|
209
|
+
provenance bundle is attached to each release too
|
|
210
|
+
(`glidepath-X.Y.Z-provenance.intoto.jsonl`), so verification also
|
|
211
|
+
works offline with `--bundle`. There are no
|
|
212
|
+
packaged binary builds (installer/exe) yet — install from PyPI as
|
|
213
|
+
above.
|
|
206
214
|
|
|
207
215
|
## Data licences
|
|
208
216
|
|
|
@@ -4,10 +4,12 @@
|
|
|
4
4
|
<img src="https://raw.githubusercontent.com/williajm/glidepath/main/src/glidepath/gui/assets/wordmark.png" alt="glidepath" width="420">
|
|
5
5
|
</p>
|
|
6
6
|
|
|
7
|
+
[](https://pypi.org/project/glidepath/)
|
|
7
8
|
[](https://github.com/williajm/glidepath/actions/workflows/ci.yml)
|
|
8
9
|
[](https://sonarcloud.io/summary/new_code?id=williajm_glidepath)
|
|
9
10
|
[](https://sonarcloud.io/summary/new_code?id=williajm_glidepath)
|
|
10
|
-
[](https://scorecard.dev/viewer/?uri=github.com/williajm/glidepath)
|
|
12
|
+
[](https://pypi.org/project/glidepath/)
|
|
11
13
|
[](https://github.com/astral-sh/uv)
|
|
12
14
|
[](https://github.com/astral-sh/ruff)
|
|
13
15
|
[](https://mypy-lang.org/)
|
|
@@ -173,8 +175,14 @@ Releases are `vX.Y.Z` tags on `main`. Each one is published to
|
|
|
173
175
|
trusted publishing with PEP 740 attestations, and as a GitHub Release
|
|
174
176
|
carrying its notes from
|
|
175
177
|
[`CHANGELOG.md`](https://github.com/williajm/glidepath/blob/main/CHANGELOG.md)
|
|
176
|
-
with the same artifacts attached
|
|
177
|
-
|
|
178
|
+
with the same artifacts attached, each carrying signed build
|
|
179
|
+
provenance — verify a downloaded file with
|
|
180
|
+
`gh attestation verify <file> -R williajm/glidepath`. The signed
|
|
181
|
+
provenance bundle is attached to each release too
|
|
182
|
+
(`glidepath-X.Y.Z-provenance.intoto.jsonl`), so verification also
|
|
183
|
+
works offline with `--bundle`. There are no
|
|
184
|
+
packaged binary builds (installer/exe) yet — install from PyPI as
|
|
185
|
+
above.
|
|
178
186
|
|
|
179
187
|
## Data licences
|
|
180
188
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "glidepath"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.6.0"
|
|
4
4
|
description = "Desktop retirement and investment planner (UK-first, region-extensible)."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
# MIT code plus one CC BY-NC-SA 4.0 data file (returns_history.toml —
|
|
@@ -61,6 +61,7 @@ dev = [
|
|
|
61
61
|
"pre-commit",
|
|
62
62
|
"pytest",
|
|
63
63
|
"pytest-cov",
|
|
64
|
+
"pytest-qt",
|
|
64
65
|
"pytest-timeout",
|
|
65
66
|
"ruff",
|
|
66
67
|
]
|
|
@@ -73,7 +74,7 @@ build-backend = "uv_build"
|
|
|
73
74
|
[tool.uv]
|
|
74
75
|
# Supply-chain cooldown: resolution ignores anything published to PyPI after
|
|
75
76
|
# this timestamp (now minus 7 days). Updated ONLY by `make deps`.
|
|
76
|
-
exclude-newer = "2026-08-
|
|
77
|
+
exclude-newer = "2026-08-06T18:44:36Z"
|
|
77
78
|
|
|
78
79
|
# PyPI is the ONLY permitted index (supply-chain policy; see CLAUDE.md).
|
|
79
80
|
[[tool.uv.index]]
|
|
@@ -153,6 +154,9 @@ pythonpath = ["scripts", "tests"]
|
|
|
153
154
|
# GUI suite waits on worker pools for up to 60s, so the ceiling sits
|
|
154
155
|
# safely above that while still cutting a wedged run short.
|
|
155
156
|
timeout = 120
|
|
157
|
+
# pytest-qt would autodetect PySide6 anyway; pinning the binding keeps the
|
|
158
|
+
# plugin from ever picking up a stray PyQt installed into the venv.
|
|
159
|
+
qt_api = "pyside6"
|
|
156
160
|
markers = [
|
|
157
161
|
"gui: exercises the PySide6 shell on the offscreen Qt platform",
|
|
158
162
|
"slow: long-running engine sweeps (Monte Carlo, backtest, solvers); deselect with -m 'not slow'",
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""glidepath: a desktop retirement and investment planner (UK-first).
|
|
2
|
+
|
|
3
|
+
``[project] version`` in pyproject.toml is the single version source
|
|
4
|
+
(rewritten only by ``make bump``, planning §4.10); it reaches the
|
|
5
|
+
installed distribution as metadata, and ``__version__`` merely reads
|
|
6
|
+
it back — never state a version here.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from importlib.metadata import version
|
|
10
|
+
|
|
11
|
+
__version__ = version("glidepath")
|
|
@@ -105,11 +105,17 @@ from glidepath.app.firstrun import (
|
|
|
105
105
|
)
|
|
106
106
|
from glidepath.app.forms import (
|
|
107
107
|
ENTITY_ID_KEY,
|
|
108
|
+
HIDE_ADVANCED_LABEL,
|
|
109
|
+
INCOME_PREFERENCE_ANNUITY,
|
|
110
|
+
INCOME_PREFERENCE_KEY,
|
|
108
111
|
OWNER_KEY,
|
|
112
|
+
REQUIRED_MARKER,
|
|
113
|
+
SHOW_ADVANCED_LABEL,
|
|
109
114
|
ChoiceOption,
|
|
110
115
|
FactsFormData,
|
|
111
116
|
FactsFormResult,
|
|
112
117
|
FactsFormViewModel,
|
|
118
|
+
FactsSubmissionOutcome,
|
|
113
119
|
FieldKind,
|
|
114
120
|
FieldSpec,
|
|
115
121
|
FormError,
|
|
@@ -150,6 +156,7 @@ from glidepath.app.montecarlo import (
|
|
|
150
156
|
state_with_monte_carlo,
|
|
151
157
|
)
|
|
152
158
|
from glidepath.app.outlook import (
|
|
159
|
+
DETERMINISTIC_BASIS_SENTENCE,
|
|
153
160
|
NO_OUTLOOK_MESSAGE,
|
|
154
161
|
OUTLOOK_HEADING,
|
|
155
162
|
OUTLOOK_NO_PLAN_MESSAGE,
|
|
@@ -162,6 +169,7 @@ from glidepath.app.plan import (
|
|
|
162
169
|
PlanState,
|
|
163
170
|
facts_saved_message,
|
|
164
171
|
initial_plan_state,
|
|
172
|
+
plan_run_config,
|
|
165
173
|
state_marked_saved,
|
|
166
174
|
state_with_household,
|
|
167
175
|
state_with_override,
|
|
@@ -219,6 +227,7 @@ __all__ = [
|
|
|
219
227
|
"DEFAULT_CHART_BASIS",
|
|
220
228
|
"DEFAULT_COMPARISON_METRIC_KEY",
|
|
221
229
|
"DEFAULT_RUN_MODE",
|
|
230
|
+
"DETERMINISTIC_BASIS_SENTENCE",
|
|
222
231
|
"DISCLAIMER_ACCEPT_LABEL",
|
|
223
232
|
"DISCLAIMER_BODY",
|
|
224
233
|
"DISCLAIMER_DECLINE_LABEL",
|
|
@@ -233,6 +242,9 @@ __all__ = [
|
|
|
233
242
|
"HELP_GUIDE_SECTIONS",
|
|
234
243
|
"HELP_GUIDE_TITLE",
|
|
235
244
|
"HELP_MENU_LABEL",
|
|
245
|
+
"HIDE_ADVANCED_LABEL",
|
|
246
|
+
"INCOME_PREFERENCE_ANNUITY",
|
|
247
|
+
"INCOME_PREFERENCE_KEY",
|
|
236
248
|
"MONTE_CARLO_CHART_TITLE",
|
|
237
249
|
"MONTE_CARLO_NO_PLAN_MESSAGE",
|
|
238
250
|
"MONTE_CARLO_PATHS_MESSAGE",
|
|
@@ -253,6 +265,7 @@ __all__ = [
|
|
|
253
265
|
"OWNER_KEY",
|
|
254
266
|
"REPORT_EXPORT_FAILED_PREFIX",
|
|
255
267
|
"REPORT_NOT_WRITTEN_MESSAGE",
|
|
268
|
+
"REQUIRED_MARKER",
|
|
256
269
|
"RETIREMENT_NO_INCOME_MESSAGE",
|
|
257
270
|
"RETIREMENT_NO_PLAN_MESSAGE",
|
|
258
271
|
"RETIREMENT_RATE_MESSAGE",
|
|
@@ -260,6 +273,7 @@ __all__ = [
|
|
|
260
273
|
"RETIREMENT_STALE_MESSAGE",
|
|
261
274
|
"RETIREMENT_SUCCESS_MESSAGE",
|
|
262
275
|
"RUN_FAILED_PREFIX",
|
|
276
|
+
"SHOW_ADVANCED_LABEL",
|
|
263
277
|
"TABLE_VIEW_LABEL",
|
|
264
278
|
"UNSAVED_CHANGES_PROMPT",
|
|
265
279
|
"UNSAVED_CHANGES_TITLE",
|
|
@@ -286,6 +300,7 @@ __all__ = [
|
|
|
286
300
|
"FactsFormData",
|
|
287
301
|
"FactsFormResult",
|
|
288
302
|
"FactsFormViewModel",
|
|
303
|
+
"FactsSubmissionOutcome",
|
|
289
304
|
"FieldKind",
|
|
290
305
|
"FieldSpec",
|
|
291
306
|
"FileMenuViewModel",
|
|
@@ -357,6 +372,7 @@ __all__ = [
|
|
|
357
372
|
"path_pool",
|
|
358
373
|
"plan_display_name",
|
|
359
374
|
"plan_entity_ids",
|
|
375
|
+
"plan_run_config",
|
|
360
376
|
"record_disclaimer_acknowledged",
|
|
361
377
|
"record_last_plan_path",
|
|
362
378
|
"report_exported_message",
|
|
@@ -19,8 +19,8 @@ from typing import TYPE_CHECKING, Any, Final
|
|
|
19
19
|
|
|
20
20
|
from glidepath.app.display import format_money, format_percent
|
|
21
21
|
from glidepath.app.montecarlo import BAND_SPECS, SUCCESS_RATE_LABEL
|
|
22
|
-
from glidepath.app.plan import PlanState, region_for, replanned_state
|
|
23
|
-
from glidepath.core import Money,
|
|
22
|
+
from glidepath.app.plan import PlanState, plan_run_config, region_for, replanned_state
|
|
23
|
+
from glidepath.core import Money, run_windows
|
|
24
24
|
from glidepath.regions.uk import load_returns_history
|
|
25
25
|
|
|
26
26
|
if TYPE_CHECKING:
|
|
@@ -122,7 +122,7 @@ def state_with_backtest(state: PlanState, *, today: date) -> PlanState:
|
|
|
122
122
|
today=today,
|
|
123
123
|
modified=state.modified,
|
|
124
124
|
)
|
|
125
|
-
config =
|
|
125
|
+
config = plan_run_config(state.household, today=today)
|
|
126
126
|
try:
|
|
127
127
|
series = load_returns_history().series
|
|
128
128
|
result = run_windows(
|
|
@@ -58,15 +58,23 @@ HELP_GUIDE_SECTIONS: Final[tuple[tuple[str, str], ...]] = (
|
|
|
58
58
|
"pension, SIPP, ISA, LISA, general account, or cash) with its "
|
|
59
59
|
"balance, contributions, and optionally its own equity "
|
|
60
60
|
"allocation percentage — blank follows the de-risking glide "
|
|
61
|
-
"path, 100 models an all-equity wrapper — any defined "
|
|
62
|
-
"benefit pension
|
|
63
|
-
"
|
|
64
|
-
"
|
|
65
|
-
"
|
|
66
|
-
"
|
|
67
|
-
"
|
|
68
|
-
"
|
|
69
|
-
"
|
|
61
|
+
"path, 100 models an all-equity wrapper — and any defined "
|
|
62
|
+
"benefit pension. "
|
|
63
|
+
"The Retirement income section holds two choices: a "
|
|
64
|
+
"preference between drawdown only (the default — everything "
|
|
65
|
+
"stays invested and is withdrawn as needed) and an annuity "
|
|
66
|
+
"or a mix, which adds a section right below to plan annuity "
|
|
67
|
+
"purchases — converting a percentage of your pension pot "
|
|
68
|
+
"into lifetime income at an age you choose, 100% "
|
|
69
|
+
"annuitising the whole pot and several purchases at "
|
|
70
|
+
"different ages annuitising in stages — and a drawdown withdrawal "
|
|
71
|
+
"strategy: fixed real spending (the default), a fixed "
|
|
72
|
+
"percentage of the pot, guardrails, or natural yield. "
|
|
73
|
+
"Add one wrapper, DB, or annuity section per item. "
|
|
74
|
+
"Fields marked * are required, hovering any field repeats "
|
|
75
|
+
'its guidance, and each section\'s "More options" reveals '
|
|
76
|
+
"its rarely needed fields — anything already filled in, or "
|
|
77
|
+
"in error, is shown automatically. Dates can "
|
|
70
78
|
"be typed or picked from the calendar assist, and the "
|
|
71
79
|
'"as of" dates on balances and your state pension forecast '
|
|
72
80
|
"default to today when left blank. Your state pension needs "
|
|
@@ -74,8 +82,8 @@ HELP_GUIDE_SECTIONS: Final[tuple[tuple[str, str], ...]] = (
|
|
|
74
82
|
"the app never re-derives what DWP has already computed. "
|
|
75
83
|
'Press "Save facts and project" to run the '
|
|
76
84
|
"projection — if anything cannot be read, the message under "
|
|
77
|
-
"the buttons
|
|
78
|
-
"until it parses."
|
|
85
|
+
"the buttons and a note under each affected field say what "
|
|
86
|
+
"to fix, and nothing is saved until it parses."
|
|
79
87
|
),
|
|
80
88
|
),
|
|
81
89
|
(
|
|
@@ -113,8 +121,11 @@ HELP_GUIDE_SECTIONS: Final[tuple[tuple[str, str], ...]] = (
|
|
|
113
121
|
"reproduce. "
|
|
114
122
|
'The "When can I retire?" card answers with the earliest '
|
|
115
123
|
"retirement age at which the plan sustains a target income — "
|
|
116
|
-
"a replacement rate you choose (66% of your employment
|
|
117
|
-
"by default)
|
|
124
|
+
"a replacement rate you choose (66% of your gross employment "
|
|
125
|
+
"income by default), enforced as after-tax spending money, so "
|
|
126
|
+
"the target is deliberately more demanding than the same "
|
|
127
|
+
"share of your take-home pay — on the selected run mode's "
|
|
128
|
+
"basis: met with no "
|
|
118
129
|
"shortfall deterministically, or with at least your chosen "
|
|
119
130
|
"Monte Carlo success rate. "
|
|
120
131
|
'The "How much can I draw down?" card asks the same question '
|
|
@@ -124,6 +135,31 @@ HELP_GUIDE_SECTIONS: Final[tuple[tuple[str, str], ...]] = (
|
|
|
124
135
|
"on the same selected basis."
|
|
125
136
|
),
|
|
126
137
|
),
|
|
138
|
+
(
|
|
139
|
+
"How spending is funded",
|
|
140
|
+
(
|
|
141
|
+
"In the projection, income already in payment — defined "
|
|
142
|
+
"benefit pension, state pension, annuity income — meets your "
|
|
143
|
+
"net spending need first, after the tax it bears. Whatever "
|
|
144
|
+
"remains is withdrawn from your wrappers. How *much* is "
|
|
145
|
+
"drawn each year follows the withdrawal strategy you chose "
|
|
146
|
+
"on the Facts tab (fixed real spending by default); which "
|
|
147
|
+
"wrapper it comes from follows one fixed order: "
|
|
148
|
+
"general accounts and cash first (every pound left in them "
|
|
149
|
+
"keeps accruing income tax), then ISAs and LISAs, then "
|
|
150
|
+
"already-crystallised pension funds, and uncrystallised "
|
|
151
|
+
"pension funds last. This order is a deliberate "
|
|
152
|
+
"simplification — it is not configurable and the app never "
|
|
153
|
+
"searches for a personally optimal withdrawal sequence, "
|
|
154
|
+
"which would amount to tax advice. In some years a "
|
|
155
|
+
"different order could use an allowance this one leaves "
|
|
156
|
+
"idle, so treat the projection as a consistent baseline, "
|
|
157
|
+
"not the best achievable outcome. The 'When can I retire?' "
|
|
158
|
+
"and 'How much can I draw down?' cards always answer their "
|
|
159
|
+
"questions in fixed real spending terms, whatever strategy "
|
|
160
|
+
"the plan itself runs."
|
|
161
|
+
),
|
|
162
|
+
),
|
|
127
163
|
(
|
|
128
164
|
"Scenarios — compare what-ifs",
|
|
129
165
|
(
|
|
@@ -12,7 +12,7 @@ from decimal import Decimal
|
|
|
12
12
|
from enum import Enum
|
|
13
13
|
from typing import Final
|
|
14
14
|
|
|
15
|
-
from glidepath.core import LifeStage, Money
|
|
15
|
+
from glidepath.core import LifeStage, Money, WithdrawalRule, WithdrawalRuleKind
|
|
16
16
|
|
|
17
17
|
_MAX_STRUCTURED_LENGTH = 120
|
|
18
18
|
|
|
@@ -24,6 +24,14 @@ _ENUM_LABELS: Final[Mapping[Enum, str]] = {
|
|
|
24
24
|
"""Enum members whose copy the generic underscore rule would mangle
|
|
25
25
|
("Go go"); the retirement sub-stages read hyphenated (issue #114)."""
|
|
26
26
|
|
|
27
|
+
WITHDRAWAL_RULE_NAMES: Final[Mapping[WithdrawalRuleKind, str]] = {
|
|
28
|
+
WithdrawalRuleKind.FIXED_REAL: "Fixed real spending",
|
|
29
|
+
WithdrawalRuleKind.FIXED_PERCENT: "Fixed percentage of pot",
|
|
30
|
+
WithdrawalRuleKind.GUARDRAILS: "Guardrails (cut or raise on crossings)",
|
|
31
|
+
WithdrawalRuleKind.NATURAL_YIELD: "Natural yield only",
|
|
32
|
+
}
|
|
33
|
+
"""Display names for the withdrawal-strategy decision (planning §5.1)."""
|
|
34
|
+
|
|
27
35
|
WRAPPER_KIND_NAMES: Final[Mapping[str, str]] = {
|
|
28
36
|
"uk.workplace_dc": "Workplace DC",
|
|
29
37
|
"uk.sipp": "SIPP",
|
|
@@ -86,6 +94,14 @@ def format_assumption_key(key: object) -> str:
|
|
|
86
94
|
return ASSUMPTION_NAMES.get(text, text)
|
|
87
95
|
|
|
88
96
|
|
|
97
|
+
def _format_withdrawal_rule(rule: WithdrawalRule) -> str:
|
|
98
|
+
"""The withdrawal-strategy decision's value as display text (10.3)."""
|
|
99
|
+
name = WITHDRAWAL_RULE_NAMES[rule.kind]
|
|
100
|
+
if rule.rate is None:
|
|
101
|
+
return name
|
|
102
|
+
return f"{name} ({format_share(rule.rate.value)} a year)"
|
|
103
|
+
|
|
104
|
+
|
|
89
105
|
def _format_mapping(value: Mapping[object, object]) -> str:
|
|
90
106
|
"""A structured table as compact ``key=value`` pairs, truncated."""
|
|
91
107
|
rendered = "; ".join(f"{key}={format_value(entry)}" for key, entry in value.items())
|
|
@@ -143,15 +159,25 @@ def format_value(value: object) -> str:
|
|
|
143
159
|
numbers, enum choices, policy strings, and structured tables
|
|
144
160
|
(rendered as compact ``key=value`` pairs, truncated when long).
|
|
145
161
|
"""
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
return format_money(value)
|
|
150
|
-
if isinstance(value, date):
|
|
151
|
-
return _format_temporal(value)
|
|
162
|
+
special = _format_special(value)
|
|
163
|
+
if special is not None:
|
|
164
|
+
return special
|
|
152
165
|
if isinstance(value, Enum):
|
|
153
166
|
generic = str(value.name).replace("_", " ").capitalize()
|
|
154
167
|
return _ENUM_LABELS.get(value, generic)
|
|
155
168
|
if isinstance(value, Mapping):
|
|
156
169
|
return _format_mapping(value)
|
|
157
170
|
return str(value)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _format_special(value: object) -> str | None:
|
|
174
|
+
"""The non-enum, non-table special cases; ``None`` to fall through."""
|
|
175
|
+
if isinstance(value, bool): # before any numeric type: bool is an int
|
|
176
|
+
return "Yes" if value else "No"
|
|
177
|
+
if isinstance(value, WithdrawalRule):
|
|
178
|
+
return _format_withdrawal_rule(value)
|
|
179
|
+
if isinstance(value, Money):
|
|
180
|
+
return format_money(value)
|
|
181
|
+
if isinstance(value, date):
|
|
182
|
+
return _format_temporal(value)
|
|
183
|
+
return None
|
|
@@ -169,11 +169,14 @@ class DrawdownAnswer:
|
|
|
169
169
|
|
|
170
170
|
``income`` is the highest sustainable net annual income in today's
|
|
171
171
|
money, or ``None`` when not even zero spending survives the plan's
|
|
172
|
-
outflows.
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
172
|
+
outflows. ``pot`` is the household's total wrapper balances as
|
|
173
|
+
stated at solve time — the denominator presenting the answer as a
|
|
174
|
+
starting withdrawal rate, recorded here so the rate always
|
|
175
|
+
describes the plan the answer was solved on. The rest is the
|
|
176
|
+
manifest side: the retirement age the answer assumed and whose it
|
|
177
|
+
was (``person_position``, the household position), the searched
|
|
178
|
+
bracket's upper bound, and the basis — ``seed``, ``paths``, and
|
|
179
|
+
``target_success_rate`` are carried only for a Monte Carlo basis.
|
|
177
180
|
"""
|
|
178
181
|
|
|
179
182
|
income: Money | None
|
|
@@ -184,6 +187,7 @@ class DrawdownAnswer:
|
|
|
184
187
|
paths: int | None = None
|
|
185
188
|
target_success_rate: Decimal | None = None
|
|
186
189
|
person_position: int = 0
|
|
190
|
+
pot: Money | None = None
|
|
187
191
|
|
|
188
192
|
|
|
189
193
|
@dataclass(frozen=True)
|
|
@@ -296,6 +300,7 @@ def state_with_drawdown(
|
|
|
296
300
|
paths=inputs.paths,
|
|
297
301
|
target_success_rate=inputs.target_success_rate,
|
|
298
302
|
person_position=inputs.person_position,
|
|
303
|
+
pot=_household_pot(household),
|
|
299
304
|
)
|
|
300
305
|
changes: dict[str, Any] = {"drawdown": answer, "drawdown_error": None}
|
|
301
306
|
return replace(base, **changes) if changes else base
|
|
@@ -358,7 +363,7 @@ def _headline(answer: DrawdownAnswer) -> str:
|
|
|
358
363
|
|
|
359
364
|
|
|
360
365
|
def _detail(answer: DrawdownAnswer) -> str:
|
|
361
|
-
"""The assumed age, searched bracket, and basis under the headline."""
|
|
366
|
+
"""The assumed age, searched bracket, rate, and basis under the headline."""
|
|
362
367
|
retiring = (
|
|
363
368
|
f"Retiring at age {answer.age}"
|
|
364
369
|
if answer.person_position == 0
|
|
@@ -376,7 +381,41 @@ def _detail(answer: DrawdownAnswer) -> str:
|
|
|
376
381
|
f" Monte Carlo success over {answer.paths} paths"
|
|
377
382
|
f" (seed {answer.seed})."
|
|
378
383
|
)
|
|
379
|
-
|
|
384
|
+
rate = _rate_line(answer)
|
|
385
|
+
lines = [target, rate, basis] if rate else [target, basis]
|
|
386
|
+
return "\n".join(lines)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _rate_line(answer: DrawdownAnswer) -> str:
|
|
390
|
+
"""The answer as a starting withdrawal rate of today's pot, if any.
|
|
391
|
+
|
|
392
|
+
Blank when nothing is sustainable or the household holds no
|
|
393
|
+
wrapper balances to take a rate of. The rate is derived from the
|
|
394
|
+
answer, never asserted: the product ships no "safe withdrawal
|
|
395
|
+
rate" figure — this line only lets users compare the computed
|
|
396
|
+
answer against rules of thumb they know.
|
|
397
|
+
"""
|
|
398
|
+
if answer.income is None or answer.pot is None or answer.pot.amount <= 0:
|
|
399
|
+
return ""
|
|
400
|
+
rate = answer.income.amount / answer.pot.amount
|
|
401
|
+
return (
|
|
402
|
+
f"A starting withdrawal rate of {format_percent(rate)} of"
|
|
403
|
+
f" today's total pot ({format_money(answer.pot)})."
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _household_pot(household: Household) -> Money:
|
|
408
|
+
"""Every wrapper balance across the household, as stated.
|
|
409
|
+
|
|
410
|
+
The whole household's pot — the §4.11 "our pot" meaning — since
|
|
411
|
+
the sustainable income is drawn from every accessible source, not
|
|
412
|
+
the tested person's alone.
|
|
413
|
+
"""
|
|
414
|
+
total = Money(Decimal(0))
|
|
415
|
+
for person in household.persons:
|
|
416
|
+
for wrapper in person.wrappers:
|
|
417
|
+
total = total + wrapper.balance.value
|
|
418
|
+
return total
|
|
380
419
|
|
|
381
420
|
|
|
382
421
|
def _age_echo(answer: DrawdownAnswer | None, household: Household | None) -> str:
|