glidepath 0.4.0__tar.gz → 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. {glidepath-0.4.0 → glidepath-0.5.0}/PKG-INFO +6 -3
  2. {glidepath-0.4.0 → glidepath-0.5.0}/README.md +5 -2
  3. {glidepath-0.4.0 → glidepath-0.5.0}/pyproject.toml +6 -2
  4. glidepath-0.5.0/src/glidepath/__init__.py +11 -0
  5. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/app/copy.py +24 -2
  6. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/app/drawdown.py +46 -7
  7. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/app/retirement.py +2 -2
  8. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/core/engine.py +28 -9
  9. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/regions/uk/__init__.py +2 -0
  10. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/regions/uk/data/age_rules.toml +1 -1
  11. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/regions/uk/data/assumptions_default.toml +1 -1
  12. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/regions/uk/data/returns_history.toml +1 -1
  13. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/regions/uk/data/tax_year_2026_27.toml +10 -2
  14. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/regions/uk/extension.py +2 -0
  15. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/regions/uk/loader.py +16 -10
  16. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/regions/uk/schema.py +39 -10
  17. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/regions/uk/tax.py +70 -28
  18. glidepath-0.4.0/src/glidepath/__init__.py +0 -3
  19. {glidepath-0.4.0 → glidepath-0.5.0}/LICENSE +0 -0
  20. {glidepath-0.4.0 → glidepath-0.5.0}/LICENSE-DATA +0 -0
  21. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/app/__init__.py +0 -0
  22. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/app/backtest.py +0 -0
  23. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/app/charts.py +0 -0
  24. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/app/display.py +0 -0
  25. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/app/example.py +0 -0
  26. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/app/exports.py +0 -0
  27. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/app/files.py +0 -0
  28. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/app/firstrun.py +0 -0
  29. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/app/forms.py +0 -0
  30. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/app/inspector.py +0 -0
  31. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/app/labels.py +0 -0
  32. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/app/montecarlo.py +0 -0
  33. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/app/outlook.py +0 -0
  34. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/app/plan.py +0 -0
  35. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/app/scenarios.py +0 -0
  36. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/app/shell.py +0 -0
  37. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/app/tables.py +0 -0
  38. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/core/__init__.py +0 -0
  39. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/core/annuities.py +0 -0
  40. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/core/backtest.py +0 -0
  41. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/core/comparison.py +0 -0
  42. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/core/config.py +0 -0
  43. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/core/contributions.py +0 -0
  44. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/core/entities.py +0 -0
  45. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/core/glide.py +0 -0
  46. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/core/investments.py +0 -0
  47. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/core/money.py +0 -0
  48. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/core/montecarlo.py +0 -0
  49. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/core/pensions.py +0 -0
  50. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/core/periods.py +0 -0
  51. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/core/provenance.py +0 -0
  52. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/core/randomness.py +0 -0
  53. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/core/region.py +0 -0
  54. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/core/reporting.py +0 -0
  55. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/core/results.py +0 -0
  56. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/core/retirement.py +0 -0
  57. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/core/returns.py +0 -0
  58. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/core/scenarios.py +0 -0
  59. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/core/state_pension.py +0 -0
  60. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/core/tax.py +0 -0
  61. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/core/withdrawals.py +0 -0
  62. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/core/wrappers.py +0 -0
  63. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/gui/__init__.py +0 -0
  64. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/gui/assets/icon_128.png +0 -0
  65. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/gui/assets/icon_16.png +0 -0
  66. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/gui/assets/icon_24.png +0 -0
  67. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/gui/assets/icon_256.png +0 -0
  68. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/gui/assets/icon_32.png +0 -0
  69. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/gui/assets/icon_48.png +0 -0
  70. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/gui/assets/icon_64.png +0 -0
  71. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/gui/assets/wordmark.png +0 -0
  72. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/gui/charts.py +0 -0
  73. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/gui/forms.py +0 -0
  74. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/gui/inspector.py +0 -0
  75. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/gui/main.py +0 -0
  76. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/gui/scenarios.py +0 -0
  77. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/gui/style.py +0 -0
  78. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/gui/tableview.py +0 -0
  79. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/gui/widgets.py +0 -0
  80. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/persistence/__init__.py +0 -0
  81. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/persistence/assumptions.py +0 -0
  82. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/persistence/decode.py +0 -0
  83. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/persistence/document.py +0 -0
  84. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/persistence/encode.py +0 -0
  85. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/persistence/migrations.py +0 -0
  86. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/persistence/values.py +0 -0
  87. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/py.typed +0 -0
  88. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/regions/__init__.py +0 -0
  89. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/regions/uk/ages.py +0 -0
  90. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/regions/uk/contributions.py +0 -0
  91. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/regions/uk/region.py +0 -0
  92. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/regions/uk/state_pension.py +0 -0
  93. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/regions/uk/wrappers.py +0 -0
  94. {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/regions/uk/years.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: glidepath
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: Desktop retirement and investment planner (UK-first, region-extensible).
5
5
  Keywords: retirement,pension,financial-planning,investment,uk,sipp,isa,monte-carlo
6
6
  Author: James Williams
@@ -201,8 +201,11 @@ Releases are `vX.Y.Z` tags on `main`. Each one is published to
201
201
  trusted publishing with PEP 740 attestations, and as a GitHub Release
202
202
  carrying its notes from
203
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.
204
+ with the same artifacts attached, each carrying signed build
205
+ provenance — verify a downloaded file with
206
+ `gh attestation verify <file> -R williajm/glidepath`. There are no
207
+ packaged binary builds (installer/exe) yet — install from PyPI as
208
+ above.
206
209
 
207
210
  ## Data licences
208
211
 
@@ -173,8 +173,11 @@ Releases are `vX.Y.Z` tags on `main`. Each one is published to
173
173
  trusted publishing with PEP 740 attestations, and as a GitHub Release
174
174
  carrying its notes from
175
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.
176
+ with the same artifacts attached, each carrying signed build
177
+ provenance — verify a downloaded file with
178
+ `gh attestation verify <file> -R williajm/glidepath`. There are no
179
+ packaged binary builds (installer/exe) yet — install from PyPI as
180
+ above.
178
181
 
179
182
  ## Data licences
180
183
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "glidepath"
3
- version = "0.4.0"
3
+ version = "0.5.0"
4
4
  description = "Desktop retirement and investment planner (UK-first, region-extensible)."
5
5
  readme = "README.md"
6
6
  # MIT code plus one CC BY-NC-SA 4.0 data file (returns_history.toml —
@@ -61,6 +61,7 @@ dev = [
61
61
  "pre-commit",
62
62
  "pytest",
63
63
  "pytest-cov",
64
+ "pytest-qt",
64
65
  "pytest-timeout",
65
66
  "ruff",
66
67
  ]
@@ -73,7 +74,7 @@ build-backend = "uv_build"
73
74
  [tool.uv]
74
75
  # Supply-chain cooldown: resolution ignores anything published to PyPI after
75
76
  # this timestamp (now minus 7 days). Updated ONLY by `make deps`.
76
- exclude-newer = "2026-08-01T17:09:10Z"
77
+ exclude-newer = "2026-08-06T18:44:36Z"
77
78
 
78
79
  # PyPI is the ONLY permitted index (supply-chain policy; see CLAUDE.md).
79
80
  [[tool.uv.index]]
@@ -153,6 +154,9 @@ pythonpath = ["scripts", "tests"]
153
154
  # GUI suite waits on worker pools for up to 60s, so the ceiling sits
154
155
  # safely above that while still cutting a wedged run short.
155
156
  timeout = 120
157
+ # pytest-qt would autodetect PySide6 anyway; pinning the binding keeps the
158
+ # plugin from ever picking up a stray PyQt installed into the venv.
159
+ qt_api = "pyside6"
156
160
  markers = [
157
161
  "gui: exercises the PySide6 shell on the offscreen Qt platform",
158
162
  "slow: long-running engine sweeps (Monte Carlo, backtest, solvers); deselect with -m 'not slow'",
@@ -0,0 +1,11 @@
1
+ """glidepath: a desktop retirement and investment planner (UK-first).
2
+
3
+ ``[project] version`` in pyproject.toml is the single version source
4
+ (rewritten only by ``make bump``, planning §4.10); it reaches the
5
+ installed distribution as metadata, and ``__version__`` merely reads
6
+ it back — never state a version here.
7
+ """
8
+
9
+ from importlib.metadata import version
10
+
11
+ __version__ = version("glidepath")
@@ -113,8 +113,11 @@ HELP_GUIDE_SECTIONS: Final[tuple[tuple[str, str], ...]] = (
113
113
  "reproduce. "
114
114
  'The "When can I retire?" card answers with the earliest '
115
115
  "retirement age at which the plan sustains a target income — "
116
- "a replacement rate you choose (66% of your employment income "
117
- "by default) — on the selected run mode's basis: met with no "
116
+ "a replacement rate you choose (66% of your gross employment "
117
+ "income by default), enforced as after-tax spending money, so "
118
+ "the target is deliberately more demanding than the same "
119
+ "share of your take-home pay — on the selected run mode's "
120
+ "basis: met with no "
118
121
  "shortfall deterministically, or with at least your chosen "
119
122
  "Monte Carlo success rate. "
120
123
  'The "How much can I draw down?" card asks the same question '
@@ -124,6 +127,25 @@ HELP_GUIDE_SECTIONS: Final[tuple[tuple[str, str], ...]] = (
124
127
  "on the same selected basis."
125
128
  ),
126
129
  ),
130
+ (
131
+ "How spending is funded",
132
+ (
133
+ "In the projection, income already in payment — defined "
134
+ "benefit pension, state pension, annuity income — meets your "
135
+ "net spending need first, after the tax it bears. Whatever "
136
+ "remains is withdrawn from your wrappers in one fixed order: "
137
+ "general accounts and cash first (every pound left in them "
138
+ "keeps accruing income tax), then ISAs and LISAs, then "
139
+ "already-crystallised pension funds, and uncrystallised "
140
+ "pension funds last. This order is a deliberate "
141
+ "simplification — it is not configurable and the app never "
142
+ "searches for a personally optimal withdrawal sequence, "
143
+ "which would amount to tax advice. In some years a "
144
+ "different order could use an allowance this one leaves "
145
+ "idle, so treat the projection as a consistent baseline, "
146
+ "not the best achievable outcome."
147
+ ),
148
+ ),
127
149
  (
128
150
  "Scenarios — compare what-ifs",
129
151
  (
@@ -169,11 +169,14 @@ class DrawdownAnswer:
169
169
 
170
170
  ``income`` is the highest sustainable net annual income in today's
171
171
  money, or ``None`` when not even zero spending survives the plan's
172
- outflows. The rest is the manifest side: the retirement age the
173
- answer assumed and whose it was (``person_position``, the
174
- household position), the searched bracket's upper bound, and the
175
- basis — ``seed``, ``paths``, and ``target_success_rate`` are
176
- carried only for a Monte Carlo basis.
172
+ outflows. ``pot`` is the household's total wrapper balances as
173
+ stated at solve time — the denominator presenting the answer as a
174
+ starting withdrawal rate, recorded here so the rate always
175
+ describes the plan the answer was solved on. The rest is the
176
+ manifest side: the retirement age the answer assumed and whose it
177
+ was (``person_position``, the household position), the searched
178
+ bracket's upper bound, and the basis — ``seed``, ``paths``, and
179
+ ``target_success_rate`` are carried only for a Monte Carlo basis.
177
180
  """
178
181
 
179
182
  income: Money | None
@@ -184,6 +187,7 @@ class DrawdownAnswer:
184
187
  paths: int | None = None
185
188
  target_success_rate: Decimal | None = None
186
189
  person_position: int = 0
190
+ pot: Money | None = None
187
191
 
188
192
 
189
193
  @dataclass(frozen=True)
@@ -296,6 +300,7 @@ def state_with_drawdown(
296
300
  paths=inputs.paths,
297
301
  target_success_rate=inputs.target_success_rate,
298
302
  person_position=inputs.person_position,
303
+ pot=_household_pot(household),
299
304
  )
300
305
  changes: dict[str, Any] = {"drawdown": answer, "drawdown_error": None}
301
306
  return replace(base, **changes) if changes else base
@@ -358,7 +363,7 @@ def _headline(answer: DrawdownAnswer) -> str:
358
363
 
359
364
 
360
365
  def _detail(answer: DrawdownAnswer) -> str:
361
- """The assumed age, searched bracket, and basis under the headline."""
366
+ """The assumed age, searched bracket, rate, and basis under the headline."""
362
367
  retiring = (
363
368
  f"Retiring at age {answer.age}"
364
369
  if answer.person_position == 0
@@ -376,7 +381,41 @@ def _detail(answer: DrawdownAnswer) -> str:
376
381
  f" Monte Carlo success over {answer.paths} paths"
377
382
  f" (seed {answer.seed})."
378
383
  )
379
- return f"{target}\n{basis}"
384
+ rate = _rate_line(answer)
385
+ lines = [target, rate, basis] if rate else [target, basis]
386
+ return "\n".join(lines)
387
+
388
+
389
+ def _rate_line(answer: DrawdownAnswer) -> str:
390
+ """The answer as a starting withdrawal rate of today's pot, if any.
391
+
392
+ Blank when nothing is sustainable or the household holds no
393
+ wrapper balances to take a rate of. The rate is derived from the
394
+ answer, never asserted: the product ships no "safe withdrawal
395
+ rate" figure — this line only lets users compare the computed
396
+ answer against rules of thumb they know.
397
+ """
398
+ if answer.income is None or answer.pot is None or answer.pot.amount <= 0:
399
+ return ""
400
+ rate = answer.income.amount / answer.pot.amount
401
+ return (
402
+ f"A starting withdrawal rate of {format_percent(rate)} of"
403
+ f" today's total pot ({format_money(answer.pot)})."
404
+ )
405
+
406
+
407
+ def _household_pot(household: Household) -> Money:
408
+ """Every wrapper balance across the household, as stated.
409
+
410
+ The whole household's pot — the §4.11 "our pot" meaning — since
411
+ the sustainable income is drawn from every accessible source, not
412
+ the tested person's alone.
413
+ """
414
+ total = Money(Decimal(0))
415
+ for person in household.persons:
416
+ for wrapper in person.wrappers:
417
+ total = total + wrapper.balance.value
418
+ return total
380
419
 
381
420
 
382
421
  def _age_echo(answer: DrawdownAnswer | None, household: Household | None) -> str:
@@ -333,8 +333,8 @@ def _detail(answer: RetirementAnswer) -> str:
333
333
  else "searched your partner's ages"
334
334
  )
335
335
  target = (
336
- f"Target income {format_money(answer.target_income)} a year"
337
- f" — {format_percent(answer.replacement_rate)} of employment income"
336
+ f"Target income {format_money(answer.target_income)} a year after tax"
337
+ f" — {format_percent(answer.replacement_rate)} of gross employment income"
338
338
  f" {format_money(answer.employment_income)} — {searched}"
339
339
  f" {answer.minimum_age} to {answer.maximum_age}."
340
340
  )
@@ -2653,7 +2653,12 @@ class _PersonProjection:
2653
2653
  offset is netted of only the marginal tax the retirement
2654
2654
  layers add on top of it: the no-portfolio assessment less one
2655
2655
  of employment income alone, which once retired is identically
2656
- the whole assessment. The offset excludes the portfolio-income
2656
+ the whole assessment. The employment-only baseline is net of
2657
+ net-pay deductions, matching their treatment in the full
2658
+ assessment: a gross baseline would understate the marginal
2659
+ tax by the contribution's relief, leaking that relief — which
2660
+ in reality lands in out-of-model take-home pay — into
2661
+ in-model cash. The offset excludes the portfolio-income
2657
2662
  layers: their tax is charged to the taxable wrappers at close,
2658
2663
  never to the need.
2659
2664
  """
@@ -2665,17 +2670,18 @@ class _PersonProjection:
2665
2670
  + income.annuity_lump_sum
2666
2671
  + pension_lump_sum
2667
2672
  )
2668
- if self._taxable_income <= income.employment:
2673
+ employment_taxable = max(income.employment - self._net_pay_deductions, _ZERO)
2674
+ if self._taxable_income <= employment_taxable:
2669
2675
  return gross
2670
2676
  full = self.region.tax.assess(
2671
2677
  period, self._tax_input(include_portfolio=False)
2672
2678
  ).tax_due
2673
2679
  employment_only = _ZERO
2674
- if income.employment > _ZERO:
2680
+ if employment_taxable > _ZERO:
2675
2681
  employment_only = self.region.tax.assess(
2676
2682
  period,
2677
2683
  self._tax_input(
2678
- non_savings_override=income.employment, include_portfolio=False
2684
+ non_savings_override=employment_taxable, include_portfolio=False
2679
2685
  ),
2680
2686
  ).tax_due
2681
2687
  return gross - (full - employment_only)
@@ -3106,7 +3112,7 @@ class _PersonProjection:
3106
3112
  (:meth:`_charge_portfolio_tax`), never to the spending need.
3107
3113
  ``non_savings_override`` replaces the accumulated non-savings
3108
3114
  income — the employment-only baseline of the pre-retirement
3109
- income offset (:meth:`_pre_retirement_income_tax`).
3115
+ income offset (:meth:`_income_offset_net`).
3110
3116
  """
3111
3117
  non_savings = (
3112
3118
  self._taxable_income
@@ -3247,8 +3253,14 @@ class _PersonProjection:
3247
3253
  captured at the period open) and its closing entitlement — the
3248
3254
  credited value carried to the period end at the same
3249
3255
  revaluation the next boundary's advance applies — for the
3250
- region to value (planning §5.2); a stream whose benefits start
3251
- by the period end has crystallised and generates no input.
3256
+ region to value (planning §5.2). A stream whose benefits
3257
+ commence *within* the period still generates an input per the
3258
+ HMRC closing-value adjustment (PTM054500): its closing
3259
+ entitlement is the credited value revalued only to the
3260
+ commencement date, so the final year's accrual is measured
3261
+ rather than discarded. Only a stream already in payment at the
3262
+ period's first day generates nothing — its accrual was
3263
+ measured in the year it crystallised.
3252
3264
  ``total_income`` is the period's full taxable picture before
3253
3265
  member pension deductions — net-pay amounts added back, the
3254
3266
  portfolio-income layers included — so the region's income
@@ -3276,10 +3288,17 @@ class _PersonProjection:
3276
3288
  cpi = returns.cpi.value
3277
3289
  arrangements: list[DbArrangementInput] = []
3278
3290
  for stream, opening in zip(self._db_streams, self._db_openings, strict=True):
3279
- if stream.start <= period.end:
3291
+ if stream.start <= period.start:
3280
3292
  continue
3293
+ revalued_share = fraction
3294
+ if stream.start <= period.end:
3295
+ # Commencement year: revalue only to the start date —
3296
+ # the §4.1 whole-month share of the period before it.
3297
+ revalued_share = service_active_fraction(
3298
+ stream.start, period, self.config.today, self.run.horizon_end()
3299
+ )
3281
3300
  closing = stream.accrued_annual * (
3282
- _ONE + stream.basis.annual_rate(cpi) * fraction
3301
+ _ONE + stream.basis.annual_rate(cpi) * revalued_share
3283
3302
  )
3284
3303
  arrangements.append(
3285
3304
  DbArrangementInput(opening_annual=opening, closing_annual=closing)
@@ -70,6 +70,7 @@ from glidepath.regions.uk.schema import (
70
70
  NmpaStep,
71
71
  PensionRules,
72
72
  ReturnsHistoryFile,
73
+ SavingsRate,
73
74
  SavingsRules,
74
75
  SpaAgeBand,
75
76
  SpaBand,
@@ -147,6 +148,7 @@ __all__ = [
147
148
  "NmpaStep",
148
149
  "PensionRules",
149
150
  "ReturnsHistoryFile",
151
+ "SavingsRate",
150
152
  "SavingsRules",
151
153
  "ScottishBandsPolicy",
152
154
  "SpaAgeBand",
@@ -3,7 +3,7 @@
3
3
  # re-verified against the live gov.uk timetable page on 2026-08-02; the
4
4
  # death-benefit age boundary against the live gov.uk page on 2026-08-12.
5
5
 
6
- schema_version = 4
6
+ schema_version = 5
7
7
 
8
8
  [meta]
9
9
  verified_on = 2026-08-12
@@ -4,7 +4,7 @@
4
4
  # user-overridable in the app; §6 announced-policy items are facts, these
5
5
  # are estimates.
6
6
 
7
- schema_version = 4
7
+ schema_version = 5
8
8
 
9
9
  [meta]
10
10
  verified_on = 2026-08-12
@@ -14,7 +14,7 @@
14
14
  # Cite Jordà, Schularick & Taylor (2017) and, for the return series,
15
15
  # Jordà, Knoll, Kuvshinov, Schularick & Taylor (2019).
16
16
 
17
- schema_version = 4
17
+ schema_version = 5
18
18
 
19
19
  [meta]
20
20
  verified_on = 2026-08-05
@@ -5,7 +5,7 @@
5
5
  # all from the primary sources below; never edit a figure without
6
6
  # re-verifying and updating [meta].
7
7
 
8
- schema_version = 4
8
+ schema_version = 5
9
9
 
10
10
  [meta]
11
11
  tax_year = "2026/27"
@@ -84,12 +84,20 @@ lisa_bonus_rate = "0.25"
84
84
  lisa_withdrawal_charge = "0.25"
85
85
 
86
86
  # Nil rates consume band width (§6); savings above them are taxed at the
87
- # rUK band rates until the separate savings rates land in the 2027/28 file.
87
+ # rates below, aligned positionally with the rUK bands (savings income
88
+ # stacks on the rUK ladder UK-wide). For 2026/27 they equal the main
89
+ # rates; the separate 22/42/47 rates (Budget 2025 OOTLAR, from 6 April
90
+ # 2027) land in the 2027/28 file.
88
91
  [savings]
89
92
  starting_rate_limit = "5000" # reduced £1 per £1 of non-savings income above the PA
90
93
  psa_basic = "1000"
91
94
  psa_higher = "500"
92
95
  psa_additional = "0"
96
+ rates = [
97
+ { name = "basic", rate = "0.20" },
98
+ { name = "higher", rate = "0.40" },
99
+ { name = "additional", rate = "0.45" },
100
+ ]
93
101
 
94
102
  # Rates align positionally with the rUK bands (dividends are UK-wide).
95
103
  # 2026/27 figures include the Budget 2025 +2ppt change, in force.
@@ -368,12 +368,14 @@ def _indexed_savings(savings: SavingsRules, factor: Decimal) -> SavingsRules:
368
368
  The starting-rate limit is legislated frozen with the rUK schedule
369
369
  (planning §6), and the PSA amounts follow the same reserved policy;
370
370
  a zero tier (the additional-rate PSA) stays zero under any factor.
371
+ The savings rates never extrapolate, like every other rate.
371
372
  """
372
373
  return SavingsRules(
373
374
  starting_rate_limit=_indexed_money(savings.starting_rate_limit, factor),
374
375
  psa_basic=_indexed_money(savings.psa_basic, factor),
375
376
  psa_higher=_indexed_money(savings.psa_higher, factor),
376
377
  psa_additional=_indexed_money(savings.psa_additional, factor),
378
+ rates=savings.rates,
377
379
  )
378
380
 
379
381
 
@@ -39,6 +39,7 @@ from glidepath.regions.uk.schema import (
39
39
  NmpaStep,
40
40
  PensionRules,
41
41
  ReturnsHistoryFile,
42
+ SavingsRate,
42
43
  SavingsRules,
43
44
  SpaAgeBand,
44
45
  SpaBand,
@@ -349,9 +350,19 @@ def _parse_isa(raw: object, context: str) -> IsaRules:
349
350
  return rules
350
351
 
351
352
 
353
+ def _parse_named_rate(raw: object, context: str) -> tuple[str, Rate]:
354
+ """Parse one ``{name, rate}`` entry of a layer's ``rates`` array."""
355
+ table = _Table(raw, context)
356
+ name = _string(table.take("name"), f"{context}.name")
357
+ rate = _fraction(table.take("rate"), f"{context}.rate")
358
+ table.finish()
359
+ return name, rate
360
+
361
+
352
362
  def _parse_savings(raw: object, context: str) -> SavingsRules:
353
363
  """Parse the ``[savings]`` table."""
354
364
  table = _Table(raw, context)
365
+ rates_raw = _array(table.take("rates"), f"{context}.rates")
355
366
  rules = SavingsRules(
356
367
  starting_rate_limit=_money(
357
368
  table.take("starting_rate_limit"), f"{context}.starting_rate_limit"
@@ -361,27 +372,22 @@ def _parse_savings(raw: object, context: str) -> SavingsRules:
361
372
  psa_additional=_money(
362
373
  table.take("psa_additional"), f"{context}.psa_additional"
363
374
  ),
375
+ rates=tuple(
376
+ SavingsRate(*_parse_named_rate(item, f"{context}.rates[{index}]"))
377
+ for index, item in enumerate(rates_raw)
378
+ ),
364
379
  )
365
380
  table.finish()
366
381
  return rules
367
382
 
368
383
 
369
- def _parse_dividend_rate(raw: object, context: str) -> DividendRate:
370
- """Parse one entry of the ``dividend.rates`` array."""
371
- table = _Table(raw, context)
372
- name = _string(table.take("name"), f"{context}.name")
373
- rate = _fraction(table.take("rate"), f"{context}.rate")
374
- table.finish()
375
- return DividendRate(name=name, rate=rate)
376
-
377
-
378
384
  def _parse_dividend(raw: object, context: str) -> DividendRules:
379
385
  """Parse the ``[dividend]`` table."""
380
386
  table = _Table(raw, context)
381
387
  allowance = _money(table.take("allowance"), f"{context}.allowance")
382
388
  rates_raw = _array(table.take("rates"), f"{context}.rates")
383
389
  rates = tuple(
384
- _parse_dividend_rate(item, f"{context}.rates[{index}]")
390
+ DividendRate(*_parse_named_rate(item, f"{context}.rates[{index}]"))
385
391
  for index, item in enumerate(rates_raw)
386
392
  )
387
393
  table.finish()
@@ -21,13 +21,13 @@ if TYPE_CHECKING:
21
21
  from collections.abc import Iterator
22
22
  from decimal import Decimal
23
23
 
24
- SCHEMA_VERSION = 4
24
+ SCHEMA_VERSION = 5
25
25
  """The data-file schema version this code understands.
26
26
 
27
- v2 (#97): tax-year files lose the ``[state_pension]`` table and
28
- ``age_rules.toml`` the ``[new_state_pension]`` table — the state
29
- pension amount is the user's stated DWP forecast, never a shipped
30
- rate.
27
+ v5 (#189): tax-year files gain ``savings.rates`` — a savings rate
28
+ schedule aligned positionally with the rUK bands, so the separate
29
+ savings-income rates enacted from 2027/28 (22/42/47, Budget 2025
30
+ OOTLAR) can ship as data in the 2027/28 file.
31
31
 
32
32
  v4 (roadmap 9.33): ``age_rules.toml`` gains the ``[death_benefits]``
33
33
  table — the age boundary below which a deceased member's pension
@@ -37,6 +37,11 @@ passes as income-tax-free beneficiary drawdown (planning §6
37
37
  v3 (#173): tax-year files gain the ``[marriage_allowance]`` table —
38
38
  the s55B transferable amount and the per-schedule recipient band
39
39
  gates (roadmap 9.32).
40
+
41
+ v2 (#97): tax-year files lose the ``[state_pension]`` table and
42
+ ``age_rules.toml`` the ``[new_state_pension]`` table — the state
43
+ pension amount is the user's stated DWP forecast, never a shipped
44
+ rate.
40
45
  """
41
46
 
42
47
  _TAX_YEAR_FORMAT = re.compile(r"\d{4}/\d{2}")
@@ -231,30 +236,49 @@ class IsaRules:
231
236
  lisa_withdrawal_charge: Rate
232
237
 
233
238
 
239
+ @dataclass(frozen=True, slots=True)
240
+ class SavingsRate:
241
+ """One savings rate, aligned positionally with the rUK band ladder."""
242
+
243
+ name: str
244
+ rate: Rate
245
+
246
+ def __post_init__(self) -> None:
247
+ """Reject unnamed rates."""
248
+ if not self.name:
249
+ _fail("SavingsRate", "name must not be empty")
250
+
251
+
234
252
  @dataclass(frozen=True, slots=True)
235
253
  class SavingsRules:
236
- """Savings-income nil rates for one tax year (planning §6, roadmap 9.2).
254
+ """Savings-income rules for one tax year (planning §6, roadmap 9.2).
237
255
 
238
256
  ``starting_rate_limit`` is the 0% starting rate for savings band,
239
257
  reduced £1 per £1 of non-savings taxable income above the personal
240
258
  allowance. The ``psa_*`` fields are the personal savings allowance
241
259
  by the band the taxpayer's income reaches — nil *rates*, not
242
- deductions: nil-rated income still consumes band width (§6). Savings
243
- income above the nil rates is taxed at the rUK band rates until the
244
- separate savings rates take effect (2027/28, shipped as data then).
260
+ deductions: nil-rated income still consumes band width (§6).
261
+ ``rates`` tax the savings income above the nil rates: they map
262
+ positionally onto the rUK income-tax bands (savings income stacks
263
+ on the rUK ladder UK-wide, §6) — one rate per band, enforced by
264
+ :class:`TaxYearFile`. Through 2026/27 they equal the main rates;
265
+ the separate 22/42/47 rates land in the 2027/28 file (#189).
245
266
  """
246
267
 
247
268
  starting_rate_limit: Money
248
269
  psa_basic: Money
249
270
  psa_higher: Money
250
271
  psa_additional: Money
272
+ rates: tuple[SavingsRate, ...]
251
273
 
252
274
  def __post_init__(self) -> None:
253
- """Require descending PSA tiers — the statutory shape."""
275
+ """Require descending PSA tiers and at least one rate."""
254
276
  if not self.psa_basic >= self.psa_higher >= self.psa_additional:
255
277
  _fail(
256
278
  "SavingsRules", "PSA tiers must satisfy basic >= higher >= additional"
257
279
  )
280
+ if not self.rates:
281
+ _fail("SavingsRules", "at least one savings rate is required")
258
282
 
259
283
 
260
284
  @dataclass(frozen=True, slots=True)
@@ -334,6 +358,11 @@ class TaxYearFile:
334
358
  "TaxYearFile",
335
359
  "dividend.rates must align one-to-one with the rUK bands",
336
360
  )
361
+ if len(self.savings.rates) != len(self.income_tax_ruk.bands):
362
+ _fail(
363
+ "TaxYearFile",
364
+ "savings.rates must align one-to-one with the rUK bands",
365
+ )
337
366
  gates = (
338
367
  (self.marriage_allowance.recipient_top_band_ruk, self.income_tax_ruk),
339
368
  (
@@ -218,7 +218,10 @@ class UkTaxSystem:
218
218
  The recipient's result gains the ITA 2007 s55B tax reducer as
219
219
  a negative no-income line: the rUK basic-rate percentage of
220
220
  the transferable amount, capped at their income-tax liability,
221
- never refundable, never a PA transfer in computation.
221
+ never refundable. The transferor is re-assessed with their
222
+ personal allowance reduced by the transferable amount
223
+ (s55B(6)), so the household result nets any cost of the
224
+ surrender.
222
225
  """
223
226
  results = tuple(entry.result for entry in assessments)
224
227
  if len(assessments) != _COUPLE:
@@ -229,9 +232,9 @@ class UkTaxSystem:
229
232
  year, assessments[transferor], assessments[recipient]
230
233
  )
231
234
  if adjusted is not None:
235
+ pair = {transferor: adjusted[0], recipient: adjusted[1]}
232
236
  return tuple(
233
- adjusted if index == recipient else result
234
- for index, result in enumerate(results)
237
+ pair.get(index, result) for index, result in enumerate(results)
235
238
  )
236
239
  return results
237
240
 
@@ -297,7 +300,7 @@ def _permitted_recipient_bands(
297
300
  break
298
301
  ruk_gate = year.marriage_allowance.recipient_top_band_ruk
299
302
  for index, band in enumerate(year.income_tax_ruk.bands):
300
- permitted.add(_savings_band_name(index, band))
303
+ permitted.add(f"savings_{year.savings.rates[index].name}")
301
304
  permitted.add(f"dividend_{year.dividend.rates[index].name}")
302
305
  if band.name == ruk_gate:
303
306
  break
@@ -308,17 +311,22 @@ def _marriage_allowance_adjusted(
308
311
  year: TaxYearFile,
309
312
  transferor: HouseholdAssessment,
310
313
  recipient: HouseholdAssessment,
311
- ) -> TaxResult | None:
312
- """The recipient's result with the s55B reducer, or ``None``.
314
+ ) -> tuple[TaxResult, TaxResult] | None:
315
+ """Both partners' results under the s55 election, or ``None``.
313
316
 
314
317
  Eligibility (planning §6 "Couples"): the transferor's income sits
315
- within their personal allowance (zero taxable income — the PA is
316
- never actually transferred in computation, a recorded
317
- simplification when their income falls inside the transferable
318
- band), and the recipient is liable at no rate above the data
319
- file's band gates. Annual-allowance-charge lines are ignored on
320
- both sides of the test and the cap: the s55B reducer lands at step
321
- 6 of the ITA 2007 s23 calculation, before the step-7 charge.
318
+ within their personal allowance (zero taxable income), and the
319
+ recipient is liable at no rate above the data file's band gates.
320
+ The recipient gains the s55B tax reducer; the transferor is
321
+ re-assessed with their personal allowance reduced by the
322
+ transferable amount (ITA 2007 s55B(6)), so when their income sits
323
+ inside the transferable band the reported household benefit nets
324
+ their cost — GOV.UK's worked example: £252 off the recipient,
325
+ £38 due from a transferor on £11,500. Annual-allowance-charge
326
+ lines are ignored on both sides of the test and the cap — the
327
+ s55B reducer lands at step 6 of the ITA 2007 s23 calculation,
328
+ before the step-7 charge — and the transferor's charge lines are
329
+ carried over the re-assessment unchanged.
322
330
  """
323
331
  if transferor.result.taxable_income > _ZERO:
324
332
  return None
@@ -339,12 +347,43 @@ def _marriage_allowance_adjusted(
339
347
  reducer_line = TaxLine(
340
348
  band=MARRIAGE_ALLOWANCE_BAND, rate=rate, taxed=_ZERO, tax=-reduction
341
349
  )
342
- return TaxResult(
350
+ adjusted_recipient = TaxResult(
343
351
  tax_due=result.tax_due - reduction,
344
352
  taxable_income=result.taxable_income,
345
353
  tax_free_allowance=result.tax_free_allowance,
346
354
  lines=(*lines, reducer_line),
347
355
  )
356
+ return _transferor_reassessed(year, transferor), adjusted_recipient
357
+
358
+
359
+ def _transferor_reassessed(
360
+ year: TaxYearFile, transferor: HouseholdAssessment
361
+ ) -> TaxResult:
362
+ """The transferor's assessment with the s55B(6) reduced allowance.
363
+
364
+ The re-assessment prices the income tax their surrendered
365
+ allowance exposes; any annual-allowance-charge lines from the
366
+ original result are appended unchanged — the engine prices that
367
+ freestanding charge separately, and the election never re-prices
368
+ it.
369
+ """
370
+ assessed = _assess_year(
371
+ year,
372
+ transferor.tax_input,
373
+ allowance_reduction=year.marriage_allowance.transferable_amount,
374
+ )
375
+ charge_lines = tuple(
376
+ line
377
+ for line in transferor.result.lines
378
+ if line.band.startswith(_AA_CHARGE_PREFIX)
379
+ )
380
+ charge_tax = sum((line.tax for line in charge_lines), start=_ZERO)
381
+ return TaxResult(
382
+ tax_due=assessed.tax_due + charge_tax,
383
+ taxable_income=assessed.taxable_income,
384
+ tax_free_allowance=assessed.tax_free_allowance,
385
+ lines=(*assessed.lines, *charge_lines),
386
+ )
348
387
 
349
388
 
350
389
  def _tapered_allowance(
@@ -440,11 +479,6 @@ def _band_own_rate(_index: int, band: TaxBand) -> Rate:
440
479
  return band.rate
441
480
 
442
481
 
443
- def _savings_band_name(_index: int, band: TaxBand) -> str:
444
- """A savings line label: the band name under the layer prefix."""
445
- return f"savings_{band.name}"
446
-
447
-
448
482
  def _aa_charge_band_name(_index: int, band: TaxBand) -> str:
449
483
  """An annual-allowance-charge line label under the layer prefix."""
450
484
  return f"{_AA_CHARGE_PREFIX}{band.name}"
@@ -482,15 +516,17 @@ def _savings_lines(
482
516
  taxable_non_savings: Money,
483
517
  total_taxable: Money,
484
518
  ) -> tuple[Money, tuple[TaxLine, ...]]:
485
- """The savings layer: starting rate, PSA, then the band rates.
519
+ """The savings layer: starting rate, PSA, then the savings rates.
486
520
 
487
521
  The layer stacks directly above the non-savings taxable income on
488
522
  the rUK ladder. The starting rate for savings covers what remains
489
523
  of its limit after non-savings taxable income eats it £1 per £1
490
524
  (planning §6); the PSA nil rate follows; the remainder is charged
491
- at the rUK band rates (savings rates separate from the main rates
492
- ship as data from 2027/28, §6). Returns the ladder position after
493
- the layer plus the layer's lines.
525
+ at the year's savings rates, aligned positionally with the rUK
526
+ bands (schema invariant) — equal to the main rates through
527
+ 2026/27, the separate enacted rates from the 2027/28 file (§6,
528
+ #189). Returns the ladder position after the layer plus the
529
+ layer's lines.
494
530
  """
495
531
  lines: list[TaxLine] = []
496
532
  position = taxable_non_savings
@@ -512,8 +548,8 @@ def _savings_lines(
512
548
  bands,
513
549
  start=position,
514
550
  amount=remaining,
515
- line_name=_savings_band_name,
516
- line_rate=_band_own_rate,
551
+ line_name=lambda index, _band: f"savings_{savings.rates[index].name}",
552
+ line_rate=lambda index, _band: savings.rates[index].rate,
517
553
  )
518
554
  )
519
555
  position = position + remaining
@@ -553,7 +589,9 @@ def _dividend_lines(
553
589
  return tuple(lines)
554
590
 
555
591
 
556
- def _assess_year(year: TaxYearFile, tax_input: TaxInput) -> TaxResult:
592
+ def _assess_year(
593
+ year: TaxYearFile, tax_input: TaxInput, allowance_reduction: Money = _ZERO
594
+ ) -> TaxResult:
557
595
  """Assess one period's categorised income (module docstring).
558
596
 
559
597
  The personal allowance is set against income in the stacking order
@@ -561,7 +599,9 @@ def _assess_year(year: TaxYearFile, tax_input: TaxInput) -> TaxResult:
561
599
  each category's taxable remainder is charged through its layer.
562
600
  Savings and dividends always stack on the rUK ladder, above the
563
601
  non-savings taxable income, whatever schedule taxed that income
564
- (planning §6).
602
+ (planning §6). ``allowance_reduction`` takes the marriage
603
+ allowance transferor's surrendered amount off the tapered
604
+ allowance (ITA 2007 s55B(6)), floored at zero.
565
605
  """
566
606
  schedule = _schedule_for(year, tax_input.residency)
567
607
  ras_gross = tax_input.relief_at_source_contributions
@@ -569,7 +609,9 @@ def _assess_year(year: TaxYearFile, tax_input: TaxInput) -> TaxResult:
569
609
  savings = tax_input.savings_income
570
610
  dividends = tax_input.dividend_income
571
611
  adjusted_net_income = max(non_savings + savings + dividends - ras_gross, _ZERO)
572
- allowance = _tapered_allowance(schedule, adjusted_net_income)
612
+ allowance = max(
613
+ _tapered_allowance(schedule, adjusted_net_income) - allowance_reduction, _ZERO
614
+ )
573
615
  taxable_non_savings = max(non_savings - allowance, _ZERO)
574
616
  allowance_left = max(allowance - non_savings, _ZERO)
575
617
  taxable_savings = max(savings - allowance_left, _ZERO)
@@ -1,3 +0,0 @@
1
- """glidepath: a desktop retirement and investment planner (UK-first)."""
2
-
3
- __version__ = "0.1.0"
File without changes
File without changes