glidepath 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. glidepath/__init__.py +3 -0
  2. glidepath/app/__init__.py +364 -0
  3. glidepath/app/backtest.py +281 -0
  4. glidepath/app/charts.py +759 -0
  5. glidepath/app/copy.py +174 -0
  6. glidepath/app/display.py +148 -0
  7. glidepath/app/drawdown.py +436 -0
  8. glidepath/app/example.py +66 -0
  9. glidepath/app/exports.py +487 -0
  10. glidepath/app/files.py +249 -0
  11. glidepath/app/firstrun.py +114 -0
  12. glidepath/app/forms.py +1750 -0
  13. glidepath/app/inspector.py +506 -0
  14. glidepath/app/labels.py +66 -0
  15. glidepath/app/montecarlo.py +399 -0
  16. glidepath/app/plan.py +354 -0
  17. glidepath/app/retirement.py +446 -0
  18. glidepath/app/scenarios.py +831 -0
  19. glidepath/app/shell.py +185 -0
  20. glidepath/app/tables.py +138 -0
  21. glidepath/core/__init__.py +390 -0
  22. glidepath/core/annuities.py +240 -0
  23. glidepath/core/backtest.py +514 -0
  24. glidepath/core/comparison.py +278 -0
  25. glidepath/core/config.py +82 -0
  26. glidepath/core/contributions.py +337 -0
  27. glidepath/core/engine.py +2811 -0
  28. glidepath/core/entities.py +264 -0
  29. glidepath/core/glide.py +289 -0
  30. glidepath/core/investments.py +175 -0
  31. glidepath/core/money.py +107 -0
  32. glidepath/core/montecarlo.py +609 -0
  33. glidepath/core/pensions.py +298 -0
  34. glidepath/core/periods.py +367 -0
  35. glidepath/core/provenance.py +271 -0
  36. glidepath/core/randomness.py +128 -0
  37. glidepath/core/region.py +46 -0
  38. glidepath/core/reporting.py +231 -0
  39. glidepath/core/results.py +504 -0
  40. glidepath/core/retirement.py +291 -0
  41. glidepath/core/returns.py +312 -0
  42. glidepath/core/scenarios.py +579 -0
  43. glidepath/core/state_pension.py +264 -0
  44. glidepath/core/tax.py +139 -0
  45. glidepath/core/withdrawals.py +461 -0
  46. glidepath/core/wrappers.py +278 -0
  47. glidepath/gui/__init__.py +6 -0
  48. glidepath/gui/assets/icon_128.png +0 -0
  49. glidepath/gui/assets/icon_16.png +0 -0
  50. glidepath/gui/assets/icon_24.png +0 -0
  51. glidepath/gui/assets/icon_256.png +0 -0
  52. glidepath/gui/assets/icon_32.png +0 -0
  53. glidepath/gui/assets/icon_48.png +0 -0
  54. glidepath/gui/assets/icon_64.png +0 -0
  55. glidepath/gui/assets/wordmark.png +0 -0
  56. glidepath/gui/charts.py +829 -0
  57. glidepath/gui/forms.py +359 -0
  58. glidepath/gui/inspector.py +186 -0
  59. glidepath/gui/main.py +51 -0
  60. glidepath/gui/scenarios.py +402 -0
  61. glidepath/gui/style.py +376 -0
  62. glidepath/gui/tableview.py +67 -0
  63. glidepath/gui/widgets.py +989 -0
  64. glidepath/persistence/__init__.py +48 -0
  65. glidepath/persistence/assumptions.py +112 -0
  66. glidepath/persistence/decode.py +747 -0
  67. glidepath/persistence/document.py +101 -0
  68. glidepath/persistence/encode.py +433 -0
  69. glidepath/persistence/migrations.py +158 -0
  70. glidepath/persistence/values.py +298 -0
  71. glidepath/py.typed +0 -0
  72. glidepath/regions/__init__.py +7 -0
  73. glidepath/regions/uk/__init__.py +189 -0
  74. glidepath/regions/uk/ages.py +156 -0
  75. glidepath/regions/uk/contributions.py +717 -0
  76. glidepath/regions/uk/data/age_rules.toml +78 -0
  77. glidepath/regions/uk/data/assumptions_default.toml +170 -0
  78. glidepath/regions/uk/data/returns_history.toml +150 -0
  79. glidepath/regions/uk/data/tax_year_2026_27.toml +98 -0
  80. glidepath/regions/uk/extension.py +479 -0
  81. glidepath/regions/uk/loader.py +704 -0
  82. glidepath/regions/uk/region.py +160 -0
  83. glidepath/regions/uk/schema.py +563 -0
  84. glidepath/regions/uk/state_pension.py +129 -0
  85. glidepath/regions/uk/tax.py +466 -0
  86. glidepath/regions/uk/wrappers.py +283 -0
  87. glidepath/regions/uk/years.py +92 -0
  88. glidepath-0.2.0.dist-info/METADATA +189 -0
  89. glidepath-0.2.0.dist-info/RECORD +93 -0
  90. glidepath-0.2.0.dist-info/WHEEL +4 -0
  91. glidepath-0.2.0.dist-info/entry_points.txt +3 -0
  92. glidepath-0.2.0.dist-info/licenses/LICENSE +21 -0
  93. glidepath-0.2.0.dist-info/licenses/LICENSE-DATA +28 -0
@@ -0,0 +1,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
+ )