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.
Files changed (95) hide show
  1. {glidepath-0.3.0 → glidepath-0.5.0}/PKG-INFO +15 -4
  2. {glidepath-0.3.0 → glidepath-0.5.0}/README.md +14 -3
  3. {glidepath-0.3.0 → glidepath-0.5.0}/pyproject.toml +6 -2
  4. glidepath-0.5.0/src/glidepath/__init__.py +11 -0
  5. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/app/__init__.py +16 -0
  6. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/app/charts.py +48 -32
  7. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/app/copy.py +24 -2
  8. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/app/display.py +12 -3
  9. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/app/drawdown.py +117 -19
  10. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/app/example.py +7 -3
  11. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/app/exports.py +1 -1
  12. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/app/forms.py +530 -119
  13. glidepath-0.5.0/src/glidepath/app/labels.py +116 -0
  14. glidepath-0.5.0/src/glidepath/app/outlook.py +595 -0
  15. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/app/retirement.py +104 -22
  16. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/app/scenarios.py +126 -8
  17. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/core/__init__.py +10 -3
  18. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/core/annuities.py +29 -2
  19. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/core/engine.py +1478 -619
  20. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/core/entities.py +30 -17
  21. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/core/montecarlo.py +60 -6
  22. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/core/pensions.py +28 -11
  23. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/core/provenance.py +2 -0
  24. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/core/results.py +89 -47
  25. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/core/retirement.py +107 -42
  26. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/core/scenarios.py +95 -6
  27. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/core/tax.py +55 -5
  28. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/core/withdrawals.py +40 -23
  29. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/core/wrappers.py +26 -3
  30. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/gui/charts.py +115 -51
  31. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/gui/forms.py +159 -18
  32. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/gui/widgets.py +14 -2
  33. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/persistence/decode.py +38 -1
  34. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/persistence/document.py +19 -1
  35. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/persistence/encode.py +18 -0
  36. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/persistence/migrations.py +102 -1
  37. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/persistence/values.py +12 -0
  38. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/regions/uk/__init__.py +8 -0
  39. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/regions/uk/ages.py +15 -0
  40. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/regions/uk/data/age_rules.toml +12 -3
  41. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/regions/uk/data/assumptions_default.toml +14 -2
  42. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/regions/uk/data/returns_history.toml +1 -1
  43. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/regions/uk/data/tax_year_2026_27.toml +26 -5
  44. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/regions/uk/extension.py +42 -6
  45. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/regions/uk/loader.py +60 -10
  46. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/regions/uk/schema.py +101 -7
  47. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/regions/uk/tax.py +204 -16
  48. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/regions/uk/wrappers.py +12 -0
  49. glidepath-0.3.0/src/glidepath/__init__.py +0 -3
  50. glidepath-0.3.0/src/glidepath/app/labels.py +0 -66
  51. {glidepath-0.3.0 → glidepath-0.5.0}/LICENSE +0 -0
  52. {glidepath-0.3.0 → glidepath-0.5.0}/LICENSE-DATA +0 -0
  53. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/app/backtest.py +0 -0
  54. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/app/files.py +0 -0
  55. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/app/firstrun.py +0 -0
  56. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/app/inspector.py +0 -0
  57. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/app/montecarlo.py +0 -0
  58. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/app/plan.py +0 -0
  59. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/app/shell.py +0 -0
  60. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/app/tables.py +0 -0
  61. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/core/backtest.py +0 -0
  62. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/core/comparison.py +0 -0
  63. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/core/config.py +0 -0
  64. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/core/contributions.py +0 -0
  65. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/core/glide.py +0 -0
  66. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/core/investments.py +0 -0
  67. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/core/money.py +0 -0
  68. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/core/periods.py +0 -0
  69. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/core/randomness.py +0 -0
  70. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/core/region.py +0 -0
  71. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/core/reporting.py +0 -0
  72. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/core/returns.py +0 -0
  73. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/core/state_pension.py +0 -0
  74. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/gui/__init__.py +0 -0
  75. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/gui/assets/icon_128.png +0 -0
  76. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/gui/assets/icon_16.png +0 -0
  77. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/gui/assets/icon_24.png +0 -0
  78. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/gui/assets/icon_256.png +0 -0
  79. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/gui/assets/icon_32.png +0 -0
  80. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/gui/assets/icon_48.png +0 -0
  81. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/gui/assets/icon_64.png +0 -0
  82. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/gui/assets/wordmark.png +0 -0
  83. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/gui/inspector.py +0 -0
  84. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/gui/main.py +0 -0
  85. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/gui/scenarios.py +0 -0
  86. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/gui/style.py +0 -0
  87. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/gui/tableview.py +0 -0
  88. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/persistence/__init__.py +0 -0
  89. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/persistence/assumptions.py +0 -0
  90. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/py.typed +0 -0
  91. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/regions/__init__.py +0 -0
  92. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/regions/uk/contributions.py +0 -0
  93. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/regions/uk/region.py +0 -0
  94. {glidepath-0.3.0 → glidepath-0.5.0}/src/glidepath/regions/uk/state_pension.py +0 -0
  95. {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.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 person, UK):
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. There are no packaged binary builds
197
- (installer/exe) yet — install from PyPI as above.
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 person, UK):
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. There are no packaged binary builds
169
- (installer/exe) yet — install from PyPI as above.
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.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-01T17:09:10Z"
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 the person's age at period
212
- start alongside (roadmap 9.11; year alone until couples activate,
213
- 9.4 — a two-person period has no single age to show). ``message``
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(row for rows in grouped.values() for row in rows)
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 person's age.
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). Only a single-person period carries an age — a
519
- two-person household has no one age to label with (revisit with
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
- if len(rows) == 1:
524
- return f"{year} · {rows[0].age_at_period_start}"
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(rows: Iterable[PeriodReportRow]) -> dict[EntityId, str]:
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
- The wrapper's kind name alone when unique; numbered in first-seen
580
- order when the household holds several of one kind (entity ids are
581
- generated UUIDs, so they are never shown as copy). Shared with the
582
- cash-flow export (9.19), which columns its balances the same way
583
- the balances chart stacks them.
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
- kinds: dict[EntityId, str] = {}
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
- kinds.setdefault(entry.wrapper_id, format_wrapper_kind(entry.kind))
589
- counts = Counter(kinds.values())
590
- numbered: Counter[str] = Counter()
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(row for rows in grouped.values() for row in rows)
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 income "
117
- "by default) — on the selected run mode's basis: met with no "
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 format_date(value)
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)