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.
- {glidepath-0.4.0 → glidepath-0.5.0}/PKG-INFO +6 -3
- {glidepath-0.4.0 → glidepath-0.5.0}/README.md +5 -2
- {glidepath-0.4.0 → glidepath-0.5.0}/pyproject.toml +6 -2
- glidepath-0.5.0/src/glidepath/__init__.py +11 -0
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/app/copy.py +24 -2
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/app/drawdown.py +46 -7
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/app/retirement.py +2 -2
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/core/engine.py +28 -9
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/regions/uk/__init__.py +2 -0
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/regions/uk/data/age_rules.toml +1 -1
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/regions/uk/data/assumptions_default.toml +1 -1
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/regions/uk/data/returns_history.toml +1 -1
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/regions/uk/data/tax_year_2026_27.toml +10 -2
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/regions/uk/extension.py +2 -0
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/regions/uk/loader.py +16 -10
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/regions/uk/schema.py +39 -10
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/regions/uk/tax.py +70 -28
- glidepath-0.4.0/src/glidepath/__init__.py +0 -3
- {glidepath-0.4.0 → glidepath-0.5.0}/LICENSE +0 -0
- {glidepath-0.4.0 → glidepath-0.5.0}/LICENSE-DATA +0 -0
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/app/__init__.py +0 -0
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/app/backtest.py +0 -0
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/app/charts.py +0 -0
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/app/display.py +0 -0
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/app/example.py +0 -0
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/app/exports.py +0 -0
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/app/files.py +0 -0
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/app/firstrun.py +0 -0
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/app/forms.py +0 -0
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/app/inspector.py +0 -0
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/app/labels.py +0 -0
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/app/montecarlo.py +0 -0
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/app/outlook.py +0 -0
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/app/plan.py +0 -0
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/app/scenarios.py +0 -0
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/app/shell.py +0 -0
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/app/tables.py +0 -0
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/core/__init__.py +0 -0
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/core/annuities.py +0 -0
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/core/backtest.py +0 -0
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/core/comparison.py +0 -0
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/core/config.py +0 -0
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/core/contributions.py +0 -0
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/core/entities.py +0 -0
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/core/glide.py +0 -0
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/core/investments.py +0 -0
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/core/money.py +0 -0
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/core/montecarlo.py +0 -0
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/core/pensions.py +0 -0
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/core/periods.py +0 -0
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/core/provenance.py +0 -0
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/core/randomness.py +0 -0
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/core/region.py +0 -0
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/core/reporting.py +0 -0
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/core/results.py +0 -0
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/core/retirement.py +0 -0
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/core/returns.py +0 -0
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/core/scenarios.py +0 -0
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/core/state_pension.py +0 -0
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/core/tax.py +0 -0
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/core/withdrawals.py +0 -0
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/core/wrappers.py +0 -0
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/gui/__init__.py +0 -0
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/gui/assets/icon_128.png +0 -0
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/gui/assets/icon_16.png +0 -0
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/gui/assets/icon_24.png +0 -0
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/gui/assets/icon_256.png +0 -0
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/gui/assets/icon_32.png +0 -0
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/gui/assets/icon_48.png +0 -0
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/gui/assets/icon_64.png +0 -0
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/gui/assets/wordmark.png +0 -0
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/gui/charts.py +0 -0
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/gui/forms.py +0 -0
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/gui/inspector.py +0 -0
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/gui/main.py +0 -0
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/gui/scenarios.py +0 -0
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/gui/style.py +0 -0
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/gui/tableview.py +0 -0
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/gui/widgets.py +0 -0
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/persistence/__init__.py +0 -0
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/persistence/assumptions.py +0 -0
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/persistence/decode.py +0 -0
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/persistence/document.py +0 -0
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/persistence/encode.py +0 -0
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/persistence/migrations.py +0 -0
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/persistence/values.py +0 -0
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/py.typed +0 -0
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/regions/__init__.py +0 -0
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/regions/uk/ages.py +0 -0
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/regions/uk/contributions.py +0 -0
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/regions/uk/region.py +0 -0
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/regions/uk/state_pension.py +0 -0
- {glidepath-0.4.0 → glidepath-0.5.0}/src/glidepath/regions/uk/wrappers.py +0 -0
- {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.
|
|
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
|
|
205
|
-
|
|
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
|
|
177
|
-
|
|
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.
|
|
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-
|
|
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
|
|
117
|
-
"by default)
|
|
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.
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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=
|
|
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:`
|
|
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)
|
|
3251
|
-
|
|
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.
|
|
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) *
|
|
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",
|
|
@@ -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 =
|
|
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
|
-
#
|
|
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
|
-
|
|
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 =
|
|
24
|
+
SCHEMA_VERSION = 5
|
|
25
25
|
"""The data-file schema version this code understands.
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
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).
|
|
243
|
-
|
|
244
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
"""
|
|
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
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
492
|
-
|
|
493
|
-
the
|
|
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=
|
|
516
|
-
line_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(
|
|
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 =
|
|
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)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|