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.
- glidepath/__init__.py +3 -0
- glidepath/app/__init__.py +364 -0
- glidepath/app/backtest.py +281 -0
- glidepath/app/charts.py +759 -0
- glidepath/app/copy.py +174 -0
- glidepath/app/display.py +148 -0
- glidepath/app/drawdown.py +436 -0
- glidepath/app/example.py +66 -0
- glidepath/app/exports.py +487 -0
- glidepath/app/files.py +249 -0
- glidepath/app/firstrun.py +114 -0
- glidepath/app/forms.py +1750 -0
- glidepath/app/inspector.py +506 -0
- glidepath/app/labels.py +66 -0
- glidepath/app/montecarlo.py +399 -0
- glidepath/app/plan.py +354 -0
- glidepath/app/retirement.py +446 -0
- glidepath/app/scenarios.py +831 -0
- glidepath/app/shell.py +185 -0
- glidepath/app/tables.py +138 -0
- glidepath/core/__init__.py +390 -0
- glidepath/core/annuities.py +240 -0
- glidepath/core/backtest.py +514 -0
- glidepath/core/comparison.py +278 -0
- glidepath/core/config.py +82 -0
- glidepath/core/contributions.py +337 -0
- glidepath/core/engine.py +2811 -0
- glidepath/core/entities.py +264 -0
- glidepath/core/glide.py +289 -0
- glidepath/core/investments.py +175 -0
- glidepath/core/money.py +107 -0
- glidepath/core/montecarlo.py +609 -0
- glidepath/core/pensions.py +298 -0
- glidepath/core/periods.py +367 -0
- glidepath/core/provenance.py +271 -0
- glidepath/core/randomness.py +128 -0
- glidepath/core/region.py +46 -0
- glidepath/core/reporting.py +231 -0
- glidepath/core/results.py +504 -0
- glidepath/core/retirement.py +291 -0
- glidepath/core/returns.py +312 -0
- glidepath/core/scenarios.py +579 -0
- glidepath/core/state_pension.py +264 -0
- glidepath/core/tax.py +139 -0
- glidepath/core/withdrawals.py +461 -0
- glidepath/core/wrappers.py +278 -0
- glidepath/gui/__init__.py +6 -0
- glidepath/gui/assets/icon_128.png +0 -0
- glidepath/gui/assets/icon_16.png +0 -0
- glidepath/gui/assets/icon_24.png +0 -0
- glidepath/gui/assets/icon_256.png +0 -0
- glidepath/gui/assets/icon_32.png +0 -0
- glidepath/gui/assets/icon_48.png +0 -0
- glidepath/gui/assets/icon_64.png +0 -0
- glidepath/gui/assets/wordmark.png +0 -0
- glidepath/gui/charts.py +829 -0
- glidepath/gui/forms.py +359 -0
- glidepath/gui/inspector.py +186 -0
- glidepath/gui/main.py +51 -0
- glidepath/gui/scenarios.py +402 -0
- glidepath/gui/style.py +376 -0
- glidepath/gui/tableview.py +67 -0
- glidepath/gui/widgets.py +989 -0
- glidepath/persistence/__init__.py +48 -0
- glidepath/persistence/assumptions.py +112 -0
- glidepath/persistence/decode.py +747 -0
- glidepath/persistence/document.py +101 -0
- glidepath/persistence/encode.py +433 -0
- glidepath/persistence/migrations.py +158 -0
- glidepath/persistence/values.py +298 -0
- glidepath/py.typed +0 -0
- glidepath/regions/__init__.py +7 -0
- glidepath/regions/uk/__init__.py +189 -0
- glidepath/regions/uk/ages.py +156 -0
- glidepath/regions/uk/contributions.py +717 -0
- glidepath/regions/uk/data/age_rules.toml +78 -0
- glidepath/regions/uk/data/assumptions_default.toml +170 -0
- glidepath/regions/uk/data/returns_history.toml +150 -0
- glidepath/regions/uk/data/tax_year_2026_27.toml +98 -0
- glidepath/regions/uk/extension.py +479 -0
- glidepath/regions/uk/loader.py +704 -0
- glidepath/regions/uk/region.py +160 -0
- glidepath/regions/uk/schema.py +563 -0
- glidepath/regions/uk/state_pension.py +129 -0
- glidepath/regions/uk/tax.py +466 -0
- glidepath/regions/uk/wrappers.py +283 -0
- glidepath/regions/uk/years.py +92 -0
- glidepath-0.2.0.dist-info/METADATA +189 -0
- glidepath-0.2.0.dist-info/RECORD +93 -0
- glidepath-0.2.0.dist-info/WHEEL +4 -0
- glidepath-0.2.0.dist-info/entry_points.txt +3 -0
- glidepath-0.2.0.dist-info/licenses/LICENSE +21 -0
- glidepath-0.2.0.dist-info/licenses/LICENSE-DATA +28 -0
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
"""Future-year extension of UK tax-year data (roadmap 2.5; planning §5.3).
|
|
2
|
+
|
|
3
|
+
Past the last shipped ``tax_year_*.toml`` the region synthesizes
|
|
4
|
+
tax-year files by extending the final shipped year per the
|
|
5
|
+
``policy.tax.future_years`` assumption (planning §7):
|
|
6
|
+
|
|
7
|
+
- ``frozen`` — every figure carried forward unchanged indefinitely
|
|
8
|
+
(nominal freeze: fiscal drag forever);
|
|
9
|
+
- ``frozen_then_cpi_indexed`` — frozen up to and including
|
|
10
|
+
``frozen_until_tax_year``, CPI-indexed thereafter. The shipped
|
|
11
|
+
default: the legislated freeze to 2030/31 (planning §6), then
|
|
12
|
+
indexation.
|
|
13
|
+
|
|
14
|
+
The 2030/31 freeze is rUK/reserved legislation: it governs the rUK
|
|
15
|
+
schedule, the pension and ISA allowances, and — in both schedules —
|
|
16
|
+
the personal allowance and its taper (reserved to Westminster). It
|
|
17
|
+
never governs the devolved Scottish band uppers, which Scotland sets
|
|
18
|
+
annually (planning §6): those follow the policy's mandatory
|
|
19
|
+
``scotland`` sub-table (:class:`ScottishBandsPolicy`), which carries
|
|
20
|
+
separate freeze ends for the lower bands (below the higher rate) and
|
|
21
|
+
the Higher/Advanced/Top group. The shipped default holds the lower
|
|
22
|
+
thresholds only through the last shipped year — Scotland uprates them
|
|
23
|
+
in practice, so CPI is the proxy — and the upper group through the
|
|
24
|
+
announced 2028/29 commitment.
|
|
25
|
+
|
|
26
|
+
There is deliberately no index-immediately mode: a legislated freeze
|
|
27
|
+
end is a fact (planning §6), and a mode without one could synthesize
|
|
28
|
+
years that contradict known legislation. A freeze end at or before the
|
|
29
|
+
last shipped year already degrades to pure CPI indexation from that
|
|
30
|
+
year, so nothing is lost once the freeze lapses.
|
|
31
|
+
|
|
32
|
+
Legislated data always beats extrapolation: extension applies only past
|
|
33
|
+
the last shipped file and re-bases automatically when a newer file
|
|
34
|
+
ships. Modelling conventions (planning §5.3): indexation scales the
|
|
35
|
+
money figures of the income-tax schedules and the pension and ISA
|
|
36
|
+
allowances, quantized to whole pounds with ``ROUND_HALF_EVEN`` (the
|
|
37
|
+
core rounding mode); band and taper *rates* never extrapolate. A
|
|
38
|
+
target year's figures compound once from the base file, so they do
|
|
39
|
+
not depend on intermediate synthesized years.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from collections.abc import Mapping
|
|
43
|
+
from dataclasses import dataclass
|
|
44
|
+
from decimal import ROUND_HALF_EVEN, Decimal
|
|
45
|
+
from enum import StrEnum
|
|
46
|
+
from functools import lru_cache
|
|
47
|
+
from typing import TYPE_CHECKING, NoReturn
|
|
48
|
+
|
|
49
|
+
from glidepath.core import Money
|
|
50
|
+
from glidepath.regions.uk.schema import (
|
|
51
|
+
DataFileError,
|
|
52
|
+
DividendRules,
|
|
53
|
+
IncomeTaxSchedule,
|
|
54
|
+
IsaRules,
|
|
55
|
+
PensionRules,
|
|
56
|
+
SavingsRules,
|
|
57
|
+
TaxBand,
|
|
58
|
+
TaxYearFile,
|
|
59
|
+
TaxYearMeta,
|
|
60
|
+
parse_tax_year_label,
|
|
61
|
+
tax_year_end,
|
|
62
|
+
tax_year_label,
|
|
63
|
+
tax_year_start,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
if TYPE_CHECKING:
|
|
67
|
+
from glidepath.core import Rate
|
|
68
|
+
from glidepath.regions.uk.schema import AssumptionValue
|
|
69
|
+
|
|
70
|
+
_CONTEXT = "policy.tax.future_years"
|
|
71
|
+
_SCOTLAND_CONTEXT = f"{_CONTEXT}.scotland"
|
|
72
|
+
_POUND = Decimal(1)
|
|
73
|
+
|
|
74
|
+
HIGHER_BAND_NAME = "higher"
|
|
75
|
+
"""The Scottish higher-rate band: anchor of the upper-threshold group.
|
|
76
|
+
|
|
77
|
+
The Scottish Government's threshold commitments split at this band —
|
|
78
|
+
lower thresholds uprate annually while Higher/Advanced/Top freeze
|
|
79
|
+
(planning §6) — so Scottish extrapolation needs it present to group
|
|
80
|
+
the band uppers.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _fail(context: str, problem: str) -> NoReturn:
|
|
85
|
+
"""Raise a :class:`DataFileError` locating ``problem`` at ``context``."""
|
|
86
|
+
msg = f"{context}: {problem}"
|
|
87
|
+
raise DataFileError(msg)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class FutureYearsMode(StrEnum):
|
|
91
|
+
"""How the last shipped tax year extrapolates (planning §5.3, §7)."""
|
|
92
|
+
|
|
93
|
+
FROZEN = "frozen"
|
|
94
|
+
FROZEN_THEN_CPI_INDEXED = "frozen_then_cpi_indexed"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _parse_mode(raw: object | None) -> FutureYearsMode:
|
|
98
|
+
"""Parse the required ``mode`` tag of the assumption value."""
|
|
99
|
+
if raw is None:
|
|
100
|
+
_fail(_CONTEXT, "missing required key 'mode'")
|
|
101
|
+
if not isinstance(raw, str):
|
|
102
|
+
_fail(f"{_CONTEXT}.mode", f"expected a string, got {type(raw).__name__}")
|
|
103
|
+
try:
|
|
104
|
+
return FutureYearsMode(raw)
|
|
105
|
+
except ValueError:
|
|
106
|
+
known = ", ".join(mode.value for mode in FutureYearsMode)
|
|
107
|
+
_fail(f"{_CONTEXT}.mode", f"unknown mode {raw!r} (one of: {known})")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _freeze_end(raw: object, context: str) -> int:
|
|
111
|
+
"""Parse a ``'YYYY/YY'`` freeze-end label into its start year."""
|
|
112
|
+
if not isinstance(raw, str):
|
|
113
|
+
_fail(context, f"expected a 'YYYY/YY' string, got {type(raw).__name__}")
|
|
114
|
+
return parse_tax_year_label(raw, context)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _indexation_steps(
|
|
118
|
+
*, base_start_year: int, target_start_year: int, frozen_until_start_year: int
|
|
119
|
+
) -> int:
|
|
120
|
+
"""Years of CPI compounding the target year gets over the base year.
|
|
121
|
+
|
|
122
|
+
Compounding starts after whichever is later of the base year (the
|
|
123
|
+
last shipped, legislated file) and the freeze end — shipped data
|
|
124
|
+
always beats extrapolation.
|
|
125
|
+
"""
|
|
126
|
+
return max(0, target_start_year - max(base_start_year, frozen_until_start_year))
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass(frozen=True, slots=True)
|
|
130
|
+
class ScottishBandsPolicy:
|
|
131
|
+
"""Devolved Scottish band-threshold extrapolation (planning §5.3, §6).
|
|
132
|
+
|
|
133
|
+
Scotland sets its income-tax thresholds annually, so the reserved
|
|
134
|
+
rUK freeze never governs them. The lower bands (below the higher
|
|
135
|
+
rate) and the Higher/Advanced/Top group each carry their own
|
|
136
|
+
freeze end.
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
lower_frozen_until_start_year: int
|
|
140
|
+
"""Start year of the last tax year the lower-band uppers hold."""
|
|
141
|
+
upper_frozen_until_start_year: int
|
|
142
|
+
"""Start year of the last tax year the higher+ band uppers hold."""
|
|
143
|
+
|
|
144
|
+
@classmethod
|
|
145
|
+
def from_assumption_value(cls, value: object) -> ScottishBandsPolicy:
|
|
146
|
+
"""Parse the policy's ``scotland`` sub-table."""
|
|
147
|
+
if not isinstance(value, Mapping):
|
|
148
|
+
_fail(
|
|
149
|
+
_SCOTLAND_CONTEXT,
|
|
150
|
+
f"expected a table value, got {type(value).__name__}",
|
|
151
|
+
)
|
|
152
|
+
entries = dict(value)
|
|
153
|
+
lower = _scotland_freeze_end(entries, "lower_bands_frozen_until_tax_year")
|
|
154
|
+
upper = _scotland_freeze_end(entries, "upper_bands_frozen_until_tax_year")
|
|
155
|
+
if entries:
|
|
156
|
+
_fail(_SCOTLAND_CONTEXT, f"unknown keys: {', '.join(sorted(entries))}")
|
|
157
|
+
return cls(
|
|
158
|
+
lower_frozen_until_start_year=lower,
|
|
159
|
+
upper_frozen_until_start_year=upper,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _scotland_freeze_end(entries: dict[str, object], key: str) -> int:
|
|
164
|
+
"""Take one required freeze-end label off the ``scotland`` sub-table."""
|
|
165
|
+
raw = entries.pop(key, None)
|
|
166
|
+
if raw is None:
|
|
167
|
+
_fail(_SCOTLAND_CONTEXT, f"missing required key {key!r}")
|
|
168
|
+
return _freeze_end(raw, f"{_SCOTLAND_CONTEXT}.{key}")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@dataclass(frozen=True, slots=True)
|
|
172
|
+
class FutureYearsPolicy:
|
|
173
|
+
"""The parsed ``policy.tax.future_years`` assumption value."""
|
|
174
|
+
|
|
175
|
+
mode: FutureYearsMode
|
|
176
|
+
frozen_until_start_year: int | None = None
|
|
177
|
+
"""Start year of the last frozen tax year (``frozen_then_cpi_indexed``)."""
|
|
178
|
+
scotland: ScottishBandsPolicy | None = None
|
|
179
|
+
"""Devolved Scottish band policy (``frozen_then_cpi_indexed`` only)."""
|
|
180
|
+
|
|
181
|
+
def __post_init__(self) -> None:
|
|
182
|
+
"""Require a real mode and the sub-fields exactly when used.
|
|
183
|
+
|
|
184
|
+
The mode must be an actual :class:`FutureYearsMode` member: a
|
|
185
|
+
bare string would pass the ``is`` identity checks below as
|
|
186
|
+
neither mode and silently index a "frozen" policy. The
|
|
187
|
+
``scotland`` table is mandatory with ``frozen_then_cpi_indexed``
|
|
188
|
+
so the reserved freeze can never silently govern the devolved
|
|
189
|
+
Scottish band uppers (module docstring).
|
|
190
|
+
"""
|
|
191
|
+
if not isinstance(self.mode, FutureYearsMode):
|
|
192
|
+
type_name = type(self.mode).__name__
|
|
193
|
+
_fail(_CONTEXT, f"mode must be a FutureYearsMode member, got {type_name}")
|
|
194
|
+
needs_until = self.mode is FutureYearsMode.FROZEN_THEN_CPI_INDEXED
|
|
195
|
+
if needs_until and self.frozen_until_start_year is None:
|
|
196
|
+
_fail(_CONTEXT, f"mode {self.mode} requires frozen_until_tax_year")
|
|
197
|
+
if not needs_until and self.frozen_until_start_year is not None:
|
|
198
|
+
_fail(_CONTEXT, f"mode {self.mode} does not take frozen_until_tax_year")
|
|
199
|
+
if needs_until and self.scotland is None:
|
|
200
|
+
_fail(_CONTEXT, f"mode {self.mode} requires a scotland table")
|
|
201
|
+
if not needs_until and self.scotland is not None:
|
|
202
|
+
_fail(_CONTEXT, f"mode {self.mode} does not take a scotland table")
|
|
203
|
+
|
|
204
|
+
@classmethod
|
|
205
|
+
def from_assumption_value(cls, value: AssumptionValue) -> FutureYearsPolicy:
|
|
206
|
+
"""Parse the assumption's table value into a typed policy."""
|
|
207
|
+
if not isinstance(value, Mapping):
|
|
208
|
+
_fail(_CONTEXT, f"expected a table value, got {type(value).__name__}")
|
|
209
|
+
entries = dict(value)
|
|
210
|
+
mode = _parse_mode(entries.pop("mode", None))
|
|
211
|
+
frozen_raw = entries.pop("frozen_until_tax_year", None)
|
|
212
|
+
scotland_raw = entries.pop("scotland", None)
|
|
213
|
+
if entries:
|
|
214
|
+
_fail(_CONTEXT, f"unknown keys: {', '.join(sorted(entries))}")
|
|
215
|
+
frozen_until: int | None = None
|
|
216
|
+
if frozen_raw is not None:
|
|
217
|
+
frozen_until = _freeze_end(frozen_raw, f"{_CONTEXT}.frozen_until_tax_year")
|
|
218
|
+
scotland: ScottishBandsPolicy | None = None
|
|
219
|
+
if scotland_raw is not None:
|
|
220
|
+
scotland = ScottishBandsPolicy.from_assumption_value(scotland_raw)
|
|
221
|
+
return cls(mode=mode, frozen_until_start_year=frozen_until, scotland=scotland)
|
|
222
|
+
|
|
223
|
+
def indexation_steps(self, *, base_start_year: int, target_start_year: int) -> int:
|
|
224
|
+
"""CPI steps for the reserved/rUK figures (module docstring)."""
|
|
225
|
+
frozen_until = self.frozen_until_start_year
|
|
226
|
+
if frozen_until is None: # __post_init__ invariant: the mode is FROZEN
|
|
227
|
+
return 0
|
|
228
|
+
return _indexation_steps(
|
|
229
|
+
base_start_year=base_start_year,
|
|
230
|
+
target_start_year=target_start_year,
|
|
231
|
+
frozen_until_start_year=frozen_until,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _require_indexable_cpi(cpi: Rate, context: str) -> None:
|
|
236
|
+
"""Reject a CPI at or below -100%: growth factors must stay positive.
|
|
237
|
+
|
|
238
|
+
A non-positive growth factor collapses or sign-flips every money
|
|
239
|
+
figure; with an even step count the output would even look valid.
|
|
240
|
+
"""
|
|
241
|
+
if cpi.growth_factor <= 0:
|
|
242
|
+
_fail(context, "CPI must be greater than -100%")
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@dataclass(frozen=True, slots=True)
|
|
246
|
+
class FutureYearsExtension:
|
|
247
|
+
"""Everything :class:`UkTaxSystem` needs to extend past shipped data."""
|
|
248
|
+
|
|
249
|
+
policy: FutureYearsPolicy
|
|
250
|
+
cpi: Rate
|
|
251
|
+
"""Assumed annual CPI (the ``inflation.cpi`` assumption)."""
|
|
252
|
+
|
|
253
|
+
def __post_init__(self) -> None:
|
|
254
|
+
"""Validate the CPI at construction time."""
|
|
255
|
+
_require_indexable_cpi(self.cpi, "FutureYearsExtension.cpi")
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _indexed_money(money: Money, factor: Decimal) -> Money:
|
|
259
|
+
"""Scale a threshold or allowance, quantized to whole pounds (§5.3)."""
|
|
260
|
+
return Money((money.amount * factor).quantize(_POUND, rounding=ROUND_HALF_EVEN))
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _indexed_band(band: TaxBand, factor: Decimal) -> TaxBand:
|
|
264
|
+
"""Index a band's upper bound; the rate never extrapolates."""
|
|
265
|
+
upper = None if band.upper is None else _indexed_money(band.upper, factor)
|
|
266
|
+
return TaxBand(name=band.name, rate=band.rate, upper=upper)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _indexed_schedule(
|
|
270
|
+
schedule: IncomeTaxSchedule, factor: Decimal
|
|
271
|
+
) -> IncomeTaxSchedule:
|
|
272
|
+
"""Index one regime's allowance, taper threshold, and band uppers."""
|
|
273
|
+
return IncomeTaxSchedule(
|
|
274
|
+
personal_allowance=_indexed_money(schedule.personal_allowance, factor),
|
|
275
|
+
pa_taper_threshold=_indexed_money(schedule.pa_taper_threshold, factor),
|
|
276
|
+
pa_taper_rate=schedule.pa_taper_rate,
|
|
277
|
+
bands=tuple(_indexed_band(band, factor) for band in schedule.bands),
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _scottish_split_index(bands: tuple[TaxBand, ...]) -> int:
|
|
282
|
+
"""The index of the higher band — where the upper group starts."""
|
|
283
|
+
for index, band in enumerate(bands):
|
|
284
|
+
if band.name == HIGHER_BAND_NAME:
|
|
285
|
+
return index
|
|
286
|
+
_fail(
|
|
287
|
+
"income_tax.scotland",
|
|
288
|
+
f"no band named {HIGHER_BAND_NAME!r} to anchor the upper-threshold group",
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _indexed_scottish_schedule(
|
|
293
|
+
schedule: IncomeTaxSchedule,
|
|
294
|
+
*,
|
|
295
|
+
reserved_factor: Decimal,
|
|
296
|
+
lower_factor: Decimal,
|
|
297
|
+
upper_factor: Decimal,
|
|
298
|
+
) -> IncomeTaxSchedule:
|
|
299
|
+
"""Index the Scottish schedule per its devolved band groups (§5.3).
|
|
300
|
+
|
|
301
|
+
The personal allowance and its taper are reserved to Westminster,
|
|
302
|
+
so they scale by the UK-wide policy factor; band uppers below the
|
|
303
|
+
higher band take the lower-group factor, the higher band and above
|
|
304
|
+
the upper-group factor.
|
|
305
|
+
"""
|
|
306
|
+
split = _scottish_split_index(schedule.bands)
|
|
307
|
+
return IncomeTaxSchedule(
|
|
308
|
+
personal_allowance=_indexed_money(schedule.personal_allowance, reserved_factor),
|
|
309
|
+
pa_taper_threshold=_indexed_money(schedule.pa_taper_threshold, reserved_factor),
|
|
310
|
+
pa_taper_rate=schedule.pa_taper_rate,
|
|
311
|
+
bands=tuple(
|
|
312
|
+
_indexed_band(band, lower_factor if index < split else upper_factor)
|
|
313
|
+
for index, band in enumerate(schedule.bands)
|
|
314
|
+
),
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _indexed_pension(pension: PensionRules, factor: Decimal) -> PensionRules:
|
|
319
|
+
"""Index the pension allowances; relief and taper rates are unchanged."""
|
|
320
|
+
return PensionRules(
|
|
321
|
+
annual_allowance=_indexed_money(pension.annual_allowance, factor),
|
|
322
|
+
aa_taper_threshold_income=_indexed_money(
|
|
323
|
+
pension.aa_taper_threshold_income, factor
|
|
324
|
+
),
|
|
325
|
+
aa_taper_adjusted_income=_indexed_money(
|
|
326
|
+
pension.aa_taper_adjusted_income, factor
|
|
327
|
+
),
|
|
328
|
+
aa_taper_rate=pension.aa_taper_rate,
|
|
329
|
+
aa_taper_floor=_indexed_money(pension.aa_taper_floor, factor),
|
|
330
|
+
mpaa=_indexed_money(pension.mpaa, factor),
|
|
331
|
+
aa_carry_forward_years=pension.aa_carry_forward_years,
|
|
332
|
+
# The scheme-pays minimum is a fixed statutory figure (FA 2004
|
|
333
|
+
# s237B, unchanged since 2011) — never indexed.
|
|
334
|
+
scheme_pays_min_charge=pension.scheme_pays_min_charge,
|
|
335
|
+
member_relief_basic_amount=_indexed_money(
|
|
336
|
+
pension.member_relief_basic_amount, factor
|
|
337
|
+
),
|
|
338
|
+
member_relief_max_age=pension.member_relief_max_age,
|
|
339
|
+
relief_at_source_rate=pension.relief_at_source_rate,
|
|
340
|
+
tax_free_lump_sum_fraction=pension.tax_free_lump_sum_fraction,
|
|
341
|
+
lump_sum_allowance=_indexed_money(pension.lump_sum_allowance, factor),
|
|
342
|
+
lump_sum_death_benefit_allowance=_indexed_money(
|
|
343
|
+
pension.lump_sum_death_benefit_allowance, factor
|
|
344
|
+
),
|
|
345
|
+
db_valuation_factor=pension.db_valuation_factor,
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _indexed_isa(isa: IsaRules, factor: Decimal) -> IsaRules:
|
|
350
|
+
"""Index the ISA allowances; the LISA bonus and charge are unchanged."""
|
|
351
|
+
return IsaRules(
|
|
352
|
+
annual_allowance=_indexed_money(isa.annual_allowance, factor),
|
|
353
|
+
lisa_allowance=_indexed_money(isa.lisa_allowance, factor),
|
|
354
|
+
lisa_bonus_rate=isa.lisa_bonus_rate,
|
|
355
|
+
lisa_withdrawal_charge=isa.lisa_withdrawal_charge,
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _indexed_savings(savings: SavingsRules, factor: Decimal) -> SavingsRules:
|
|
360
|
+
"""Index the savings nil-rate amounts (reserved figures, §5.3).
|
|
361
|
+
|
|
362
|
+
The starting-rate limit is legislated frozen with the rUK schedule
|
|
363
|
+
(planning §6), and the PSA amounts follow the same reserved policy;
|
|
364
|
+
a zero tier (the additional-rate PSA) stays zero under any factor.
|
|
365
|
+
"""
|
|
366
|
+
return SavingsRules(
|
|
367
|
+
starting_rate_limit=_indexed_money(savings.starting_rate_limit, factor),
|
|
368
|
+
psa_basic=_indexed_money(savings.psa_basic, factor),
|
|
369
|
+
psa_higher=_indexed_money(savings.psa_higher, factor),
|
|
370
|
+
psa_additional=_indexed_money(savings.psa_additional, factor),
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _indexed_dividend(dividend: DividendRules, factor: Decimal) -> DividendRules:
|
|
375
|
+
"""Index the dividend allowance; the rates never extrapolate."""
|
|
376
|
+
return DividendRules(
|
|
377
|
+
allowance=_indexed_money(dividend.allowance, factor),
|
|
378
|
+
rates=dividend.rates,
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
@lru_cache(maxsize=256)
|
|
383
|
+
def extend_tax_year(
|
|
384
|
+
base: TaxYearFile,
|
|
385
|
+
target_start_year: int,
|
|
386
|
+
*,
|
|
387
|
+
policy: FutureYearsPolicy,
|
|
388
|
+
cpi: Rate,
|
|
389
|
+
) -> TaxYearFile:
|
|
390
|
+
"""Synthesize the tax-year file for ``target_start_year`` from ``base``.
|
|
391
|
+
|
|
392
|
+
``base`` should be the last shipped file: extension only reaches
|
|
393
|
+
forward, and shipped (legislated) data always beats extrapolation.
|
|
394
|
+
The synthesized meta keeps the base file's ``verified_on`` and
|
|
395
|
+
``sources`` — they date the figures the extrapolation rests on.
|
|
396
|
+
|
|
397
|
+
The function is pure over immutable inputs, so it is memoized:
|
|
398
|
+
every projection date past the last shipped year resolves through
|
|
399
|
+
here (:meth:`~glidepath.regions.uk.years.TaxYearSeries.year_containing`),
|
|
400
|
+
which made re-synthesis the dominant cost of a Monte Carlo run
|
|
401
|
+
(planning §5.2). The cache bound comfortably holds a horizon's worth
|
|
402
|
+
of years for several concurrent policy/CPI variants (scenario
|
|
403
|
+
overrides build their own regions).
|
|
404
|
+
|
|
405
|
+
Raises:
|
|
406
|
+
ValueError: If ``target_start_year`` is not after the base year.
|
|
407
|
+
DataFileError: If ``cpi`` is at or below -100%.
|
|
408
|
+
"""
|
|
409
|
+
_require_indexable_cpi(cpi, "extend_tax_year.cpi")
|
|
410
|
+
base_start_year = base.meta.start_date.year
|
|
411
|
+
if target_start_year <= base_start_year:
|
|
412
|
+
msg = (
|
|
413
|
+
f"target start year {target_start_year} is not after the last"
|
|
414
|
+
f" shipped tax year {base.meta.tax_year}; use the shipped file"
|
|
415
|
+
)
|
|
416
|
+
raise ValueError(msg)
|
|
417
|
+
meta = TaxYearMeta(
|
|
418
|
+
tax_year=tax_year_label(target_start_year),
|
|
419
|
+
start_date=tax_year_start(target_start_year),
|
|
420
|
+
end_date=tax_year_end(target_start_year),
|
|
421
|
+
verified_on=base.meta.verified_on,
|
|
422
|
+
sources=base.meta.sources,
|
|
423
|
+
)
|
|
424
|
+
steps = policy.indexation_steps(
|
|
425
|
+
base_start_year=base_start_year, target_start_year=target_start_year
|
|
426
|
+
)
|
|
427
|
+
scotland_policy = policy.scotland
|
|
428
|
+
if scotland_policy is None:
|
|
429
|
+
# __post_init__ invariant: the mode is FROZEN (steps is 0) —
|
|
430
|
+
# every figure carries forward however distant the target.
|
|
431
|
+
return TaxYearFile(
|
|
432
|
+
schema_version=base.schema_version,
|
|
433
|
+
meta=meta,
|
|
434
|
+
income_tax_ruk=base.income_tax_ruk,
|
|
435
|
+
income_tax_scotland=base.income_tax_scotland,
|
|
436
|
+
pension=base.pension,
|
|
437
|
+
isa=base.isa,
|
|
438
|
+
savings=base.savings,
|
|
439
|
+
dividend=base.dividend,
|
|
440
|
+
)
|
|
441
|
+
factor = cpi.growth_factor**steps
|
|
442
|
+
lower_steps = _indexation_steps(
|
|
443
|
+
base_start_year=base_start_year,
|
|
444
|
+
target_start_year=target_start_year,
|
|
445
|
+
frozen_until_start_year=scotland_policy.lower_frozen_until_start_year,
|
|
446
|
+
)
|
|
447
|
+
upper_steps = _indexation_steps(
|
|
448
|
+
base_start_year=base_start_year,
|
|
449
|
+
target_start_year=target_start_year,
|
|
450
|
+
frozen_until_start_year=scotland_policy.upper_frozen_until_start_year,
|
|
451
|
+
)
|
|
452
|
+
scotland = (
|
|
453
|
+
base.income_tax_scotland
|
|
454
|
+
if steps == 0 and lower_steps == 0 and upper_steps == 0
|
|
455
|
+
else _indexed_scottish_schedule(
|
|
456
|
+
base.income_tax_scotland,
|
|
457
|
+
reserved_factor=factor,
|
|
458
|
+
lower_factor=cpi.growth_factor**lower_steps,
|
|
459
|
+
upper_factor=cpi.growth_factor**upper_steps,
|
|
460
|
+
)
|
|
461
|
+
)
|
|
462
|
+
return TaxYearFile(
|
|
463
|
+
schema_version=base.schema_version,
|
|
464
|
+
meta=meta,
|
|
465
|
+
income_tax_ruk=(
|
|
466
|
+
base.income_tax_ruk
|
|
467
|
+
if steps == 0
|
|
468
|
+
else _indexed_schedule(base.income_tax_ruk, factor)
|
|
469
|
+
),
|
|
470
|
+
income_tax_scotland=scotland,
|
|
471
|
+
pension=base.pension if steps == 0 else _indexed_pension(base.pension, factor),
|
|
472
|
+
isa=base.isa if steps == 0 else _indexed_isa(base.isa, factor),
|
|
473
|
+
savings=(
|
|
474
|
+
base.savings if steps == 0 else _indexed_savings(base.savings, factor)
|
|
475
|
+
),
|
|
476
|
+
dividend=(
|
|
477
|
+
base.dividend if steps == 0 else _indexed_dividend(base.dividend, factor)
|
|
478
|
+
),
|
|
479
|
+
)
|