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.
Files changed (94) hide show
  1. {glidepath-0.4.0 → glidepath-0.6.0}/PKG-INFO +12 -4
  2. {glidepath-0.4.0 → glidepath-0.6.0}/README.md +11 -3
  3. {glidepath-0.4.0 → glidepath-0.6.0}/pyproject.toml +6 -2
  4. glidepath-0.6.0/src/glidepath/__init__.py +11 -0
  5. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/app/__init__.py +16 -0
  6. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/app/backtest.py +3 -3
  7. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/app/copy.py +49 -13
  8. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/app/display.py +33 -7
  9. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/app/drawdown.py +46 -7
  10. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/app/forms.py +276 -12
  11. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/app/montecarlo.py +5 -3
  12. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/app/outlook.py +125 -30
  13. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/app/plan.py +33 -2
  14. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/app/retirement.py +2 -2
  15. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/core/__init__.py +4 -0
  16. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/core/engine.py +28 -9
  17. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/core/entities.py +12 -0
  18. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/core/results.py +7 -0
  19. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/core/withdrawals.py +65 -0
  20. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/gui/forms.py +322 -53
  21. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/gui/style.py +17 -0
  22. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/gui/widgets.py +7 -3
  23. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/persistence/decode.py +25 -0
  24. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/persistence/document.py +6 -1
  25. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/persistence/encode.py +14 -0
  26. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/persistence/migrations.py +16 -0
  27. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/persistence/values.py +9 -0
  28. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/regions/uk/__init__.py +2 -0
  29. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/regions/uk/data/age_rules.toml +1 -1
  30. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/regions/uk/data/assumptions_default.toml +1 -1
  31. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/regions/uk/data/returns_history.toml +1 -1
  32. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/regions/uk/data/tax_year_2026_27.toml +10 -2
  33. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/regions/uk/extension.py +2 -0
  34. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/regions/uk/loader.py +16 -10
  35. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/regions/uk/schema.py +39 -10
  36. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/regions/uk/tax.py +70 -28
  37. glidepath-0.4.0/src/glidepath/__init__.py +0 -3
  38. {glidepath-0.4.0 → glidepath-0.6.0}/LICENSE +0 -0
  39. {glidepath-0.4.0 → glidepath-0.6.0}/LICENSE-DATA +0 -0
  40. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/app/charts.py +0 -0
  41. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/app/example.py +0 -0
  42. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/app/exports.py +0 -0
  43. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/app/files.py +0 -0
  44. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/app/firstrun.py +0 -0
  45. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/app/inspector.py +0 -0
  46. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/app/labels.py +0 -0
  47. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/app/scenarios.py +0 -0
  48. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/app/shell.py +0 -0
  49. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/app/tables.py +0 -0
  50. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/core/annuities.py +0 -0
  51. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/core/backtest.py +0 -0
  52. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/core/comparison.py +0 -0
  53. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/core/config.py +0 -0
  54. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/core/contributions.py +0 -0
  55. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/core/glide.py +0 -0
  56. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/core/investments.py +0 -0
  57. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/core/money.py +0 -0
  58. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/core/montecarlo.py +0 -0
  59. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/core/pensions.py +0 -0
  60. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/core/periods.py +0 -0
  61. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/core/provenance.py +0 -0
  62. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/core/randomness.py +0 -0
  63. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/core/region.py +0 -0
  64. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/core/reporting.py +0 -0
  65. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/core/retirement.py +0 -0
  66. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/core/returns.py +0 -0
  67. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/core/scenarios.py +0 -0
  68. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/core/state_pension.py +0 -0
  69. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/core/tax.py +0 -0
  70. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/core/wrappers.py +0 -0
  71. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/gui/__init__.py +0 -0
  72. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/gui/assets/icon_128.png +0 -0
  73. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/gui/assets/icon_16.png +0 -0
  74. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/gui/assets/icon_24.png +0 -0
  75. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/gui/assets/icon_256.png +0 -0
  76. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/gui/assets/icon_32.png +0 -0
  77. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/gui/assets/icon_48.png +0 -0
  78. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/gui/assets/icon_64.png +0 -0
  79. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/gui/assets/wordmark.png +0 -0
  80. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/gui/charts.py +0 -0
  81. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/gui/inspector.py +0 -0
  82. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/gui/main.py +0 -0
  83. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/gui/scenarios.py +0 -0
  84. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/gui/tableview.py +0 -0
  85. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/persistence/__init__.py +0 -0
  86. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/persistence/assumptions.py +0 -0
  87. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/py.typed +0 -0
  88. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/regions/__init__.py +0 -0
  89. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/regions/uk/ages.py +0 -0
  90. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/regions/uk/contributions.py +0 -0
  91. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/regions/uk/region.py +0 -0
  92. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/regions/uk/state_pension.py +0 -0
  93. {glidepath-0.4.0 → glidepath-0.6.0}/src/glidepath/regions/uk/wrappers.py +0 -0
  94. {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.4.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
+ [![PyPI](https://img.shields.io/pypi/v/glidepath)](https://pypi.org/project/glidepath/)
35
36
  [![CI](https://github.com/williajm/glidepath/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/williajm/glidepath/actions/workflows/ci.yml)
36
37
  [![Quality Gate](https://sonarcloud.io/api/project_badges/measure?project=williajm_glidepath&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=williajm_glidepath)
37
38
  [![Coverage](https://sonarcloud.io/api/project_badges/measure?project=williajm_glidepath&metric=coverage)](https://sonarcloud.io/summary/new_code?id=williajm_glidepath)
38
- [![Python 3.14](https://img.shields.io/badge/python-3.14-blue)](https://github.com/williajm/glidepath/blob/main/.python-version)
39
+ [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/williajm/glidepath/badge)](https://scorecard.dev/viewer/?uri=github.com/williajm/glidepath)
40
+ [![Python versions](https://img.shields.io/pypi/pyversions/glidepath)](https://pypi.org/project/glidepath/)
39
41
  [![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv)
40
42
  [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
41
43
  [![Checked with mypy](https://www.mypy-lang.org/static/mypy_badge.svg)](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. There are no packaged binary builds
205
- (installer/exe) yet — install from PyPI as above.
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
+ [![PyPI](https://img.shields.io/pypi/v/glidepath)](https://pypi.org/project/glidepath/)
7
8
  [![CI](https://github.com/williajm/glidepath/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/williajm/glidepath/actions/workflows/ci.yml)
8
9
  [![Quality Gate](https://sonarcloud.io/api/project_badges/measure?project=williajm_glidepath&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=williajm_glidepath)
9
10
  [![Coverage](https://sonarcloud.io/api/project_badges/measure?project=williajm_glidepath&metric=coverage)](https://sonarcloud.io/summary/new_code?id=williajm_glidepath)
10
- [![Python 3.14](https://img.shields.io/badge/python-3.14-blue)](https://github.com/williajm/glidepath/blob/main/.python-version)
11
+ [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/williajm/glidepath/badge)](https://scorecard.dev/viewer/?uri=github.com/williajm/glidepath)
12
+ [![Python versions](https://img.shields.io/pypi/pyversions/glidepath)](https://pypi.org/project/glidepath/)
11
13
  [![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv)
12
14
  [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
13
15
  [![Checked with mypy](https://www.mypy-lang.org/static/mypy_badge.svg)](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. There are no packaged binary builds
177
- (installer/exe) yet — install from PyPI as above.
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.4.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-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,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, RunConfig, run_windows
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 = RunConfig(today=today)
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, and "
63
- "any planned annuity purchase — converting part of your "
64
- "pension pot into lifetime income at an age you choose. "
65
- "Drawdown is the default: anything you do not annuitise "
66
- "stays invested, a fraction of 1 annuitises the whole pot, "
67
- "and several purchases at different ages annuitise in "
68
- "stages. "
69
- "Add one wrapper, DB, or annuity section per item. Dates can "
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 says which field to fix and nothing is saved "
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 income "
117
- "by default) — on the selected run mode's basis: met with no "
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
- if isinstance(value, bool): # before any numeric type: bool is an int
147
- return "Yes" if value else "No"
148
- if isinstance(value, Money):
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. The rest is the manifest side: the retirement age the
173
- answer assumed and whose it was (``person_position``, the
174
- household position), the searched bracket's upper bound, and the
175
- basis — ``seed``, ``paths``, and ``target_success_rate`` are
176
- carried only for a Monte Carlo basis.
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
- return f"{target}\n{basis}"
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: