glidepath 0.5.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 (93) hide show
  1. {glidepath-0.5.0 → glidepath-0.6.0}/PKG-INFO +8 -3
  2. {glidepath-0.5.0 → glidepath-0.6.0}/README.md +7 -2
  3. {glidepath-0.5.0 → glidepath-0.6.0}/pyproject.toml +1 -1
  4. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/app/__init__.py +16 -0
  5. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/app/backtest.py +3 -3
  6. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/app/copy.py +27 -13
  7. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/app/display.py +33 -7
  8. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/app/forms.py +276 -12
  9. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/app/montecarlo.py +5 -3
  10. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/app/outlook.py +125 -30
  11. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/app/plan.py +33 -2
  12. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/core/__init__.py +4 -0
  13. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/core/entities.py +12 -0
  14. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/core/results.py +7 -0
  15. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/core/withdrawals.py +65 -0
  16. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/gui/forms.py +322 -53
  17. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/gui/style.py +17 -0
  18. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/gui/widgets.py +7 -3
  19. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/persistence/decode.py +25 -0
  20. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/persistence/document.py +6 -1
  21. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/persistence/encode.py +14 -0
  22. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/persistence/migrations.py +16 -0
  23. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/persistence/values.py +9 -0
  24. {glidepath-0.5.0 → glidepath-0.6.0}/LICENSE +0 -0
  25. {glidepath-0.5.0 → glidepath-0.6.0}/LICENSE-DATA +0 -0
  26. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/__init__.py +0 -0
  27. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/app/charts.py +0 -0
  28. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/app/drawdown.py +0 -0
  29. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/app/example.py +0 -0
  30. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/app/exports.py +0 -0
  31. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/app/files.py +0 -0
  32. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/app/firstrun.py +0 -0
  33. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/app/inspector.py +0 -0
  34. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/app/labels.py +0 -0
  35. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/app/retirement.py +0 -0
  36. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/app/scenarios.py +0 -0
  37. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/app/shell.py +0 -0
  38. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/app/tables.py +0 -0
  39. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/core/annuities.py +0 -0
  40. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/core/backtest.py +0 -0
  41. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/core/comparison.py +0 -0
  42. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/core/config.py +0 -0
  43. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/core/contributions.py +0 -0
  44. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/core/engine.py +0 -0
  45. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/core/glide.py +0 -0
  46. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/core/investments.py +0 -0
  47. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/core/money.py +0 -0
  48. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/core/montecarlo.py +0 -0
  49. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/core/pensions.py +0 -0
  50. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/core/periods.py +0 -0
  51. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/core/provenance.py +0 -0
  52. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/core/randomness.py +0 -0
  53. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/core/region.py +0 -0
  54. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/core/reporting.py +0 -0
  55. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/core/retirement.py +0 -0
  56. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/core/returns.py +0 -0
  57. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/core/scenarios.py +0 -0
  58. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/core/state_pension.py +0 -0
  59. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/core/tax.py +0 -0
  60. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/core/wrappers.py +0 -0
  61. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/gui/__init__.py +0 -0
  62. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/gui/assets/icon_128.png +0 -0
  63. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/gui/assets/icon_16.png +0 -0
  64. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/gui/assets/icon_24.png +0 -0
  65. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/gui/assets/icon_256.png +0 -0
  66. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/gui/assets/icon_32.png +0 -0
  67. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/gui/assets/icon_48.png +0 -0
  68. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/gui/assets/icon_64.png +0 -0
  69. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/gui/assets/wordmark.png +0 -0
  70. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/gui/charts.py +0 -0
  71. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/gui/inspector.py +0 -0
  72. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/gui/main.py +0 -0
  73. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/gui/scenarios.py +0 -0
  74. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/gui/tableview.py +0 -0
  75. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/persistence/__init__.py +0 -0
  76. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/persistence/assumptions.py +0 -0
  77. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/py.typed +0 -0
  78. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/regions/__init__.py +0 -0
  79. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/regions/uk/__init__.py +0 -0
  80. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/regions/uk/ages.py +0 -0
  81. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/regions/uk/contributions.py +0 -0
  82. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/regions/uk/data/age_rules.toml +0 -0
  83. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/regions/uk/data/assumptions_default.toml +0 -0
  84. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/regions/uk/data/returns_history.toml +0 -0
  85. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/regions/uk/data/tax_year_2026_27.toml +0 -0
  86. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/regions/uk/extension.py +0 -0
  87. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/regions/uk/loader.py +0 -0
  88. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/regions/uk/region.py +0 -0
  89. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/regions/uk/schema.py +0 -0
  90. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/regions/uk/state_pension.py +0 -0
  91. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/regions/uk/tax.py +0 -0
  92. {glidepath-0.5.0 → glidepath-0.6.0}/src/glidepath/regions/uk/wrappers.py +0 -0
  93. {glidepath-0.5.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.5.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/)
@@ -203,7 +205,10 @@ carrying its notes from
203
205
  [`CHANGELOG.md`](https://github.com/williajm/glidepath/blob/main/CHANGELOG.md)
204
206
  with the same artifacts attached, each carrying signed build
205
207
  provenance — verify a downloaded file with
206
- `gh attestation verify <file> -R williajm/glidepath`. There are no
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
207
212
  packaged binary builds (installer/exe) yet — install from PyPI as
208
213
  above.
209
214
 
@@ -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/)
@@ -175,7 +177,10 @@ carrying its notes from
175
177
  [`CHANGELOG.md`](https://github.com/williajm/glidepath/blob/main/CHANGELOG.md)
176
178
  with the same artifacts attached, each carrying signed build
177
179
  provenance — verify a downloaded file with
178
- `gh attestation verify <file> -R williajm/glidepath`. There are no
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
179
184
  packaged binary builds (installer/exe) yet — install from PyPI as
180
185
  above.
181
186
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "glidepath"
3
- version = "0.5.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 —
@@ -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
  (
@@ -133,7 +141,10 @@ HELP_GUIDE_SECTIONS: Final[tuple[tuple[str, str], ...]] = (
133
141
  "In the projection, income already in payment — defined "
134
142
  "benefit pension, state pension, annuity income — meets your "
135
143
  "net spending need first, after the tax it bears. Whatever "
136
- "remains is withdrawn from your wrappers in one fixed order: "
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: "
137
148
  "general accounts and cash first (every pound left in them "
138
149
  "keeps accruing income tax), then ISAs and LISAs, then "
139
150
  "already-crystallised pension funds, and uncrystallised "
@@ -143,7 +154,10 @@ HELP_GUIDE_SECTIONS: Final[tuple[tuple[str, str], ...]] = (
143
154
  "which would amount to tax advice. In some years a "
144
155
  "different order could use an allowance this one leaves "
145
156
  "idle, so treat the projection as a consistent baseline, "
146
- "not the best achievable outcome."
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."
147
161
  ),
148
162
  ),
149
163
  (
@@ -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