glidepath 0.2.0__py3-none-any.whl

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/__init__.py +3 -0
  2. glidepath/app/__init__.py +364 -0
  3. glidepath/app/backtest.py +281 -0
  4. glidepath/app/charts.py +759 -0
  5. glidepath/app/copy.py +174 -0
  6. glidepath/app/display.py +148 -0
  7. glidepath/app/drawdown.py +436 -0
  8. glidepath/app/example.py +66 -0
  9. glidepath/app/exports.py +487 -0
  10. glidepath/app/files.py +249 -0
  11. glidepath/app/firstrun.py +114 -0
  12. glidepath/app/forms.py +1750 -0
  13. glidepath/app/inspector.py +506 -0
  14. glidepath/app/labels.py +66 -0
  15. glidepath/app/montecarlo.py +399 -0
  16. glidepath/app/plan.py +354 -0
  17. glidepath/app/retirement.py +446 -0
  18. glidepath/app/scenarios.py +831 -0
  19. glidepath/app/shell.py +185 -0
  20. glidepath/app/tables.py +138 -0
  21. glidepath/core/__init__.py +390 -0
  22. glidepath/core/annuities.py +240 -0
  23. glidepath/core/backtest.py +514 -0
  24. glidepath/core/comparison.py +278 -0
  25. glidepath/core/config.py +82 -0
  26. glidepath/core/contributions.py +337 -0
  27. glidepath/core/engine.py +2811 -0
  28. glidepath/core/entities.py +264 -0
  29. glidepath/core/glide.py +289 -0
  30. glidepath/core/investments.py +175 -0
  31. glidepath/core/money.py +107 -0
  32. glidepath/core/montecarlo.py +609 -0
  33. glidepath/core/pensions.py +298 -0
  34. glidepath/core/periods.py +367 -0
  35. glidepath/core/provenance.py +271 -0
  36. glidepath/core/randomness.py +128 -0
  37. glidepath/core/region.py +46 -0
  38. glidepath/core/reporting.py +231 -0
  39. glidepath/core/results.py +504 -0
  40. glidepath/core/retirement.py +291 -0
  41. glidepath/core/returns.py +312 -0
  42. glidepath/core/scenarios.py +579 -0
  43. glidepath/core/state_pension.py +264 -0
  44. glidepath/core/tax.py +139 -0
  45. glidepath/core/withdrawals.py +461 -0
  46. glidepath/core/wrappers.py +278 -0
  47. glidepath/gui/__init__.py +6 -0
  48. glidepath/gui/assets/icon_128.png +0 -0
  49. glidepath/gui/assets/icon_16.png +0 -0
  50. glidepath/gui/assets/icon_24.png +0 -0
  51. glidepath/gui/assets/icon_256.png +0 -0
  52. glidepath/gui/assets/icon_32.png +0 -0
  53. glidepath/gui/assets/icon_48.png +0 -0
  54. glidepath/gui/assets/icon_64.png +0 -0
  55. glidepath/gui/assets/wordmark.png +0 -0
  56. glidepath/gui/charts.py +829 -0
  57. glidepath/gui/forms.py +359 -0
  58. glidepath/gui/inspector.py +186 -0
  59. glidepath/gui/main.py +51 -0
  60. glidepath/gui/scenarios.py +402 -0
  61. glidepath/gui/style.py +376 -0
  62. glidepath/gui/tableview.py +67 -0
  63. glidepath/gui/widgets.py +989 -0
  64. glidepath/persistence/__init__.py +48 -0
  65. glidepath/persistence/assumptions.py +112 -0
  66. glidepath/persistence/decode.py +747 -0
  67. glidepath/persistence/document.py +101 -0
  68. glidepath/persistence/encode.py +433 -0
  69. glidepath/persistence/migrations.py +158 -0
  70. glidepath/persistence/values.py +298 -0
  71. glidepath/py.typed +0 -0
  72. glidepath/regions/__init__.py +7 -0
  73. glidepath/regions/uk/__init__.py +189 -0
  74. glidepath/regions/uk/ages.py +156 -0
  75. glidepath/regions/uk/contributions.py +717 -0
  76. glidepath/regions/uk/data/age_rules.toml +78 -0
  77. glidepath/regions/uk/data/assumptions_default.toml +170 -0
  78. glidepath/regions/uk/data/returns_history.toml +150 -0
  79. glidepath/regions/uk/data/tax_year_2026_27.toml +98 -0
  80. glidepath/regions/uk/extension.py +479 -0
  81. glidepath/regions/uk/loader.py +704 -0
  82. glidepath/regions/uk/region.py +160 -0
  83. glidepath/regions/uk/schema.py +563 -0
  84. glidepath/regions/uk/state_pension.py +129 -0
  85. glidepath/regions/uk/tax.py +466 -0
  86. glidepath/regions/uk/wrappers.py +283 -0
  87. glidepath/regions/uk/years.py +92 -0
  88. glidepath-0.2.0.dist-info/METADATA +189 -0
  89. glidepath-0.2.0.dist-info/RECORD +93 -0
  90. glidepath-0.2.0.dist-info/WHEEL +4 -0
  91. glidepath-0.2.0.dist-info/entry_points.txt +3 -0
  92. glidepath-0.2.0.dist-info/licenses/LICENSE +21 -0
  93. glidepath-0.2.0.dist-info/licenses/LICENSE-DATA +28 -0
