glidepath 0.3.0__tar.gz → 0.5.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.3.0 → glidepath-0.5.0}/PKG-INFO +15 -4
- {glidepath-0.3.0 → glidepath-0.5.0}/README.md +14 -3
- {glidepath-0.3.0 → glidepath-0.5.0}/pyproject.toml +6 -2
- glidepath-0.5.0/src/glidepath/__init__.py +11 -0
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/app/__init__.py +16 -0
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/app/charts.py +48 -32
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/app/copy.py +24 -2
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/app/display.py +12 -3
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/app/drawdown.py +117 -19
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/app/example.py +7 -3
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/app/exports.py +1 -1
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/app/forms.py +530 -119
- glidepath-0.5.0/src/glidepath/app/labels.py +116 -0
- glidepath-0.5.0/src/glidepath/app/outlook.py +595 -0
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/app/retirement.py +104 -22
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/app/scenarios.py +126 -8
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/core/__init__.py +10 -3
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/core/annuities.py +29 -2
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/core/engine.py +1478 -619
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/core/entities.py +30 -17
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/core/montecarlo.py +60 -6
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/core/pensions.py +28 -11
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/core/provenance.py +2 -0
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/core/results.py +89 -47
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/core/retirement.py +107 -42
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/core/scenarios.py +95 -6
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/core/tax.py +55 -5
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/core/withdrawals.py +40 -23
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/core/wrappers.py +26 -3
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/gui/charts.py +115 -51
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/gui/forms.py +159 -18
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/gui/widgets.py +14 -2
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/persistence/decode.py +38 -1
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/persistence/document.py +19 -1
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/persistence/encode.py +18 -0
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/persistence/migrations.py +102 -1
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/persistence/values.py +12 -0
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/regions/uk/__init__.py +8 -0
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/regions/uk/ages.py +15 -0
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/regions/uk/data/age_rules.toml +12 -3
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/regions/uk/data/assumptions_default.toml +14 -2
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/regions/uk/data/returns_history.toml +1 -1
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/regions/uk/data/tax_year_2026_27.toml +26 -5
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/regions/uk/extension.py +42 -6
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/regions/uk/loader.py +60 -10
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/regions/uk/schema.py +101 -7
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/regions/uk/tax.py +204 -16
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/regions/uk/wrappers.py +12 -0
- glidepath-0.3.0/src/glidepath/__init__.py +0 -3
- glidepath-0.3.0/src/glidepath/app/labels.py +0 -66
- {glidepath-0.3.0 → glidepath-0.5.0}/LICENSE +0 -0
- {glidepath-0.3.0 → glidepath-0.5.0}/LICENSE-DATA +0 -0
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/app/backtest.py +0 -0
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/app/files.py +0 -0
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/app/firstrun.py +0 -0
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/app/inspector.py +0 -0
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/app/montecarlo.py +0 -0
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/app/plan.py +0 -0
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/app/shell.py +0 -0
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/app/tables.py +0 -0
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/core/backtest.py +0 -0
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/core/comparison.py +0 -0
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/core/config.py +0 -0
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/core/contributions.py +0 -0
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/core/glide.py +0 -0
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/core/investments.py +0 -0
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/core/money.py +0 -0
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/core/periods.py +0 -0
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/core/randomness.py +0 -0
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/core/region.py +0 -0
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/core/reporting.py +0 -0
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/core/returns.py +0 -0
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/core/state_pension.py +0 -0
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/gui/__init__.py +0 -0
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/gui/assets/icon_128.png +0 -0
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/gui/assets/icon_16.png +0 -0
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/gui/assets/icon_24.png +0 -0
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/gui/assets/icon_256.png +0 -0
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/gui/assets/icon_32.png +0 -0
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/gui/assets/icon_48.png +0 -0
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/gui/assets/icon_64.png +0 -0
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/gui/assets/wordmark.png +0 -0
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/gui/inspector.py +0 -0
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/gui/main.py +0 -0
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/gui/scenarios.py +0 -0
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/gui/style.py +0 -0
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/gui/tableview.py +0 -0
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/persistence/__init__.py +0 -0
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/persistence/assumptions.py +0 -0
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/py.typed +0 -0
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/regions/__init__.py +0 -0
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/regions/uk/contributions.py +0 -0
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/regions/uk/region.py +0 -0
- {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/regions/uk/state_pension.py +0 -0
- {glidepath-0.3.0 → glidepath-0.5.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.5.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
|
|
@@ -59,7 +59,7 @@ nothing is transmitted.
|
|
|
59
59
|
|
|
60
60
|
*(All screenshots show example data, not anyone's real finances.)*
|
|
61
61
|
|
|
62
|
-
What it models today (single
|
|
62
|
+
What it models today (UK, single or couple):
|
|
63
63
|
|
|
64
64
|
- **Wrappers** — workplace DC, SIPP, S&S ISA, LISA, GIA and cash, with
|
|
65
65
|
UK contribution relief mechanics and dividend/savings taxation.
|
|
@@ -69,6 +69,14 @@ What it models today (single person, UK):
|
|
|
69
69
|
including deferral.
|
|
70
70
|
- **Tax** — rUK and Scottish income tax from verified 2026/27 data
|
|
71
71
|
files; pension allowances (AA/taper/MPAA, lump-sum allowance).
|
|
72
|
+
- **Couples** — an optional partner, modelled end to end: one pooled
|
|
73
|
+
household decumulation drawing tax-efficiently across both partners'
|
|
74
|
+
wrappers with each person taxed individually (mixed rUK/Scottish
|
|
75
|
+
residency included), the marriage allowance claimed when eligible,
|
|
76
|
+
optional survivor modelling ("model death at age" — pensions pass as
|
|
77
|
+
beneficiary drawdown, ISAs via the additional permitted
|
|
78
|
+
subscription, DB schemes at their survivor fraction), and joint-life
|
|
79
|
+
annuities paying a 50/66/100% survivor income.
|
|
72
80
|
- **Projection** — deterministic or Monte Carlo runs from the app:
|
|
73
81
|
success rate, probability of ruin, ending-pot percentiles, and a
|
|
74
82
|
probability fan chart on its own tab, reproducible from a seed. With
|
|
@@ -193,8 +201,11 @@ Releases are `vX.Y.Z` tags on `main`. Each one is published to
|
|
|
193
201
|
trusted publishing with PEP 740 attestations, and as a GitHub Release
|
|
194
202
|
carrying its notes from
|
|
195
203
|
[`CHANGELOG.md`](https://github.com/williajm/glidepath/blob/main/CHANGELOG.md)
|
|
196
|
-
with the same artifacts attached
|
|
197
|
-
|
|
204
|
+
with the same artifacts attached, each carrying signed build
|
|
205
|
+
provenance — verify a downloaded file with
|
|
206
|
+
`gh attestation verify <file> -R williajm/glidepath`. There are no
|
|
207
|
+
packaged binary builds (installer/exe) yet — install from PyPI as
|
|
208
|
+
above.
|
|
198
209
|
|
|
199
210
|
## Data licences
|
|
200
211
|
|
|
@@ -31,7 +31,7 @@ nothing is transmitted.
|
|
|
31
31
|
|
|
32
32
|
*(All screenshots show example data, not anyone's real finances.)*
|
|
33
33
|
|
|
34
|
-
What it models today (single
|
|
34
|
+
What it models today (UK, single or couple):
|
|
35
35
|
|
|
36
36
|
- **Wrappers** — workplace DC, SIPP, S&S ISA, LISA, GIA and cash, with
|
|
37
37
|
UK contribution relief mechanics and dividend/savings taxation.
|
|
@@ -41,6 +41,14 @@ What it models today (single person, UK):
|
|
|
41
41
|
including deferral.
|
|
42
42
|
- **Tax** — rUK and Scottish income tax from verified 2026/27 data
|
|
43
43
|
files; pension allowances (AA/taper/MPAA, lump-sum allowance).
|
|
44
|
+
- **Couples** — an optional partner, modelled end to end: one pooled
|
|
45
|
+
household decumulation drawing tax-efficiently across both partners'
|
|
46
|
+
wrappers with each person taxed individually (mixed rUK/Scottish
|
|
47
|
+
residency included), the marriage allowance claimed when eligible,
|
|
48
|
+
optional survivor modelling ("model death at age" — pensions pass as
|
|
49
|
+
beneficiary drawdown, ISAs via the additional permitted
|
|
50
|
+
subscription, DB schemes at their survivor fraction), and joint-life
|
|
51
|
+
annuities paying a 50/66/100% survivor income.
|
|
44
52
|
- **Projection** — deterministic or Monte Carlo runs from the app:
|
|
45
53
|
success rate, probability of ruin, ending-pot percentiles, and a
|
|
46
54
|
probability fan chart on its own tab, reproducible from a seed. With
|
|
@@ -165,8 +173,11 @@ Releases are `vX.Y.Z` tags on `main`. Each one is published to
|
|
|
165
173
|
trusted publishing with PEP 740 attestations, and as a GitHub Release
|
|
166
174
|
carrying its notes from
|
|
167
175
|
[`CHANGELOG.md`](https://github.com/williajm/glidepath/blob/main/CHANGELOG.md)
|
|
168
|
-
with the same artifacts attached
|
|
169
|
-
|
|
176
|
+
with the same artifacts attached, each carrying signed build
|
|
177
|
+
provenance — verify a downloaded file with
|
|
178
|
+
`gh attestation verify <file> -R williajm/glidepath`. There are no
|
|
179
|
+
packaged binary builds (installer/exe) yet — install from PyPI as
|
|
180
|
+
above.
|
|
170
181
|
|
|
171
182
|
## Data licences
|
|
172
183
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "glidepath"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.5.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,6 +105,7 @@ from glidepath.app.firstrun import (
|
|
|
105
105
|
)
|
|
106
106
|
from glidepath.app.forms import (
|
|
107
107
|
ENTITY_ID_KEY,
|
|
108
|
+
OWNER_KEY,
|
|
108
109
|
ChoiceOption,
|
|
109
110
|
FactsFormData,
|
|
110
111
|
FactsFormResult,
|
|
@@ -112,6 +113,7 @@ from glidepath.app.forms import (
|
|
|
112
113
|
FieldKind,
|
|
113
114
|
FieldSpec,
|
|
114
115
|
FormError,
|
|
116
|
+
PersonFormData,
|
|
115
117
|
PlanEntityIds,
|
|
116
118
|
SectionSpec,
|
|
117
119
|
build_facts_form_view_model,
|
|
@@ -147,6 +149,13 @@ from glidepath.app.montecarlo import (
|
|
|
147
149
|
run_mode_key,
|
|
148
150
|
state_with_monte_carlo,
|
|
149
151
|
)
|
|
152
|
+
from glidepath.app.outlook import (
|
|
153
|
+
NO_OUTLOOK_MESSAGE,
|
|
154
|
+
OUTLOOK_HEADING,
|
|
155
|
+
OUTLOOK_NO_PLAN_MESSAGE,
|
|
156
|
+
OutlookPanelViewModel,
|
|
157
|
+
build_outlook_panel,
|
|
158
|
+
)
|
|
150
159
|
from glidepath.app.plan import (
|
|
151
160
|
OVERRIDE_SOURCE,
|
|
152
161
|
OverrideOutcome,
|
|
@@ -235,9 +244,13 @@ __all__ = [
|
|
|
235
244
|
"NO_BACKTEST_MESSAGE",
|
|
236
245
|
"NO_DRAWDOWN_MESSAGE",
|
|
237
246
|
"NO_MONTE_CARLO_MESSAGE",
|
|
247
|
+
"NO_OUTLOOK_MESSAGE",
|
|
238
248
|
"NO_PROJECTION_MESSAGE",
|
|
239
249
|
"NO_RETIREMENT_MESSAGE",
|
|
250
|
+
"OUTLOOK_HEADING",
|
|
251
|
+
"OUTLOOK_NO_PLAN_MESSAGE",
|
|
240
252
|
"OVERRIDE_SOURCE",
|
|
253
|
+
"OWNER_KEY",
|
|
241
254
|
"REPORT_EXPORT_FAILED_PREFIX",
|
|
242
255
|
"REPORT_NOT_WRITTEN_MESSAGE",
|
|
243
256
|
"RETIREMENT_NO_INCOME_MESSAGE",
|
|
@@ -284,8 +297,10 @@ __all__ = [
|
|
|
284
297
|
"LoadOutcome",
|
|
285
298
|
"MonteCarloMetric",
|
|
286
299
|
"MonteCarloPanelViewModel",
|
|
300
|
+
"OutlookPanelViewModel",
|
|
287
301
|
"OverrideOutcome",
|
|
288
302
|
"OverrideRow",
|
|
303
|
+
"PersonFormData",
|
|
289
304
|
"PlanEntityIds",
|
|
290
305
|
"PlanReport",
|
|
291
306
|
"PlanState",
|
|
@@ -310,6 +325,7 @@ __all__ = [
|
|
|
310
325
|
"build_drawdown_panel",
|
|
311
326
|
"build_facts_form_view_model",
|
|
312
327
|
"build_inspector_view_model",
|
|
328
|
+
"build_outlook_panel",
|
|
313
329
|
"build_plan_report",
|
|
314
330
|
"build_retirement_panel",
|
|
315
331
|
"build_scenarios_view_model",
|
|
@@ -10,7 +10,6 @@ inflation source (planning §5.2). Amounts stay ``Decimal`` here:
|
|
|
10
10
|
converting them to plot coordinates is shell mechanics (§4.7).
|
|
11
11
|
"""
|
|
12
12
|
|
|
13
|
-
from collections import Counter
|
|
14
13
|
from dataclasses import dataclass
|
|
15
14
|
from decimal import Decimal
|
|
16
15
|
from typing import TYPE_CHECKING, Final
|
|
@@ -25,6 +24,7 @@ from glidepath.app.drawdown import (
|
|
|
25
24
|
DrawdownPanelViewModel,
|
|
26
25
|
build_drawdown_panel,
|
|
27
26
|
)
|
|
27
|
+
from glidepath.app.labels import numbered_unique
|
|
28
28
|
from glidepath.app.montecarlo import (
|
|
29
29
|
DEFAULT_RUN_MODE,
|
|
30
30
|
FAN_MEDIAN_LABEL,
|
|
@@ -32,6 +32,10 @@ from glidepath.app.montecarlo import (
|
|
|
32
32
|
MonteCarloPanelViewModel,
|
|
33
33
|
build_monte_carlo_panel,
|
|
34
34
|
)
|
|
35
|
+
from glidepath.app.outlook import (
|
|
36
|
+
OutlookPanelViewModel,
|
|
37
|
+
build_outlook_panel,
|
|
38
|
+
)
|
|
35
39
|
from glidepath.app.retirement import (
|
|
36
40
|
RetirementPanelViewModel,
|
|
37
41
|
build_retirement_panel,
|
|
@@ -54,6 +58,7 @@ if TYPE_CHECKING:
|
|
|
54
58
|
AssetAllocation,
|
|
55
59
|
BacktestResult,
|
|
56
60
|
EntityId,
|
|
61
|
+
Household,
|
|
57
62
|
MonteCarloResult,
|
|
58
63
|
Period,
|
|
59
64
|
PeriodReportRow,
|
|
@@ -208,9 +213,9 @@ class ChartsViewModel:
|
|
|
208
213
|
"""The projection charts screen (roadmap 8.4).
|
|
209
214
|
|
|
210
215
|
``categories`` labels the shared x axis — one label per projected
|
|
211
|
-
period: the period-start year with
|
|
212
|
-
start alongside (roadmap 9.11;
|
|
213
|
-
|
|
216
|
+
period: the period-start year with each person's age at period
|
|
217
|
+
start alongside (roadmap 9.11; both ages in household order for a
|
|
218
|
+
couple, e.g. ``2032 · 60/58`` — roadmap 9.31). ``message``
|
|
214
219
|
carries the empty-state copy when there is nothing to chart; it
|
|
215
220
|
is blank whenever ``charts`` is populated. ``allocation_note``
|
|
216
221
|
states the asset allocation each wrapper actually ran — stated
|
|
@@ -227,6 +232,7 @@ class ChartsViewModel:
|
|
|
227
232
|
message: str
|
|
228
233
|
allocation_note: str
|
|
229
234
|
monte_carlo: MonteCarloPanelViewModel
|
|
235
|
+
outlook: OutlookPanelViewModel
|
|
230
236
|
retirement: RetirementPanelViewModel
|
|
231
237
|
drawdown: DrawdownPanelViewModel
|
|
232
238
|
backtest: BacktestPanelViewModel
|
|
@@ -366,6 +372,7 @@ def build_charts_view_model(
|
|
|
366
372
|
monte_carlo=build_monte_carlo_panel(
|
|
367
373
|
state, mode, ending_pot_deflator=None, basis_suffix=suffix
|
|
368
374
|
),
|
|
375
|
+
outlook=build_outlook_panel(state),
|
|
369
376
|
retirement=build_retirement_panel(state, mode),
|
|
370
377
|
drawdown=build_drawdown_panel(state, mode),
|
|
371
378
|
backtest=build_backtest_panel(
|
|
@@ -379,7 +386,7 @@ def build_charts_view_model(
|
|
|
379
386
|
bands = _chart_bands(state, grouped, backtest_year)
|
|
380
387
|
final_rows = next(reversed(grouped.values()))
|
|
381
388
|
charts = [
|
|
382
|
-
_balances_chart(grouped, suffix, bands),
|
|
389
|
+
_balances_chart(grouped, suffix, bands, state.household),
|
|
383
390
|
_income_chart(grouped, suffix),
|
|
384
391
|
_tax_chart(grouped, suffix),
|
|
385
392
|
]
|
|
@@ -403,6 +410,7 @@ def build_charts_view_model(
|
|
|
403
410
|
ending_pot_deflator=final_rows[0].balance_deflator,
|
|
404
411
|
basis_suffix=suffix,
|
|
405
412
|
),
|
|
413
|
+
outlook=build_outlook_panel(state),
|
|
406
414
|
retirement=build_retirement_panel(state, mode),
|
|
407
415
|
drawdown=build_drawdown_panel(state, mode),
|
|
408
416
|
backtest=build_backtest_panel(
|
|
@@ -493,7 +501,9 @@ def _allocation_note(
|
|
|
493
501
|
household = state.household
|
|
494
502
|
if household is None:
|
|
495
503
|
return ""
|
|
496
|
-
labels = wrapper_display_labels(
|
|
504
|
+
labels = wrapper_display_labels(
|
|
505
|
+
(row for rows in grouped.values() for row in rows), household
|
|
506
|
+
)
|
|
497
507
|
glide = _glide_note(state)
|
|
498
508
|
parts = []
|
|
499
509
|
for person in household.persons:
|
|
@@ -512,17 +522,15 @@ def _allocation_note(
|
|
|
512
522
|
|
|
513
523
|
|
|
514
524
|
def _category_label(period: Period, rows: list[PeriodReportRow]) -> str:
|
|
515
|
-
"""One period's x-axis label: its start year, with the
|
|
525
|
+
"""One period's x-axis label: its start year, with the persons' ages.
|
|
516
526
|
|
|
517
527
|
``2032 · 60`` reads the horizon in ages as well as calendar years
|
|
518
|
-
(roadmap 9.11)
|
|
519
|
-
|
|
520
|
-
couples activation, 9.4).
|
|
528
|
+
(roadmap 9.11); a two-person period labels both ages in household
|
|
529
|
+
order — ``2032 · 60/58`` (roadmap 9.31).
|
|
521
530
|
"""
|
|
522
531
|
year = str(period.start.year)
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
return year
|
|
532
|
+
ages = "/".join(str(row.age_at_period_start) for row in rows)
|
|
533
|
+
return f"{year} · {ages}" if ages else year
|
|
526
534
|
|
|
527
535
|
|
|
528
536
|
def _rows_by_period(
|
|
@@ -573,29 +581,34 @@ def _chart(
|
|
|
573
581
|
)
|
|
574
582
|
|
|
575
583
|
|
|
576
|
-
def wrapper_display_labels(
|
|
584
|
+
def wrapper_display_labels(
|
|
585
|
+
rows: Iterable[PeriodReportRow], household: Household | None = None
|
|
586
|
+
) -> dict[EntityId, str]:
|
|
577
587
|
"""A display label per wrapper, in first-seen order.
|
|
578
588
|
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
589
|
+
A wrapper the user named shows its own label; the rest read their
|
|
590
|
+
kind name (entity ids are generated UUIDs, so they are never shown
|
|
591
|
+
as copy). Any name repeated across the final set — several unnamed
|
|
592
|
+
wrappers of one kind, a label colliding with a kind name, or one
|
|
593
|
+
label given twice in a hand-edited plan file — is numbered in
|
|
594
|
+
first-seen order (:func:`~glidepath.app.labels.numbered_unique`).
|
|
595
|
+
Shared with the cash-flow export (9.19), which columns its
|
|
596
|
+
balances the same way the balances chart stacks them.
|
|
584
597
|
"""
|
|
585
|
-
|
|
598
|
+
named: dict[EntityId, str] = {}
|
|
599
|
+
if household is not None:
|
|
600
|
+
named = {
|
|
601
|
+
wrapper.id: wrapper.label
|
|
602
|
+
for person in household.persons
|
|
603
|
+
for wrapper in person.wrappers
|
|
604
|
+
if wrapper.label is not None
|
|
605
|
+
}
|
|
606
|
+
bases: dict[EntityId, str] = {}
|
|
586
607
|
for row in rows:
|
|
587
608
|
for entry in row.wrapper_balances:
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
labels: dict[EntityId, str] = {}
|
|
592
|
-
for wrapper_id, kind in kinds.items():
|
|
593
|
-
if counts[kind] == 1:
|
|
594
|
-
labels[wrapper_id] = kind
|
|
595
|
-
else:
|
|
596
|
-
numbered[kind] += 1
|
|
597
|
-
labels[wrapper_id] = f"{kind} {numbered[kind]}"
|
|
598
|
-
return labels
|
|
609
|
+
base = named.get(entry.wrapper_id) or format_wrapper_kind(entry.kind)
|
|
610
|
+
bases.setdefault(entry.wrapper_id, base)
|
|
611
|
+
return numbered_unique(bases)
|
|
599
612
|
|
|
600
613
|
|
|
601
614
|
def _deflated(
|
|
@@ -716,10 +729,13 @@ def _balances_chart(
|
|
|
716
729
|
grouped: dict[Period, list[PeriodReportRow]],
|
|
717
730
|
suffix: str,
|
|
718
731
|
bands: tuple[ChartBand, ...],
|
|
732
|
+
household: Household | None,
|
|
719
733
|
) -> ChartSpec:
|
|
720
734
|
"""Closing balance per wrapper per period, stacked to the total."""
|
|
721
735
|
series = []
|
|
722
|
-
labels = wrapper_display_labels(
|
|
736
|
+
labels = wrapper_display_labels(
|
|
737
|
+
(row for rows in grouped.values() for row in rows), household
|
|
738
|
+
)
|
|
723
739
|
for wrapper_id, label in labels.items():
|
|
724
740
|
values = []
|
|
725
741
|
for rows in grouped.values():
|
|
@@ -113,8 +113,11 @@ HELP_GUIDE_SECTIONS: Final[tuple[tuple[str, str], ...]] = (
|
|
|
113
113
|
"reproduce. "
|
|
114
114
|
'The "When can I retire?" card answers with the earliest '
|
|
115
115
|
"retirement age at which the plan sustains a target income — "
|
|
116
|
-
"a replacement rate you choose (66% of your employment
|
|
117
|
-
"by default)
|
|
116
|
+
"a replacement rate you choose (66% of your gross employment "
|
|
117
|
+
"income by default), enforced as after-tax spending money, so "
|
|
118
|
+
"the target is deliberately more demanding than the same "
|
|
119
|
+
"share of your take-home pay — on the selected run mode's "
|
|
120
|
+
"basis: met with no "
|
|
118
121
|
"shortfall deterministically, or with at least your chosen "
|
|
119
122
|
"Monte Carlo success rate. "
|
|
120
123
|
'The "How much can I draw down?" card asks the same question '
|
|
@@ -124,6 +127,25 @@ HELP_GUIDE_SECTIONS: Final[tuple[tuple[str, str], ...]] = (
|
|
|
124
127
|
"on the same selected basis."
|
|
125
128
|
),
|
|
126
129
|
),
|
|
130
|
+
(
|
|
131
|
+
"How spending is funded",
|
|
132
|
+
(
|
|
133
|
+
"In the projection, income already in payment — defined "
|
|
134
|
+
"benefit pension, state pension, annuity income — meets your "
|
|
135
|
+
"net spending need first, after the tax it bears. Whatever "
|
|
136
|
+
"remains is withdrawn from your wrappers in one fixed order: "
|
|
137
|
+
"general accounts and cash first (every pound left in them "
|
|
138
|
+
"keeps accruing income tax), then ISAs and LISAs, then "
|
|
139
|
+
"already-crystallised pension funds, and uncrystallised "
|
|
140
|
+
"pension funds last. This order is a deliberate "
|
|
141
|
+
"simplification — it is not configurable and the app never "
|
|
142
|
+
"searches for a personally optimal withdrawal sequence, "
|
|
143
|
+
"which would amount to tax advice. In some years a "
|
|
144
|
+
"different order could use an allowance this one leaves "
|
|
145
|
+
"idle, so treat the projection as a consistent baseline, "
|
|
146
|
+
"not the best achievable outcome."
|
|
147
|
+
),
|
|
148
|
+
),
|
|
127
149
|
(
|
|
128
150
|
"Scenarios — compare what-ifs",
|
|
129
151
|
(
|
|
@@ -70,6 +70,8 @@ ASSUMPTION_NAMES: Final[Mapping[str, str]] = {
|
|
|
70
70
|
"Annuity rate (inflation-linked, single life, age 65)"
|
|
71
71
|
),
|
|
72
72
|
"annuity.age_adjustment": "Annuity rate age adjustment",
|
|
73
|
+
"db.survivor_fraction": "DB survivor pension fraction",
|
|
74
|
+
"spending.survivor_multiplier": "Survivor spending multiplier",
|
|
73
75
|
}
|
|
74
76
|
"""Human display names for the shipped assumption keys (roadmap 8.3).
|
|
75
77
|
|
|
@@ -127,6 +129,13 @@ def format_recorded(moment: datetime) -> str:
|
|
|
127
129
|
return moment.date().isoformat()
|
|
128
130
|
|
|
129
131
|
|
|
132
|
+
def _format_temporal(value: date) -> str:
|
|
133
|
+
"""A datetime (a recorded-on moment, and a date subclass) or a date."""
|
|
134
|
+
if isinstance(value, datetime):
|
|
135
|
+
return format_recorded(value)
|
|
136
|
+
return format_date(value)
|
|
137
|
+
|
|
138
|
+
|
|
130
139
|
def format_value(value: object) -> str:
|
|
131
140
|
"""Any fact, decision, or assumption value as display text.
|
|
132
141
|
|
|
@@ -134,12 +143,12 @@ def format_value(value: object) -> str:
|
|
|
134
143
|
numbers, enum choices, policy strings, and structured tables
|
|
135
144
|
(rendered as compact ``key=value`` pairs, truncated when long).
|
|
136
145
|
"""
|
|
146
|
+
if isinstance(value, bool): # before any numeric type: bool is an int
|
|
147
|
+
return "Yes" if value else "No"
|
|
137
148
|
if isinstance(value, Money):
|
|
138
149
|
return format_money(value)
|
|
139
|
-
if isinstance(value, datetime): # before date: datetime is a date subclass
|
|
140
|
-
return format_recorded(value)
|
|
141
150
|
if isinstance(value, date):
|
|
142
|
-
return
|
|
151
|
+
return _format_temporal(value)
|
|
143
152
|
if isinstance(value, Enum):
|
|
144
153
|
generic = str(value.name).replace("_", " ").capitalize()
|
|
145
154
|
return _ENUM_LABELS.get(value, generic)
|