glidepath 0.2.1__tar.gz → 0.4.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.2.1 → glidepath-0.4.0}/PKG-INFO +29 -13
  2. {glidepath-0.2.1 → glidepath-0.4.0}/README.md +24 -10
  3. {glidepath-0.2.1 → glidepath-0.4.0}/pyproject.toml +25 -31
  4. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/app/__init__.py +16 -0
  5. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/app/backtest.py +1 -1
  6. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/app/charts.py +48 -32
  7. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/app/display.py +12 -3
  8. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/app/drawdown.py +76 -17
  9. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/app/example.py +7 -3
  10. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/app/exports.py +1 -1
  11. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/app/forms.py +530 -119
  12. glidepath-0.4.0/src/glidepath/app/labels.py +116 -0
  13. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/app/montecarlo.py +1 -1
  14. glidepath-0.4.0/src/glidepath/app/outlook.py +595 -0
  15. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/app/retirement.py +103 -21
  16. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/app/scenarios.py +126 -8
  17. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/core/__init__.py +10 -3
  18. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/core/annuities.py +29 -2
  19. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/core/backtest.py +5 -2
  20. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/core/engine.py +1457 -617
  21. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/core/entities.py +30 -17
  22. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/core/money.py +3 -1
  23. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/core/montecarlo.py +65 -8
  24. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/core/pensions.py +28 -11
  25. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/core/provenance.py +2 -0
  26. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/core/results.py +89 -47
  27. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/core/retirement.py +112 -44
  28. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/core/scenarios.py +95 -6
  29. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/core/tax.py +55 -5
  30. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/core/withdrawals.py +40 -23
  31. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/core/wrappers.py +26 -3
  32. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/gui/charts.py +115 -51
  33. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/gui/forms.py +159 -18
  34. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/gui/main.py +13 -3
  35. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/gui/widgets.py +26 -3
  36. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/persistence/decode.py +38 -1
  37. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/persistence/document.py +19 -1
  38. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/persistence/encode.py +43 -5
  39. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/persistence/migrations.py +102 -1
  40. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/persistence/values.py +12 -0
  41. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/regions/uk/__init__.py +6 -0
  42. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/regions/uk/ages.py +15 -0
  43. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/regions/uk/data/age_rules.toml +12 -3
  44. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/regions/uk/data/assumptions_default.toml +14 -2
  45. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/regions/uk/data/returns_history.toml +1 -1
  46. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/regions/uk/data/tax_year_2026_27.toml +17 -4
  47. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/regions/uk/extension.py +43 -7
  48. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/regions/uk/loader.py +44 -0
  49. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/regions/uk/schema.py +67 -2
  50. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/regions/uk/tax.py +148 -2
  51. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/regions/uk/wrappers.py +12 -0
  52. glidepath-0.2.1/src/glidepath/app/labels.py +0 -66
  53. {glidepath-0.2.1 → glidepath-0.4.0}/LICENSE +0 -0
  54. {glidepath-0.2.1 → glidepath-0.4.0}/LICENSE-DATA +0 -0
  55. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/__init__.py +0 -0
  56. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/app/copy.py +0 -0
  57. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/app/files.py +0 -0
  58. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/app/firstrun.py +0 -0
  59. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/app/inspector.py +0 -0
  60. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/app/plan.py +0 -0
  61. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/app/shell.py +0 -0
  62. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/app/tables.py +0 -0
  63. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/core/comparison.py +0 -0
  64. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/core/config.py +0 -0
  65. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/core/contributions.py +0 -0
  66. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/core/glide.py +0 -0
  67. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/core/investments.py +0 -0
  68. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/core/periods.py +0 -0
  69. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/core/randomness.py +0 -0
  70. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/core/region.py +0 -0
  71. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/core/reporting.py +0 -0
  72. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/core/returns.py +0 -0
  73. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/core/state_pension.py +0 -0
  74. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/gui/__init__.py +0 -0
  75. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/gui/assets/icon_128.png +0 -0
  76. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/gui/assets/icon_16.png +0 -0
  77. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/gui/assets/icon_24.png +0 -0
  78. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/gui/assets/icon_256.png +0 -0
  79. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/gui/assets/icon_32.png +0 -0
  80. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/gui/assets/icon_48.png +0 -0
  81. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/gui/assets/icon_64.png +0 -0
  82. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/gui/assets/wordmark.png +0 -0
  83. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/gui/inspector.py +0 -0
  84. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/gui/scenarios.py +0 -0
  85. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/gui/style.py +0 -0
  86. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/gui/tableview.py +0 -0
  87. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/persistence/__init__.py +0 -0
  88. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/persistence/assumptions.py +0 -0
  89. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/py.typed +0 -0
  90. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/regions/__init__.py +0 -0
  91. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/regions/uk/contributions.py +0 -0
  92. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/regions/uk/region.py +0 -0
  93. {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/regions/uk/state_pension.py +0 -0
  94. {glidepath-0.2.1 → glidepath-0.4.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.2.1
3
+ Version: 0.4.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
@@ -12,11 +12,13 @@ Classifier: Environment :: Win32 (MS Windows)
12
12
  Classifier: Environment :: X11 Applications :: Qt
13
13
  Classifier: Intended Audience :: End Users/Desktop
14
14
  Classifier: Intended Audience :: Financial and Insurance Industry
15
- Classifier: Operating System :: OS Independent
15
+ Classifier: Operating System :: Microsoft :: Windows
16
+ Classifier: Operating System :: POSIX :: Linux
16
17
  Classifier: Programming Language :: Python :: 3 :: Only
17
18
  Classifier: Programming Language :: Python :: 3.14
18
19
  Classifier: Topic :: Office/Business :: Financial :: Investment
19
- Requires-Dist: pyside6>=6.11.1
20
+ Classifier: Typing :: Typed
21
+ Requires-Dist: pyside6==6.11.1
20
22
  Requires-Python: >=3.14
21
23
  Project-URL: Homepage, https://williajm.github.io/glidepath/
22
24
  Project-URL: Repository, https://github.com/williajm/glidepath
@@ -24,8 +26,10 @@ Project-URL: Changelog, https://github.com/williajm/glidepath/blob/main/CHANGELO
24
26
  Project-URL: Issues, https://github.com/williajm/glidepath/issues
25
27
  Description-Content-Type: text/markdown
26
28
 
29
+ <!-- Image and badge URLs are absolute so the README renders on PyPI,
30
+ which does not resolve repository-relative paths. -->
27
31
  <p align="center">
28
- <img src="src/glidepath/gui/assets/wordmark.png" alt="glidepath" width="420">
32
+ <img src="https://raw.githubusercontent.com/williajm/glidepath/main/src/glidepath/gui/assets/wordmark.png" alt="glidepath" width="420">
29
33
  </p>
30
34
 
31
35
  [![CI](https://github.com/williajm/glidepath/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/williajm/glidepath/actions/workflows/ci.yml)
@@ -35,7 +39,7 @@ Description-Content-Type: text/markdown
35
39
  [![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)
36
40
  [![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)
37
41
  [![Checked with mypy](https://www.mypy-lang.org/static/mypy_badge.svg)](https://mypy-lang.org/)
38
- [![License: MIT](https://img.shields.io/github/license/williajm/glidepath)](LICENSE)
42
+ [![License: MIT + CC BY-NC-SA 4.0 data](https://img.shields.io/badge/license-MIT%20%2B%20CC%20BY--NC--SA%204.0%20data-blue)](https://github.com/williajm/glidepath/blob/main/LICENSE)
39
43
 
40
44
  <p align="center">
41
45
  <a href="https://williajm.github.io/glidepath/"><b>williajm.github.io/glidepath</b></a>
@@ -48,14 +52,14 @@ stated, a **decision** you made, or an **assumption** the app defaulted
48
52
  nothing is transmitted.
49
53
 
50
54
  <p align="center">
51
- <img src="docs/screenshots/charts.png"
55
+ <img src="https://raw.githubusercontent.com/williajm/glidepath/main/docs/screenshots/charts.png"
52
56
  alt="The charts tab: the Monte Carlo fan chart — nested percentile bands deepening toward the median line — beside the success-rate readout and the retirement-age and backtest cards"
53
57
  width="800">
54
58
  </p>
55
59
 
56
60
  *(All screenshots show example data, not anyone's real finances.)*
57
61
 
58
- What it models today (single person, UK):
62
+ What it models today (UK, single or couple):
59
63
 
60
64
  - **Wrappers** — workplace DC, SIPP, S&S ISA, LISA, GIA and cash, with
61
65
  UK contribution relief mechanics and dividend/savings taxation.
@@ -65,6 +69,14 @@ What it models today (single person, UK):
65
69
  including deferral.
66
70
  - **Tax** — rUK and Scottish income tax from verified 2026/27 data
67
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.
68
80
  - **Projection** — deterministic or Monte Carlo runs from the app:
69
81
  success rate, probability of ruin, ending-pot percentiles, and a
70
82
  probability fan chart on its own tab, reproducible from a seed. With
@@ -94,7 +106,7 @@ a choice you make; anything estimated lives in the assumptions
94
106
  inspector instead:
95
107
 
96
108
  <p align="center">
97
- <img src="docs/screenshots/facts.png"
109
+ <img src="https://raw.githubusercontent.com/williajm/glidepath/main/docs/screenshots/facts.png"
98
110
  alt="The facts tab: the About you, Household spending, and State pension cards of the entry form, with the example plan's values filled in"
99
111
  width="800">
100
112
  </p>
@@ -104,7 +116,7 @@ choices in effect, and every assumption the run used with its value,
104
116
  default/overridden status, source, and date:
105
117
 
106
118
  <p align="center">
107
- <img src="docs/screenshots/stated_vs_assumed.png"
119
+ <img src="https://raw.githubusercontent.com/williajm/glidepath/main/docs/screenshots/stated_vs_assumed.png"
108
120
  alt="The stated-vs-assumed tab: tables of stated facts, choices in effect, assumptions used with sources, and the plan structure"
109
121
  width="800">
110
122
  </p>
@@ -169,7 +181,7 @@ fine.)
169
181
  ## Everyday commands
170
182
 
171
183
  ```sh
172
- make check # all merge gates: ruff, format, mypy --strict, pytest (>=90% cov), dep age
184
+ make check # all merge gates: ruff, format, mypy --strict, pytest (>=96% cov), dep age
173
185
  make fix # auto-fix lint issues and reformat
174
186
  make test # tests with coverage
175
187
  make deps # the ONLY way to add/upgrade dependencies (7-day cooldown lock)
@@ -184,9 +196,13 @@ clean `make check` locally does not quite guarantee a green pipeline. See
184
196
 
185
197
  ## Releases
186
198
 
187
- Releases are `vX.Y.Z` tags on `main`; each GitHub Release carries its
188
- notes from [`CHANGELOG.md`](CHANGELOG.md). There are no packaged builds
189
- yet — run from source as above.
199
+ Releases are `vX.Y.Z` tags on `main`. Each one is published to
200
+ [PyPI](https://pypi.org/project/glidepath/) as an sdist and wheel via
201
+ trusted publishing with PEP 740 attestations, and as a GitHub Release
202
+ carrying its notes from
203
+ [`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.
190
206
 
191
207
  ## Data licences
192
208
 
@@ -1,5 +1,7 @@
1
+ <!-- Image and badge URLs are absolute so the README renders on PyPI,
2
+ which does not resolve repository-relative paths. -->
1
3
  <p align="center">
2
- <img src="src/glidepath/gui/assets/wordmark.png" alt="glidepath" width="420">
4
+ <img src="https://raw.githubusercontent.com/williajm/glidepath/main/src/glidepath/gui/assets/wordmark.png" alt="glidepath" width="420">
3
5
  </p>
4
6
 
5
7
  [![CI](https://github.com/williajm/glidepath/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/williajm/glidepath/actions/workflows/ci.yml)
@@ -9,7 +11,7 @@
9
11
  [![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)
10
12
  [![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)
11
13
  [![Checked with mypy](https://www.mypy-lang.org/static/mypy_badge.svg)](https://mypy-lang.org/)
12
- [![License: MIT](https://img.shields.io/github/license/williajm/glidepath)](LICENSE)
14
+ [![License: MIT + CC BY-NC-SA 4.0 data](https://img.shields.io/badge/license-MIT%20%2B%20CC%20BY--NC--SA%204.0%20data-blue)](https://github.com/williajm/glidepath/blob/main/LICENSE)
13
15
 
14
16
  <p align="center">
15
17
  <a href="https://williajm.github.io/glidepath/"><b>williajm.github.io/glidepath</b></a>
@@ -22,14 +24,14 @@ stated, a **decision** you made, or an **assumption** the app defaulted
22
24
  nothing is transmitted.
23
25
 
24
26
  <p align="center">
25
- <img src="docs/screenshots/charts.png"
27
+ <img src="https://raw.githubusercontent.com/williajm/glidepath/main/docs/screenshots/charts.png"
26
28
  alt="The charts tab: the Monte Carlo fan chart — nested percentile bands deepening toward the median line — beside the success-rate readout and the retirement-age and backtest cards"
27
29
  width="800">
28
30
  </p>
29
31
 
30
32
  *(All screenshots show example data, not anyone's real finances.)*
31
33
 
32
- What it models today (single person, UK):
34
+ What it models today (UK, single or couple):
33
35
 
34
36
  - **Wrappers** — workplace DC, SIPP, S&S ISA, LISA, GIA and cash, with
35
37
  UK contribution relief mechanics and dividend/savings taxation.
@@ -39,6 +41,14 @@ What it models today (single person, UK):
39
41
  including deferral.
40
42
  - **Tax** — rUK and Scottish income tax from verified 2026/27 data
41
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.
42
52
  - **Projection** — deterministic or Monte Carlo runs from the app:
43
53
  success rate, probability of ruin, ending-pot percentiles, and a
44
54
  probability fan chart on its own tab, reproducible from a seed. With
@@ -68,7 +78,7 @@ a choice you make; anything estimated lives in the assumptions
68
78
  inspector instead:
69
79
 
70
80
  <p align="center">
71
- <img src="docs/screenshots/facts.png"
81
+ <img src="https://raw.githubusercontent.com/williajm/glidepath/main/docs/screenshots/facts.png"
72
82
  alt="The facts tab: the About you, Household spending, and State pension cards of the entry form, with the example plan's values filled in"
73
83
  width="800">
74
84
  </p>
@@ -78,7 +88,7 @@ choices in effect, and every assumption the run used with its value,
78
88
  default/overridden status, source, and date:
79
89
 
80
90
  <p align="center">
81
- <img src="docs/screenshots/stated_vs_assumed.png"
91
+ <img src="https://raw.githubusercontent.com/williajm/glidepath/main/docs/screenshots/stated_vs_assumed.png"
82
92
  alt="The stated-vs-assumed tab: tables of stated facts, choices in effect, assumptions used with sources, and the plan structure"
83
93
  width="800">
84
94
  </p>
@@ -143,7 +153,7 @@ fine.)
143
153
  ## Everyday commands
144
154
 
145
155
  ```sh
146
- make check # all merge gates: ruff, format, mypy --strict, pytest (>=90% cov), dep age
156
+ make check # all merge gates: ruff, format, mypy --strict, pytest (>=96% cov), dep age
147
157
  make fix # auto-fix lint issues and reformat
148
158
  make test # tests with coverage
149
159
  make deps # the ONLY way to add/upgrade dependencies (7-day cooldown lock)
@@ -158,9 +168,13 @@ clean `make check` locally does not quite guarantee a green pipeline. See
158
168
 
159
169
  ## Releases
160
170
 
161
- Releases are `vX.Y.Z` tags on `main`; each GitHub Release carries its
162
- notes from [`CHANGELOG.md`](CHANGELOG.md). There are no packaged builds
163
- yet — run from source as above.
171
+ Releases are `vX.Y.Z` tags on `main`. Each one is published to
172
+ [PyPI](https://pypi.org/project/glidepath/) as an sdist and wheel via
173
+ trusted publishing with PEP 740 attestations, and as a GitHub Release
174
+ carrying its notes from
175
+ [`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.
164
178
 
165
179
  ## Data licences
166
180
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "glidepath"
3
- version = "0.2.1"
3
+ version = "0.4.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 —
@@ -27,13 +27,21 @@ classifiers = [
27
27
  "Environment :: X11 Applications :: Qt",
28
28
  "Intended Audience :: End Users/Desktop",
29
29
  "Intended Audience :: Financial and Insurance Industry",
30
- "Operating System :: OS Independent",
30
+ # The platforms the suite actually runs on (ci.yml); no macOS claim
31
+ # until it is tested there.
32
+ "Operating System :: Microsoft :: Windows",
33
+ "Operating System :: POSIX :: Linux",
31
34
  "Programming Language :: Python :: 3 :: Only",
32
35
  "Programming Language :: Python :: 3.14",
33
36
  "Topic :: Office/Business :: Financial :: Investment",
37
+ "Typing :: Typed",
34
38
  ]
39
+ # glidepath is an application, not a library: the runtime dependency is
40
+ # pinned exactly so `uv tool install glidepath` installs the same PySide6
41
+ # the release was tested against (uv.lock does not apply to end users).
42
+ # Bump the pin only via `make deps`.
35
43
  dependencies = [
36
- "pyside6>=6.11.1",
44
+ "pyside6==6.11.1",
37
45
  ]
38
46
 
39
47
  [project.urls]
@@ -65,7 +73,7 @@ build-backend = "uv_build"
65
73
  [tool.uv]
66
74
  # Supply-chain cooldown: resolution ignores anything published to PyPI after
67
75
  # this timestamp (now minus 7 days). Updated ONLY by `make deps`.
68
- exclude-newer = "2026-07-30T09:40:45Z"
76
+ exclude-newer = "2026-08-01T17:09:10Z"
69
77
 
70
78
  # PyPI is the ONLY permitted index (supply-chain policy; see CLAUDE.md).
71
79
  [[tool.uv.index]]
@@ -91,34 +99,16 @@ convention = "google"
91
99
  [tool.ruff.lint.mccabe]
92
100
  max-complexity = 10
93
101
 
102
+ # BLE001 (app/ slow-run transitions) and PLR0913 (§5.2 runner signatures)
103
+ # are granted inline at the specific lines that earned them, not file-wide,
104
+ # so new violations elsewhere in those modules still fail lint.
94
105
  [tool.ruff.lint.per-file-ignores]
95
106
  "src/glidepath/core/randomness.py" = [
96
107
  "S311", # Seeded Monte Carlo simulation draws, never security material (planning §4.6).
97
108
  ]
98
- "src/glidepath/core/montecarlo.py" = [
99
- "PLR0913", # run_paths/sustainable_income take the §5.2 engine quartet plus keyword-only run knobs; bundling would obscure the run(plan, assumptions, region, config) shape.
100
- ]
101
- "src/glidepath/core/backtest.py" = [
102
- "PLR0913", # run_windows and its chunk worker mirror the §5.2 runner signature (see core/montecarlo.py).
103
- ]
104
109
  "scripts/build_returns_history.py" = [
105
110
  "S314", # xml.etree parses the JST workbook the developer supplies locally, never untrusted input.
106
111
  ]
107
- "src/glidepath/core/retirement.py" = [
108
- "PLR0913", # earliest_retirement_age mirrors the §5.2 runner signature (see core/montecarlo.py).
109
- ]
110
- "src/glidepath/app/montecarlo.py" = [
111
- "BLE001", # The slow-run transition must fold ANY failure into state (§4.7): an exception escaping past the shell's worker thread holds the in-flight guard forever.
112
- ]
113
- "src/glidepath/app/retirement.py" = [
114
- "BLE001", # Same in-flight-guard rationale as app/montecarlo.py.
115
- ]
116
- "src/glidepath/app/drawdown.py" = [
117
- "BLE001", # Same in-flight-guard rationale as app/montecarlo.py.
118
- ]
119
- "src/glidepath/app/backtest.py" = [
120
- "BLE001", # Same in-flight-guard rationale as app/montecarlo.py.
121
- ]
122
112
  "tests/**" = [
123
113
  "S101", # pytest tests use bare assert by design.
124
114
  "PLR2004", # Literal expected values in assertions are idiomatic in tests.
@@ -133,12 +123,15 @@ max-complexity = 10
133
123
  # --- mypy -------------------------------------------------------------------
134
124
  [tool.mypy]
135
125
  strict = true
136
- warn_unused_ignores = true
126
+ # Not implied by strict: flags code mypy can prove unreachable — high-yield
127
+ # in Decimal/None-narrowing-heavy code.
128
+ warn_unreachable = true
137
129
  python_version = "3.14"
138
130
  files = ["src", "tests", "scripts"]
139
131
  # Lets the tests' top-level script imports (e.g. `import check_dep_age`)
140
- # resolve to scripts/ — mirrors pytest's pythonpath below.
141
- mypy_path = ["scripts"]
132
+ # and the shared test factories (`import factories`) resolve — mirrors
133
+ # pytest's pythonpath below.
134
+ mypy_path = ["scripts", "tests"]
142
135
 
143
136
  # --- pytest / coverage ------------------------------------------------------
144
137
  [tool.pytest.ini_options]
@@ -152,9 +145,10 @@ addopts = [
152
145
  "--cov-report=xml",
153
146
  ]
154
147
  testpaths = ["tests"]
155
- # scripts/ is not a package; tests import the operational scripts (the
156
- # gates behind `make deps`/`make bump`/releases) as top-level modules.
157
- pythonpath = ["scripts"]
148
+ # Neither scripts/ nor tests/ is a package; tests import the operational
149
+ # scripts (the gates behind `make deps`/`make bump`/releases) and the
150
+ # shared test-data factories (tests/factories.py) as top-level modules.
151
+ pythonpath = ["scripts", "tests"]
158
152
  # Fail a stuck test fast instead of hanging CI for the runner limit: the
159
153
  # GUI suite waits on worker pools for up to 60s, so the ceiling sits
160
154
  # safely above that while still cutting a wedged run short.
@@ -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",
@@ -132,7 +132,7 @@ def state_with_backtest(state: PlanState, *, today: date) -> PlanState:
132
132
  config,
133
133
  series=series,
134
134
  )
135
- except Exception as exc:
135
+ except Exception as exc: # noqa: BLE001
136
136
  # Broad by design, mirroring the Monte Carlo transition: an
137
137
  # escape past the shell's worker thread would leave the
138
138
  # in-flight guard held (buttons disabled, spinner running)
@@ -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():
@@ -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)