@@ -0,0 +1,283 @@
1
+ """UK wrapper kinds and rules (roadmap 3.1, 9.2; planning §4.2, §5.1, §6).
2
+
3
+ Implements the core :class:`~glidepath.core.WrapperRuleset` protocol for
4
+ the UK wrapper kinds — workplace DC, SIPP, ISA, LISA, GIA and cash.
5
+ Every figure — the pension tax-free lump-sum fraction, the ISA and LISA
6
+ allowances, the LISA bonus rate — comes from the tax-year data files
7
+ (§5.3); nothing is hardcoded here (guard-tested). The kind-to-rules
8
+ *structure* (which kinds are pensions, which relief mechanics each may
9
+ operate) is scheme mechanics, not policy figures:
10
+
11
+ - Workplace DC schemes operate either relief mechanic: relief at source
12
+ or a net pay arrangement, per the employer's scheme.
13
+ - A SIPP is a personal pension, so contributions get relief at source.
14
+ - ISAs and LISAs attract no contribution relief; growth and withdrawals
15
+ are tax-free. The LISA's 25% government bonus is a contribution
16
+ bonus, not tax relief (it never extends tax bands), and its
17
+ contributions are age-windowed (18 to 50, pro-rated by whole months).
18
+ - GIA and cash accounts are bare taxable accounts: paid from taxed
19
+ income, growth taxable as it arises (dividends and interest feed the
20
+ §6 savings/dividend layers), withdrawals themselves tax-free. A cash
21
+ savings account prices no platform or fund charges, so the cash kind
22
+ alone is exempt from the shipped default fee assumptions
23
+ (``bears_default_fees``); the shipped cash return default is
24
+ therefore the account's whole return.
25
+ - Pension money in is tax-relieved, grows tax-free, and comes out as
26
+ taxable income after the tax-free lump-sum fraction (EET).
27
+
28
+ Access follows the §4.1 gate convention: pension kinds are gated by the
29
+ NMPA (via :class:`~glidepath.regions.uk.ages.UkAgeRules`, including the
30
+ 2028 step-up); LISAs by the charge-free access age of 60 — the engine
31
+ never models a charged early withdrawal, so the 25% withdrawal charge
32
+ ships as data but no draw ever bears it (planning §5.2 gates are
33
+ errors, never silent draws); ISAs, GIAs and cash have no access age.
34
+ Contribution caps: the ISA annual allowance is a per-person cap shared
35
+ across all ISAs and LISAs through the ``uk.isa`` allowance group, and
36
+ the LISA allowance is a sub-cap inside it (its own group); pension
37
+ kinds have no per-kind cap here — the annual allowance is a
38
+ cross-pension measure of pension input amounts and lands in roadmap
39
+ 3.3, the member relief limit with contribution schedules in 3.2.
40
+ """
41
+
42
+ from dataclasses import dataclass
43
+ from typing import TYPE_CHECKING, NoReturn
44
+
45
+ from glidepath.core import (
46
+ ContributionCap,
47
+ ContributionTaxTreatment,
48
+ ContributionTerms,
49
+ GrowthTaxTreatment,
50
+ ReliefMechanic,
51
+ WithdrawalTaxTreatment,
52
+ WrapperKindId,
53
+ WrapperTaxTreatment,
54
+ )
55
+ from glidepath.regions.uk.ages import UkAgeRules
56
+ from glidepath.regions.uk.loader import available_tax_years, load_tax_year
57
+ from glidepath.regions.uk.years import TaxYearSeries, UkTaxYearError
58
+
59
+ if TYPE_CHECKING:
60
+ from collections.abc import Mapping
61
+ from datetime import date
62
+
63
+ from glidepath.core import Money, Period
64
+ from glidepath.regions.uk.extension import FutureYearsExtension
65
+ from glidepath.regions.uk.schema import TaxYearFile
66
+
67
+ WORKPLACE_DC_KIND = WrapperKindId("uk.workplace_dc")
68
+ """An employer-sponsored defined-contribution pension scheme."""
69
+
70
+ SIPP_KIND = WrapperKindId("uk.sipp")
71
+ """A self-invested personal pension."""
72
+
73
+ ISA_KIND = WrapperKindId("uk.isa")
74
+ """A (stocks-and-shares or cash) individual savings account."""
75
+
76
+ LISA_KIND = WrapperKindId("uk.lisa")
77
+ """A lifetime ISA: bonus-bearing, age-windowed, gated until 60."""
78
+
79
+ GIA_KIND = WrapperKindId("uk.gia")
80
+ """A general investment account: bare, taxable as growth arises."""
81
+
82
+ CASH_KIND = WrapperKindId("uk.cash")
83
+ """A cash savings account outside any wrapper: interest is taxable."""
84
+
85
+ ISA_ALLOWANCE_GROUP = "uk.isa"
86
+ """Allowance group of the overall ISA annual allowance (ISA + LISA)."""
87
+
88
+ LISA_ALLOWANCE_GROUP = "uk.lisa"
89
+ """Allowance group of the LISA sub-allowance inside the overall one."""
90
+
91
+ _PENSION_KINDS = frozenset({WORKPLACE_DC_KIND, SIPP_KIND})
92
+ _TAX_FREE_SAVINGS_KINDS = frozenset({ISA_KIND, LISA_KIND})
93
+ _TAXABLE_KINDS = frozenset({GIA_KIND, CASH_KIND})
94
+ _ALL_KINDS = _PENSION_KINDS | _TAX_FREE_SAVINGS_KINDS | _TAXABLE_KINDS
95
+
96
+ _RELIEF_MECHANICS: Mapping[WrapperKindId, frozenset[ReliefMechanic]] = {
97
+ WORKPLACE_DC_KIND: frozenset(
98
+ {ReliefMechanic.RELIEF_AT_SOURCE, ReliefMechanic.NET_PAY}
99
+ ),
100
+ SIPP_KIND: frozenset({ReliefMechanic.RELIEF_AT_SOURCE}),
101
+ ISA_KIND: frozenset(),
102
+ LISA_KIND: frozenset(),
103
+ GIA_KIND: frozenset(),
104
+ CASH_KIND: frozenset(),
105
+ }
106
+
107
+
108
+ class UkWrapperError(ValueError):
109
+ """A wrapper query the shipped UK data cannot answer."""
110
+
111
+
112
+ def _unknown_kind(kind: WrapperKindId) -> NoReturn:
113
+ """Reject a wrapper kind the UK region does not define."""
114
+ msg = f"unknown UK wrapper kind {kind!r}"
115
+ raise UkWrapperError(msg)
116
+
117
+
118
+ @dataclass(frozen=True, slots=True)
119
+ class UkWrapperRuleset:
120
+ """UK implementation of the core ``WrapperRuleset`` protocol.
121
+
122
+ Holds the tax-year files its figures come from, plus the age rules
123
+ behind the pension access gate. Figure queries (``tax_treatment``,
124
+ ``annual_contribution_limit``) share the tax system's coverage
125
+ semantics: shipped data beats extrapolation, and the query's tax
126
+ year is always resolved — even when the answer needs no yearly
127
+ figure — so a query outside coverage fails loudly rather than
128
+ answering from the wrong year. Access gates read the effective-dated
129
+ age rules instead and carry no tax-year coverage constraint.
130
+ """
131
+
132
+ tax_years: tuple[TaxYearFile, ...]
133
+ ages: UkAgeRules
134
+ future_years: FutureYearsExtension | None = None
135
+
136
+ def __post_init__(self) -> None:
137
+ """Require at least one year, ascending and non-overlapping."""
138
+ try:
139
+ self._series()
140
+ except UkTaxYearError as exc:
141
+ raise UkWrapperError(str(exc)) from exc
142
+
143
+ @classmethod
144
+ def from_shipped_data(
145
+ cls, future_years: FutureYearsExtension | None = None
146
+ ) -> UkWrapperRuleset:
147
+ """Build a ruleset over every shipped data file."""
148
+ years = available_tax_years()
149
+ return cls(
150
+ tax_years=tuple(load_tax_year(year) for year in years),
151
+ ages=UkAgeRules.from_shipped_data(),
152
+ future_years=future_years,
153
+ )
154
+
155
+ def tax_treatment(self, kind: WrapperKindId, period: Period) -> WrapperTaxTreatment:
156
+ """The in/during/out treatment of ``kind`` during ``period`` (§4.2).
157
+
158
+ Pension kinds are EET with the year's tax-free lump-sum
159
+ fraction on the way out; ISAs and LISAs are TEE; GIA and cash
160
+ accounts are taxed as growth arises, with withdrawals
161
+ themselves tax-free (module docstring).
162
+ """
163
+ year = self._year_for(period)
164
+ if kind in _PENSION_KINDS:
165
+ return WrapperTaxTreatment(
166
+ contributions=ContributionTaxTreatment.TAX_RELIEVED,
167
+ growth=GrowthTaxTreatment.TAX_FREE,
168
+ withdrawals=WithdrawalTaxTreatment.PARTIALLY_TAX_FREE,
169
+ tax_free_fraction=year.pension.tax_free_lump_sum_fraction,
170
+ )
171
+ if kind in _TAX_FREE_SAVINGS_KINDS:
172
+ return WrapperTaxTreatment(
173
+ contributions=ContributionTaxTreatment.FROM_TAXED_INCOME,
174
+ growth=GrowthTaxTreatment.TAX_FREE,
175
+ withdrawals=WithdrawalTaxTreatment.TAX_FREE,
176
+ )
177
+ if kind in _TAXABLE_KINDS:
178
+ return WrapperTaxTreatment(
179
+ contributions=ContributionTaxTreatment.FROM_TAXED_INCOME,
180
+ growth=GrowthTaxTreatment.TAXABLE,
181
+ withdrawals=WithdrawalTaxTreatment.TAX_FREE,
182
+ )
183
+ _unknown_kind(kind)
184
+
185
+ def contribution_terms(
186
+ self, kind: WrapperKindId, date_of_birth: date, period: Period
187
+ ) -> ContributionTerms:
188
+ """The contribution terms of ``kind`` for one person and period.
189
+
190
+ The ISA annual allowance is per person per tax year, shared
191
+ across all their ISAs and LISAs through the ``uk.isa`` group;
192
+ the LISA allowance is a sub-cap inside it, and LISA
193
+ contributions carry the year's bonus rate within the exact
194
+ 18-to-50 contribution window (planning §6) — the engine
195
+ intersects the window with the period and the run window and
196
+ pro-rates by whole months. Pension, GIA and cash kinds are
197
+ uncapped
198
+ here — the pension annual allowance is a cross-pension measure
199
+ of pension input amounts (roadmap 3.3), not a per-kind cap.
200
+ """
201
+ year = self._year_for(period)
202
+ if kind == ISA_KIND:
203
+ return ContributionTerms(
204
+ caps=(ContributionCap(ISA_ALLOWANCE_GROUP, year.isa.annual_allowance),)
205
+ )
206
+ if kind == LISA_KIND:
207
+ return ContributionTerms(
208
+ caps=(
209
+ ContributionCap(LISA_ALLOWANCE_GROUP, year.isa.lisa_allowance),
210
+ ContributionCap(ISA_ALLOWANCE_GROUP, year.isa.annual_allowance),
211
+ ),
212
+ bonus_rate=year.isa.lisa_bonus_rate,
213
+ window=self.ages.lisa_contribution_window(date_of_birth),
214
+ )
215
+ if kind in _PENSION_KINDS or kind in _TAXABLE_KINDS:
216
+ return ContributionTerms()
217
+ _unknown_kind(kind)
218
+
219
+ def permitted_relief_mechanics(
220
+ self, kind: WrapperKindId
221
+ ) -> frozenset[ReliefMechanic]:
222
+ """The relief mechanics ``kind`` may operate (module docstring)."""
223
+ mechanics = _RELIEF_MECHANICS.get(kind)
224
+ if mechanics is None:
225
+ _unknown_kind(kind)
226
+ return mechanics
227
+
228
+ def bears_default_fees(self, kind: WrapperKindId) -> bool:
229
+ """Whether ``kind`` bears the default platform/fund fees (§7).
230
+
231
+ Cash savings accounts price no platform or fund charges — the
232
+ rate on the account is the whole deal — so the cash kind is
233
+ exempt from the shipped fee defaults (issue #118); every other
234
+ UK kind is a platform-administered account those defaults
235
+ describe. Cash *held inside* another wrapper (a SIPP or ISA
236
+ cash slice) still bears that wrapper's fees.
237
+ """
238
+ if kind not in _ALL_KINDS:
239
+ _unknown_kind(kind)
240
+ return kind != CASH_KIND
241
+
242
+ def lump_sum_allowance(self, period: Period) -> Money | None:
243
+ """The year's lump sum allowance (planning §6; roadmap 5.2).
244
+
245
+ The per-person lifetime cap on pension tax-free cash — PCLS
246
+ and the tax-free elements of UFPLS payments. The engine tracks
247
+ usage across the run; this only answers the period's figure,
248
+ with the usual coverage semantics (class docstring).
249
+ """
250
+ return self._year_for(period).pension.lump_sum_allowance
251
+
252
+ def is_access_open(
253
+ self, kind: WrapperKindId, date_of_birth: date, period: Period
254
+ ) -> bool:
255
+ """Whether *new* access to ``kind`` may open in ``period``.
256
+
257
+ Pension kinds follow the NMPA access gate (§4.1), including the
258
+ 2028 step-up; LISAs the charge-free access age of 60 (module
259
+ docstring); ISAs, GIAs and cash are always accessible. Only
260
+ *new* pension access (crystallisation, UFPLS) is gated — funds
261
+ already crystallised and benefits already in payment are never
262
+ re-gated (planning §5.1), so someone who crystallised at 55
263
+ before the 2028 step keeps drawing at 56 even while this gate
264
+ is shut.
265
+ """
266
+ if kind in _PENSION_KINDS:
267
+ return self.ages.is_pension_access_open(date_of_birth, period)
268
+ if kind == LISA_KIND:
269
+ return self.ages.is_lisa_access_open(date_of_birth, period)
270
+ if kind == ISA_KIND or kind in _TAXABLE_KINDS:
271
+ return True
272
+ _unknown_kind(kind)
273
+
274
+ def _series(self) -> TaxYearSeries:
275
+ """The shared year-resolution series over this ruleset's files."""
276
+ return TaxYearSeries(tax_years=self.tax_years, future_years=self.future_years)
277
+
278
+ def _year_for(self, period: Period) -> TaxYearFile:
279
+ """The shipped or synthesized file fully containing ``period``."""
280
+ try:
281
+ return self._series().year_for(period)
282
+ except UkTaxYearError as exc:
283
+ raise UkWrapperError(str(exc)) from exc
@@ -0,0 +1,92 @@
1
+ """Tax-year data resolution shared by the UK rule implementations (§5.3).
2
+
3
+ Both the tax system (roadmap 2.3) and the wrapper ruleset (roadmap 3.1)
4
+ answer period queries from the same per-tax-year data files, with the
5
+ same coverage semantics: shipped data always beats extrapolation, the
6
+ future-years extension (roadmap 2.5) only reaches *past* the last
7
+ shipped year, and a query outside coverage fails loudly. This module
8
+ holds that resolution once; the consumers wrap
9
+ :class:`UkTaxYearError` in their own error types.
10
+ """
11
+
12
+ from dataclasses import dataclass
13
+ from itertools import pairwise
14
+ from typing import TYPE_CHECKING
15
+
16
+ from glidepath.regions.uk.extension import extend_tax_year
17
+ from glidepath.regions.uk.schema import tax_year_start_year
18
+
19
+ if TYPE_CHECKING:
20
+ from datetime import date
21
+
22
+ from glidepath.core import Period
23
+ from glidepath.regions.uk.extension import FutureYearsExtension
24
+ from glidepath.regions.uk.schema import TaxYearFile
25
+
26
+
27
+ class UkTaxYearError(ValueError):
28
+ """A query no shipped or synthesized tax-year data can answer."""
29
+
30
+
31
+ @dataclass(frozen=True, slots=True)
32
+ class TaxYearSeries:
33
+ """An ascending run of tax-year files plus an optional extension.
34
+
35
+ The single source of "which year's figures govern this period" for
36
+ every UK ruleset (planning §5.3).
37
+ """
38
+
39
+ tax_years: tuple[TaxYearFile, ...]
40
+ future_years: FutureYearsExtension | None = None
41
+
42
+ def __post_init__(self) -> None:
43
+ """Require at least one year, ascending and non-overlapping."""
44
+ if not self.tax_years:
45
+ msg = "at least one tax-year file is required"
46
+ raise UkTaxYearError(msg)
47
+ for previous, current in pairwise(self.tax_years):
48
+ if current.meta.start_date <= previous.meta.end_date:
49
+ msg = "tax-year files must be in ascending, non-overlapping order"
50
+ raise UkTaxYearError(msg)
51
+
52
+ def year_for(self, period: Period) -> TaxYearFile:
53
+ """The shipped or synthesized file fully containing ``period``.
54
+
55
+ Raises:
56
+ UkTaxYearError: If no file covers the period's start, or the
57
+ period extends beyond that file's tax year.
58
+ """
59
+ year = self.year_containing(period.start)
60
+ if period.end > year.meta.end_date:
61
+ msg = (
62
+ f"period {period.start}..{period.end} extends beyond"
63
+ f" tax year {year.meta.tax_year}"
64
+ )
65
+ raise UkTaxYearError(msg)
66
+ return year
67
+
68
+ def year_containing(self, day: date) -> TaxYearFile:
69
+ """The shipped file covering ``day``, or an extension past the last.
70
+
71
+ Raises:
72
+ UkTaxYearError: If ``day`` falls outside shipped coverage and
73
+ the extension cannot reach it.
74
+ """
75
+ for year in self.tax_years:
76
+ if year.meta.start_date <= day <= year.meta.end_date:
77
+ return year
78
+ last = self.tax_years[-1]
79
+ if self.future_years is not None and day > last.meta.end_date:
80
+ return extend_tax_year(
81
+ last,
82
+ tax_year_start_year(day),
83
+ policy=self.future_years.policy,
84
+ cpi=self.future_years.cpi,
85
+ )
86
+ problem = (
87
+ "the future-years extension only reaches past the last shipped year"
88
+ if self.future_years is not None
89
+ else "no future-years extension is configured (planning §5.3)"
90
+ )
91
+ msg = f"no shipped tax-year data covers {day}; {problem}"
92
+ raise UkTaxYearError(msg)
@@ -0,0 +1,189 @@
1
+ Metadata-Version: 2.4
2
+ Name: glidepath
3
+ Version: 0.2.0
4
+ Summary: Desktop retirement and investment planner (UK-first, region-extensible).
5
+ License-Expression: MIT AND CC-BY-NC-SA-4.0
6
+ License-File: LICENSE
7
+ License-File: LICENSE-DATA
8
+ Requires-Dist: pyside6>=6.11.1
9
+ Requires-Python: >=3.14
10
+ Description-Content-Type: text/markdown
11
+
12
+ <p align="center">
13
+ <img src="src/glidepath/gui/assets/wordmark.png" alt="glidepath" width="420">
14
+ </p>
15
+
16
+ [![CI](https://github.com/williajm/glidepath/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/williajm/glidepath/actions/workflows/ci.yml)
17
+ [![Quality Gate](https://sonarcloud.io/api/project_badges/measure?project=williajm_glidepath&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=williajm_glidepath)
18
+ [![Coverage](https://sonarcloud.io/api/project_badges/measure?project=williajm_glidepath&metric=coverage)](https://sonarcloud.io/summary/new_code?id=williajm_glidepath)
19
+ [![Python 3.14](https://img.shields.io/badge/python-3.14-blue)](https://github.com/williajm/glidepath/blob/main/.python-version)
20
+ [![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)
21
+ [![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)
22
+ [![Checked with mypy](https://www.mypy-lang.org/static/mypy_badge.svg)](https://mypy-lang.org/)
23
+ [![License: MIT](https://img.shields.io/github/license/williajm/glidepath)](LICENSE)
24
+
25
+ <p align="center">
26
+ <a href="https://williajm.github.io/glidepath/"><b>williajm.github.io/glidepath</b></a>
27
+ </p>
28
+
29
+ A desktop retirement and investment planner. UK-first, built so other
30
+ regions can be added later. Every number in a plan is a **fact** you
31
+ stated, a **decision** you made, or an **assumption** the app defaulted
32
+ — always inspectable, never silently guessed. All data stays local;
33
+ nothing is transmitted.
34
+
35
+ <p align="center">
36
+ <img src="docs/screenshots/charts.png"
37
+ 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"
38
+ width="800">
39
+ </p>
40
+
41
+ *(All screenshots show example data, not anyone's real finances.)*
42
+
43
+ What it models today (single person, UK):
44
+
45
+ - **Wrappers** — workplace DC, SIPP, S&S ISA, LISA, GIA and cash, with
46
+ UK contribution relief mechanics and dividend/savings taxation.
47
+ - **Defined benefit pensions** — deferred entitlements or active
48
+ CARE-style accrual, with revaluation, early/late factors and
49
+ commutation; **state pension** from your official DWP forecast,
50
+ including deferral.
51
+ - **Tax** — rUK and Scottish income tax from verified 2026/27 data
52
+ files; pension allowances (AA/taper/MPAA, lump-sum allowance).
53
+ - **Projection** — deterministic or Monte Carlo runs from the app:
54
+ success rate, probability of ruin, ending-pot percentiles, and a
55
+ probability fan chart on its own tab, reproducible from a seed. With
56
+ a de-risking glide path, tax-aware decumulation with optional
57
+ go-go/slow-go/no-go retirement spending multipliers, and annuity
58
+ purchases entered in the facts form. (The engine also models
59
+ alternative withdrawal strategies — fixed %, guardrails, natural
60
+ yield — and tax-free-cash strategies; the app currently runs the
61
+ fixed-real defaults, with no strategy picker in the UI yet.)
62
+ - **"When can I retire?"** — a solver for the earliest retirement age
63
+ that sustains a target income (a replacement rate you choose, 66% of
64
+ employment income by default), met deterministically or at a Monte
65
+ Carlo success target.
66
+ - **Historical backtesting** — replays the plan over every rolling
67
+ window of world market history since 1900 (global equities in
68
+ sterling terms, UK gilts and cash, deflated by UK inflation):
69
+ the share of historical starting years the plan survives, the worst
70
+ starting year, and the range of outcomes as chart bands —
71
+ sequence-of-returns risk that independent Monte Carlo draws miss.
72
+ - **Scenarios** — named what-ifs over your decisions and assumptions,
73
+ with a side-by-side comparison; plans saved as a local JSON file.
74
+
75
+ ## More screenshots
76
+
77
+ Facts entry — everything on this screen is either a fact you state or
78
+ a choice you make; anything estimated lives in the assumptions
79
+ inspector instead:
80
+
81
+ <p align="center">
82
+ <img src="docs/screenshots/facts.png"
83
+ 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"
84
+ width="800">
85
+ </p>
86
+
87
+ Stated vs assumed — the provenance view: the facts you stated, the
88
+ choices in effect, and every assumption the run used with its value,
89
+ default/overridden status, source, and date:
90
+
91
+ <p align="center">
92
+ <img src="docs/screenshots/stated_vs_assumed.png"
93
+ alt="The stated-vs-assumed tab: tables of stated facts, choices in effect, assumptions used with sources, and the plan structure"
94
+ width="800">
95
+ </p>
96
+
97
+ ## Disclaimer
98
+
99
+ Glidepath is a personal modelling tool for exploring retirement scenarios.
100
+ It is not financial advice and is not regulated; its outputs depend on
101
+ assumptions that will not match reality. Do not make financial decisions
102
+ based solely on this tool.
103
+
104
+ ## Run
105
+
106
+ Requires only [uv](https://docs.astral.sh/uv/). Install the latest
107
+ release [from PyPI](https://pypi.org/project/glidepath/) and launch it:
108
+
109
+ ```sh
110
+ uv tool install glidepath
111
+ glidepath
112
+ ```
113
+
114
+ (`pipx install glidepath` works too. Glidepath is a desktop app
115
+ distributed through PyPI, not a library — importing `glidepath`
116
+ modules is not a supported API.)
117
+
118
+ Or run straight from a checkout:
119
+
120
+ ```sh
121
+ git clone https://github.com/williajm/glidepath.git
122
+ cd glidepath
123
+ uv run glidepath
124
+ ```
125
+
126
+ The first run from a checkout creates a virtual environment and
127
+ installs the locked dependencies automatically, then launches the
128
+ desktop app. Accept the
129
+ disclaimer and a fresh install
130
+ opens with an example plan already projected, so every tab has
131
+ something to show — replace its values with your own facts, or clear
132
+ the form and start blank. Charts label each bar with the tax year and
133
+ your age and switch between today's money and nominal; Help → "How to
134
+ use glidepath" walks through every tab. Save your plan from the File
135
+ menu as a `.glidepath.json` file you own, stored wherever you choose;
136
+ the next launch reopens your last plan automatically.
137
+
138
+ ## Developing
139
+
140
+ Development additionally requires GNU Make, which drives every workflow
141
+ command:
142
+
143
+ ```sh
144
+ make sync # create the platform venv from the lockfile (fails if the lock has drifted)
145
+ make hooks # install the pre-commit hooks (required before committing)
146
+ ```
147
+
148
+ One-time per machine: set `UV_PROJECT_ENVIRONMENT` user-wide (`.venv-win`
149
+ on Windows, `.venv-wsl` in WSL) so bare `uv` commands and the git hooks use
150
+ the same venv as make. See `CLAUDE.md`. (Skipping this only matters for a
151
+ shared Windows/WSL checkout — for trying the app, uv's default `.venv` is
152
+ fine.)
153
+
154
+ ## Everyday commands
155
+
156
+ ```sh
157
+ make check # all merge gates: ruff, format, mypy --strict, pytest (>=90% cov), dep age
158
+ make fix # auto-fix lint issues and reformat
159
+ make test # tests with coverage
160
+ make deps # the ONLY way to add/upgrade dependencies (7-day cooldown lock)
161
+ make audit # pip-audit the lockfile for known CVEs
162
+ ```
163
+
164
+ Dependencies are never added with plain `uv add`/`uv lock`: run `make deps`
165
+ so the 7-day supply-chain cooldown is applied. CI runs every `make check`
166
+ gate plus `make audit` and the SonarCloud quality gate on each PR — so a
167
+ clean `make check` locally does not quite guarantee a green pipeline. See
168
+ `CLAUDE.md` for the full policies.
169
+
170
+ ## Releases
171
+
172
+ Releases are `vX.Y.Z` tags on `main`; each GitHub Release carries its
173
+ notes from [`CHANGELOG.md`](CHANGELOG.md). There are no packaged builds
174
+ yet — run from source as above.
175
+
176
+ ## Data licences
177
+
178
+ The code is MIT-licensed (see `LICENSE`). One data file is not: the
179
+ historical return series
180
+ (`src/glidepath/regions/uk/data/returns_history.toml`) is derived from
181
+ the [JST Macrohistory Database](https://www.macrohistory.net/database/)
182
+ (Jordà, Schularick & Taylor; return series per Jordà, Knoll, Kuvshinov,
183
+ Schularick & Taylor 2019) and is distributed under
184
+ [CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/) —
185
+ attribution required, non-commercial use only, share-alike. The package
186
+ metadata declares `MIT AND CC-BY-NC-SA-4.0` accordingly; `LICENSE-DATA`
187
+ and the file's own header carry the full notice, and
188
+ `scripts/build_returns_history.py` regenerates the file from the
189
+ upstream dataset.
@@ -0,0 +1,93 @@
1
+ glidepath/__init__.py,sha256=Vi-6JCIWmCK4aG7z8SdSFe3dgy472Aw2GdB7oXREBYM,96
2
+ glidepath/app/__init__.py,sha256=Z7avWMx9HBGF7U3NhYrdQLPm-zgxTN-ek_DUT_8AN6w,9102
3
+ glidepath/app/backtest.py,sha256=An1Pt-7dZVvAZZYVimBjSWijfbQZlhSRO3gEZuihwSc,10375
4
+ glidepath/app/charts.py,sha256=ekEGk_IpkL7vcclOWLXS4jhLbLE1fq6wIqIgnVIktX0,26623
5
+ glidepath/app/copy.py,sha256=p9WuE5btMaOs8GLwZakdaHVzN1QJHvZx0bUc_rBpA8k,8567
6
+ glidepath/app/display.py,sha256=UiJ6jpa1OdGj1e_kYQjJF3QfVg9bDPV_gMzZB2Z55hk,5333
7
+ glidepath/app/drawdown.py,sha256=RAK-kInYpm8qlNBGjhi08bWk-Z0r-Rla6zHbDmRmWiw,15406
8
+ glidepath/app/example.py,sha256=mk1qG5QeItZ0TrwEQbp2jQLiV9TTfSIDvey6wdJmaXI,2659
9
+ glidepath/app/exports.py,sha256=gByQk6TyLOvJuRj9FqlB5qfSolfulCwBolcK9IRja8c,17226
10
+ glidepath/app/files.py,sha256=OgbwsLICeM-fMIs5vNowRvwWGt4TMDvwlYq7IEmUIGM,8492
11
+ glidepath/app/firstrun.py,sha256=8X9LsiwCcJAR8lgjFpk1e2osCO89DW4OSf8xYNpTJeE,3977
12
+ glidepath/app/forms.py,sha256=FRYJ-BDMUsB7fZ4XgCGRDO4qIL2eppTK82qfxzmf2lA,66976
13
+ glidepath/app/inspector.py,sha256=SVikOMErDnrJ9guNcS-ifW_J9k13h5mJqNdkG66BhwE,18009
14
+ glidepath/app/labels.py,sha256=ldY_Vjn00KoDwt8YWX5xR1FFtAjMPO6IDq15J5w2Tr0,2327
15
+ glidepath/app/montecarlo.py,sha256=14MabwPhhOyDUR3MKfgh_Wc7MyidKLZDPR6Su7sMBXY,14044
16
+ glidepath/app/plan.py,sha256=jAjSqmlCiFkDtFvpN5w6aaL-mVGJwfTI-_cN-PLKzcA,12691
17
+ glidepath/app/retirement.py,sha256=pkcAok4UKKK7ES5wFeNSe62d1u08Uvwtjtegilvd3T0,15855
18
+ glidepath/app/scenarios.py,sha256=QaWj-uStyW1v7_CdVb4OPkOknPrGz0phhK1JKmzlpLI,28316
19
+ glidepath/app/shell.py,sha256=LtSlMMGijRulXWU8SAf1HcZDi_FLTKzMgk6Ay7lQT3E,5338
20
+ glidepath/app/tables.py,sha256=Cf-9c7bt7BstdG2Tzfe9FNBfafGyEziZtAg8Zb7ZlNY,4869
21
+ glidepath/core/__init__.py,sha256=xUVGH8pt8qLlk-XM8j9gHN1DBMHx4B6VlR3DeCA00Q8,9118
22
+ glidepath/core/annuities.py,sha256=zs2oOsoCWhyEnPJrxPXzcGt-nDxkHKdrLk8n3zfAOgA,9477
23
+ glidepath/core/backtest.py,sha256=Xc0v3rD4L-gogNowt-xZDnBxqL_YU5UGyIn3PwMT5lY,20265
24
+ glidepath/core/comparison.py,sha256=6iuSZMA_Fk8UI05oBd4xF5ZJ29Bqn4lzgQ5jZkp9noU,10419
25
+ glidepath/core/config.py,sha256=1hotIfVhzbXvZ17G5Mp0up4DQ_SG6vRU4_i8ZgNyhAM,3022
26
+ glidepath/core/contributions.py,sha256=o3QT77_oO-Ki5ckOpuWBi_a2ULzu1QNYcdwToKU_430,13721
27
+ glidepath/core/engine.py,sha256=muElq7dHscX5VekhpytQCnnD6bLmpD_eQT0jtCYOjdc,126490
28
+ glidepath/core/entities.py,sha256=gg0JKjHmxjv-Ef2cU-D6f66qnu-kaCnlDwkQxuObcB0,10452
29
+ glidepath/core/glide.py,sha256=Hrm2hF9zllolTgwPxzWhpv7LoiwkYxqTaUD3o72w71Q,11427
30
+ glidepath/core/investments.py,sha256=s6XefinlQiMim76b7-4lEmkHVCOixh2sntURdVFJLFk,6177
31
+ glidepath/core/money.py,sha256=Lng5B4HAKWAopo-d-U_N7SdjpU0F3UStytSCDfe1BIM,3812
32
+ glidepath/core/montecarlo.py,sha256=oyiC3qjk45gSWY_rTdrWnsszd-Y3-yYcx89nOJYKwQc,25350
33
+ glidepath/core/pensions.py,sha256=YXoFgkkFXrSN-yHOm8SM6Bqbt3N8_b5KtjkIZ7kDnUQ,12407
34
+ glidepath/core/periods.py,sha256=L1ecdl--bFW8kWWxFrxO4fUuANO3myv2-eAJL733bJs,13978
35
+ glidepath/core/provenance.py,sha256=BMflWxqO0zNwwhLdQx3Gz-m0AFG8RtmmLOvmBujBkrE,9272
36
+ glidepath/core/randomness.py,sha256=gM28ebJWV7Yi5mPiHIc5vhaF2yw-FRiGwaNySSljbIo,5470
37
+ glidepath/core/region.py,sha256=zaw5dBI_62dYhnmDtIs4J0zhNkg1z1oeXeXAyERhDU8,1742
38
+ glidepath/core/reporting.py,sha256=qeHw0QrKs-N1vTVc9uYlh1oA_JcqKBEh3pmZm2U0VIE,8552
39
+ glidepath/core/results.py,sha256=r1BseA-Grv-gnorHCjAhcV__tMrS26t1lsKrfxdHEWI,20518
40
+ glidepath/core/retirement.py,sha256=eFpf0_vofntqlN-23MoDWhijEaUHhty4R2ErZD60uzA,12453
41
+ glidepath/core/returns.py,sha256=JpBaV7sAt6jNKrBTe6MyIfbLMsqzUGOzUk-_359GEDA,12584
42
+ glidepath/core/scenarios.py,sha256=YZ1lkS9M8Y28wmbJVkM4v9DG0ij4btwC_1RJpFlD9PI,22466
43
+ glidepath/core/state_pension.py,sha256=vmBksTi2ZEd8mfW5H7tLjF9zY7PfeqnpLQpYGGQGL-Y,10557
44
+ glidepath/core/tax.py,sha256=1befR5b1p7knvyKSavvo9qwnwmOJeDpLsz1Z0O1IDs4,5134
45
+ glidepath/core/withdrawals.py,sha256=LlQ-7GgsjV-_yalTd1OdxwLUqL3NdoECQ2VPJNqqSHU,19640
46
+ glidepath/core/wrappers.py,sha256=DSg4leOBmrcvPhjuOFNeez6h6NAFuOxFlp92srAk6xc,11213
47
+ glidepath/gui/__init__.py,sha256=MNnse5aChZ4X-xWHc646jfYxQjwdJoVnyKT8OcWXuHM,266
48
+ glidepath/gui/assets/icon_128.png,sha256=Mo1TMH1xskDBFBUh2vr4s695OIVdAhdf4yXgycgfAIM,12367
49
+ glidepath/gui/assets/icon_16.png,sha256=npOGODf3xu2MZ0H3pvs7lQPY1WwP2sBeWOIVPly59vM,524
50
+ glidepath/gui/assets/icon_24.png,sha256=7VOticK7lUklv7gfQfSaqtCWAUFdyqZXjxFitDt3r3M,910
51
+ glidepath/gui/assets/icon_256.png,sha256=LC9Qfqyn5y9Q1XXRUQSpo8D9xePPso0M0LN5MU4hH8k,47043
52
+ glidepath/gui/assets/icon_32.png,sha256=sOYguevAWV3iVOfhw_V12y3YMvJY9cuR0a3eI5VyF2I,1349
53
+ glidepath/gui/assets/icon_48.png,sha256=ZOV6Eb-2hIVKgMcsYQv28t2aF4PuaWX9rNIqM37_5nQ,2476
54
+ glidepath/gui/assets/icon_64.png,sha256=HIXqq4oiEN0560296QzhBAZ1TjlmwCKnm2LoeRHw1NM,3886
55
+ glidepath/gui/assets/wordmark.png,sha256=rzZvG2fyq3V5O3LRBaDxALlA7JVEK0NtFPLE5hIoGs0,139710
56
+ glidepath/gui/charts.py,sha256=s6MA1SW9VO6AA613oOZsB1X-CcBaKhx5wCV0Voorkqg,34168
57
+ glidepath/gui/forms.py,sha256=UBENTCZrice_SndzhsOSj6_CgT4C1pEyDMYJdOC7LSE,14087
58
+ glidepath/gui/inspector.py,sha256=8aljbZaC5qWXiaA30ASoKuiP3dKMF5l6cmSZOiE-Bf4,6990
59
+ glidepath/gui/main.py,sha256=H14yrEdQS5ZwnRpF85KGlFapk80wwEO0PBfjWPG7Kko,1829
60
+ glidepath/gui/scenarios.py,sha256=Ozal-Tm7b7i65E-DavzGuccYxUMLrRRvyUFnKoJ2ctE,16651
61
+ glidepath/gui/style.py,sha256=GrEEnMzV8emmKu1sOx6xxVcJoIDHzDO7sxZF8BJ0rIk,10036
62
+ glidepath/gui/tableview.py,sha256=ePJE1vNRWq7p_lDTqSRf5R88WAdlMj_koIiDrD1koig,2331
63
+ glidepath/gui/widgets.py,sha256=cdQl6pf6hjXjSxcT6cd83B_pJsRa2pKW9xSv2SDQ8fs,39509
64
+ glidepath/persistence/__init__.py,sha256=g84BDLvNuz9nIF1dYMEWmyd4__qNTDlPu-FTZyeXFjU,1364
65
+ glidepath/persistence/assumptions.py,sha256=jhwIxznXOFrmzNOjWrKAxTXvrQVMEReg3DI-Iowb_7I,4328
66
+ glidepath/persistence/decode.py,sha256=lSXEsyhoTw6yG6WXx436IydlCA6JiAT7eVRZeaAJ5Ig,25173
67
+ glidepath/persistence/document.py,sha256=6QRWaKy6yZtWemBW_hoQu0utBxFsnLYGr1NK-s6UtKU,3998
68
+ glidepath/persistence/encode.py,sha256=5qNEsHawdb-N6uiFPnCTPlOClZCeKsiwsQtQGXPq6ag,15209
69
+ glidepath/persistence/migrations.py,sha256=kAzbfZX9Qvu3YxDMD2afrr3TdDgLGgV0_7-O4iO-qlA,6461
70
+ glidepath/persistence/values.py,sha256=KbdVMnSCkgBZOAC3GHTysclTH5dbqq0rUQbhJwvU2NA,9857
71
+ glidepath/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
72
+ glidepath/regions/__init__.py,sha256=KVe-NFgzrNCKjCLUpGkyilNCZywKNXGr0P811vorztk,362
73
+ glidepath/regions/uk/__init__.py,sha256=Ma0wbPoMbS66pCUhL71OlJDbISiaCHBDVTZWr9Qa0HA,4542
74
+ glidepath/regions/uk/ages.py,sha256=Syr0DZ2IhEWkIcpnRoFVXOO5sWpxn2dHYdyVt4FPP1Q,6434
75
+ glidepath/regions/uk/contributions.py,sha256=yCs3uJv8b41ZHxLheRjVLecNtQ1ZyDyGMQUdbATS7aY,30879
76
+ glidepath/regions/uk/data/age_rules.toml,sha256=l8AiAJl27VmkgNthjnYJJ92biFz2c3hHq3l4BlFehCA,4114
77
+ glidepath/regions/uk/data/assumptions_default.toml,sha256=tjB0Nv9006yrfyCnsc2dMP3mi8J_1ob1iHNBMAJ5HyU,6515
78
+ glidepath/regions/uk/data/returns_history.toml,sha256=x0I5PgFRv_iGQNpxRA04bmm6RENXDrcDWkY9I_cWZ88,13003
79
+ glidepath/regions/uk/data/tax_year_2026_27.toml,sha256=bIeF1z8JdmhFZAjkwZAI7hNvdAyCWiBhXoTbc1p0A3w,4301
80
+ glidepath/regions/uk/extension.py,sha256=xrTxlEm1FA028AJR9OAjM4qffwKEzJ84jMgHE59icmE,19720
81
+ glidepath/regions/uk/loader.py,sha256=OMi0qyuLEKjxjPQ9vByBEho3Q1xL-jF908Mumv-698Y,25812
82
+ glidepath/regions/uk/region.py,sha256=OWAM1-jIqybX3nqMNHL8-m64lrDUgU5GO3x2mCfHq40,6629
83
+ glidepath/regions/uk/schema.py,sha256=ojCp-8PFstxIcoQpcxRtaUD-5GD_zERJTspfZWuU9EI,19878
84
+ glidepath/regions/uk/state_pension.py,sha256=Pcaq-TiMp4zkN9EMfLwMTwHI2Ntb4B_ybiGhQcIb8Ao,5102
85
+ glidepath/regions/uk/tax.py,sha256=hYnQRz8kLGqn3iUqHkdc-lTTA4RZnTODBj6BJRI66vE,18666
86
+ glidepath/regions/uk/wrappers.py,sha256=dKW8QMC0BFeHII02lC7XdVsx4sRRaqVNf6g_GdOX-sU,12319
87
+ glidepath/regions/uk/years.py,sha256=3J-AuuugkWif-vn-RTwQ8S2C8CWByAgan0VikxH1Jkw,3544
88
+ glidepath-0.2.0.dist-info/licenses/LICENSE,sha256=jZIkRQTP4p9jTFpLIOYNhSic-5vp3eK7RGTe3Qpcvbo,1065
89
+ glidepath-0.2.0.dist-info/licenses/LICENSE-DATA,sha256=dCGRR-wYyTIvTWSZ_L9Za6Hy2VaZoMFZyfT7EVqZgVU,1256
90
+ glidepath-0.2.0.dist-info/WHEEL,sha256=l3MmIxu8qaet7ng2J9fFoJnYGj8IREj7jXTbgsuzmy4,81
91
+ glidepath-0.2.0.dist-info/entry_points.txt,sha256=PqQpL7yA9dpYn3ebDjHc3e0rpNfV4D5pD5BL9-sH0uc,51
92
+ glidepath-0.2.0.dist-info/METADATA,sha256=il6iMNMrsYnLZJD6vzfLBiKbKEeXdHsnJ9gS_CK3MFs,8646
93
+ glidepath-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.11.32
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [gui_scripts]
2
+ glidepath = glidepath.gui.main:main
3
+