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,563 @@
|
|
|
1
|
+
"""Typed shapes of the UK region data files (planning §5.3).
|
|
2
|
+
|
|
3
|
+
Every UK policy figure lives in a TOML data file under
|
|
4
|
+
``glidepath/regions/uk/data/`` — never in code (guard-tested). The strict
|
|
5
|
+
loader (:mod:`glidepath.regions.uk.loader`) parses those files into the
|
|
6
|
+
frozen dataclasses defined here. Cross-field invariants (band ordering,
|
|
7
|
+
SPA band contiguity, assumption-key completeness) are enforced in
|
|
8
|
+
``__post_init__`` so any instance that exists is valid.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from collections.abc import Mapping
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from datetime import date, timedelta
|
|
15
|
+
from itertools import pairwise
|
|
16
|
+
from typing import TYPE_CHECKING, NoReturn
|
|
17
|
+
|
|
18
|
+
from glidepath.core import AssumptionKey, HistoricalSeries, Money, Rate
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from collections.abc import Iterator
|
|
22
|
+
from decimal import Decimal
|
|
23
|
+
|
|
24
|
+
SCHEMA_VERSION = 2
|
|
25
|
+
"""The data-file schema version this code understands.
|
|
26
|
+
|
|
27
|
+
v2 (#97): tax-year files lose the ``[state_pension]`` table and
|
|
28
|
+
``age_rules.toml`` the ``[new_state_pension]`` table — the state
|
|
29
|
+
pension amount is the user's stated DWP forecast, never a shipped
|
|
30
|
+
rate.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
_TAX_YEAR_FORMAT = re.compile(r"\d{4}/\d{2}")
|
|
34
|
+
_MONTHS_PER_YEAR = 12
|
|
35
|
+
_TAX_YEAR_START = (4, 6) # UK tax years run 6 April - 5 April.
|
|
36
|
+
_TAX_YEAR_END = (4, 5)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class DataFileError(ValueError):
|
|
40
|
+
"""A UK region data file failed strict validation (planning §5.3)."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _fail(context: str, problem: str) -> NoReturn:
|
|
44
|
+
"""Raise a :class:`DataFileError` locating ``problem`` at ``context``."""
|
|
45
|
+
msg = f"{context}: {problem}"
|
|
46
|
+
raise DataFileError(msg)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def tax_year_label(start_year: int) -> str:
|
|
50
|
+
"""The ``YYYY/YY`` label of the tax year starting 6 April ``start_year``."""
|
|
51
|
+
return f"{start_year}/{(start_year + 1) % 100:02d}"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def tax_year_start(start_year: int) -> date:
|
|
55
|
+
"""6 April of ``start_year``: the first day of that tax year."""
|
|
56
|
+
month, day = _TAX_YEAR_START
|
|
57
|
+
return date(start_year, month, day)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def tax_year_end(start_year: int) -> date:
|
|
61
|
+
"""5 April of the following year: the last day of that tax year."""
|
|
62
|
+
month, day = _TAX_YEAR_END
|
|
63
|
+
return date(start_year + 1, month, day)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def tax_year_start_year(day: date) -> int:
|
|
67
|
+
"""The start year of the UK tax year containing ``day``."""
|
|
68
|
+
return day.year if day >= tax_year_start(day.year) else day.year - 1
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def parse_tax_year_label(label: str, context: str) -> int:
|
|
72
|
+
"""Parse a ``YYYY/YY`` label into its start year, validating the suffix."""
|
|
73
|
+
if not _TAX_YEAR_FORMAT.fullmatch(label):
|
|
74
|
+
_fail(context, f"tax_year {label!r} is not 'YYYY/YY'")
|
|
75
|
+
start_year = int(label[:4])
|
|
76
|
+
if int(label[5:]) != (start_year + 1) % 100:
|
|
77
|
+
_fail(context, f"tax_year {label!r} suffix is not start+1")
|
|
78
|
+
return start_year
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _require_sources(sources: tuple[str, ...], owner: str) -> None:
|
|
82
|
+
"""Every data file must cite at least one source (planning §5.3)."""
|
|
83
|
+
if not sources:
|
|
84
|
+
_fail(owner, "meta.sources must list at least one source")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _require_schema_version(version: int, owner: str) -> None:
|
|
88
|
+
"""Reject files written against a different schema version."""
|
|
89
|
+
if version != SCHEMA_VERSION:
|
|
90
|
+
_fail(owner, f"schema_version {version} is not supported ({SCHEMA_VERSION})")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _require_dob_order(dob_from: date | None, dob_to: date | None, owner: str) -> None:
|
|
94
|
+
"""A band's date-of-birth range must not be inverted."""
|
|
95
|
+
if dob_from is not None and dob_to is not None and dob_from > dob_to:
|
|
96
|
+
_fail(owner, f"dob_from {dob_from} is after dob_to {dob_to}")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True, slots=True)
|
|
100
|
+
class FileMeta:
|
|
101
|
+
"""The mandatory ``[meta]`` table of a non-tax-year data file."""
|
|
102
|
+
|
|
103
|
+
verified_on: date
|
|
104
|
+
sources: tuple[str, ...]
|
|
105
|
+
|
|
106
|
+
def __post_init__(self) -> None:
|
|
107
|
+
"""Require at least one source."""
|
|
108
|
+
_require_sources(self.sources, "FileMeta")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass(frozen=True, slots=True)
|
|
112
|
+
class TaxYearMeta:
|
|
113
|
+
"""The mandatory ``[meta]`` table of a tax-year file."""
|
|
114
|
+
|
|
115
|
+
tax_year: str
|
|
116
|
+
start_date: date
|
|
117
|
+
end_date: date
|
|
118
|
+
verified_on: date
|
|
119
|
+
sources: tuple[str, ...]
|
|
120
|
+
|
|
121
|
+
def __post_init__(self) -> None:
|
|
122
|
+
"""Require a coherent UK tax-year label and date range."""
|
|
123
|
+
_require_sources(self.sources, "TaxYearMeta")
|
|
124
|
+
start_year = parse_tax_year_label(self.tax_year, "TaxYearMeta")
|
|
125
|
+
if self.start_date != tax_year_start(start_year):
|
|
126
|
+
_fail("TaxYearMeta", "start_date must be 6 April of the start year")
|
|
127
|
+
if self.end_date != tax_year_end(start_year):
|
|
128
|
+
_fail("TaxYearMeta", "end_date must be 5 April of the following year")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
BASIC_BAND_NAME = "basic"
|
|
132
|
+
"""The statutory basic-rate band every regime's ladder must define.
|
|
133
|
+
|
|
134
|
+
Relief at source extends the basic rate limit and every rate limit
|
|
135
|
+
above it — never the limits below it, i.e. the Scottish starter band
|
|
136
|
+
(FA 2004 s192; SI 2018/459 for Scottish taxpayers) — so the assessment
|
|
137
|
+
needs this anchor present in every schedule.
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@dataclass(frozen=True, slots=True)
|
|
142
|
+
class TaxBand:
|
|
143
|
+
"""One income-tax band: width is taxable income above the allowance."""
|
|
144
|
+
|
|
145
|
+
name: str
|
|
146
|
+
rate: Rate
|
|
147
|
+
upper: Money | None
|
|
148
|
+
"""Cumulative taxable-income upper bound; ``None`` = unbounded top band."""
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@dataclass(frozen=True, slots=True)
|
|
152
|
+
class IncomeTaxSchedule:
|
|
153
|
+
"""Personal allowance, taper, and ascending bands for one regime."""
|
|
154
|
+
|
|
155
|
+
personal_allowance: Money
|
|
156
|
+
pa_taper_threshold: Money
|
|
157
|
+
pa_taper_rate: Rate
|
|
158
|
+
bands: tuple[TaxBand, ...]
|
|
159
|
+
|
|
160
|
+
def __post_init__(self) -> None:
|
|
161
|
+
"""Require ascending bands, one unbounded top, one basic band."""
|
|
162
|
+
owner = "IncomeTaxSchedule"
|
|
163
|
+
if not self.bands:
|
|
164
|
+
_fail(owner, "at least one tax band is required")
|
|
165
|
+
previous_upper: Money | None = None
|
|
166
|
+
last_index = len(self.bands) - 1
|
|
167
|
+
for index, band in enumerate(self.bands):
|
|
168
|
+
if (band.upper is None) != (index == last_index):
|
|
169
|
+
_fail(owner, "exactly the last band must omit 'upper'")
|
|
170
|
+
if band.upper is None:
|
|
171
|
+
continue
|
|
172
|
+
if previous_upper is not None and band.upper <= previous_upper:
|
|
173
|
+
_fail(owner, "band uppers must be strictly increasing")
|
|
174
|
+
previous_upper = band.upper
|
|
175
|
+
basic_count = sum(band.name == BASIC_BAND_NAME for band in self.bands)
|
|
176
|
+
if basic_count != 1:
|
|
177
|
+
_fail(owner, f"exactly one band must be named {BASIC_BAND_NAME!r}")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@dataclass(frozen=True, slots=True)
|
|
181
|
+
class PensionRules:
|
|
182
|
+
"""Pension allowances and relief parameters for one tax year."""
|
|
183
|
+
|
|
184
|
+
annual_allowance: Money
|
|
185
|
+
aa_taper_threshold_income: Money
|
|
186
|
+
aa_taper_adjusted_income: Money
|
|
187
|
+
aa_taper_rate: Rate
|
|
188
|
+
aa_taper_floor: Money
|
|
189
|
+
mpaa: Money
|
|
190
|
+
aa_carry_forward_years: int
|
|
191
|
+
"""How many previous tax years' unused AA may carry forward (FA 2004 s228A)."""
|
|
192
|
+
scheme_pays_min_charge: Money
|
|
193
|
+
"""Mandatory scheme pays applies above this AA charge (FA 2004 s237B)."""
|
|
194
|
+
member_relief_basic_amount: Money
|
|
195
|
+
"""Relief floor for low/no earners, available via relief at source only."""
|
|
196
|
+
member_relief_max_age: int
|
|
197
|
+
"""No relief on contributions from this age on (FA 2004 s188(3)(a))."""
|
|
198
|
+
relief_at_source_rate: Rate
|
|
199
|
+
tax_free_lump_sum_fraction: Rate
|
|
200
|
+
lump_sum_allowance: Money
|
|
201
|
+
lump_sum_death_benefit_allowance: Money
|
|
202
|
+
db_valuation_factor: int
|
|
203
|
+
"""DB pension input amounts are ``factor x annual pension`` (FA 2004 s234)."""
|
|
204
|
+
|
|
205
|
+
def __post_init__(self) -> None:
|
|
206
|
+
"""Require a positive relief age limit and a sensible carry window."""
|
|
207
|
+
if self.member_relief_max_age <= 0:
|
|
208
|
+
_fail("PensionRules", "member_relief_max_age must be positive")
|
|
209
|
+
if self.aa_carry_forward_years < 0:
|
|
210
|
+
_fail("PensionRules", "aa_carry_forward_years must be non-negative")
|
|
211
|
+
if self.db_valuation_factor <= 0:
|
|
212
|
+
_fail("PensionRules", "db_valuation_factor must be positive")
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@dataclass(frozen=True, slots=True)
|
|
216
|
+
class IsaRules:
|
|
217
|
+
"""ISA and LISA allowances and rates for one tax year."""
|
|
218
|
+
|
|
219
|
+
annual_allowance: Money
|
|
220
|
+
lisa_allowance: Money
|
|
221
|
+
lisa_bonus_rate: Rate
|
|
222
|
+
lisa_withdrawal_charge: Rate
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@dataclass(frozen=True, slots=True)
|
|
226
|
+
class SavingsRules:
|
|
227
|
+
"""Savings-income nil rates for one tax year (planning §6, roadmap 9.2).
|
|
228
|
+
|
|
229
|
+
``starting_rate_limit`` is the 0% starting rate for savings band,
|
|
230
|
+
reduced £1 per £1 of non-savings taxable income above the personal
|
|
231
|
+
allowance. The ``psa_*`` fields are the personal savings allowance
|
|
232
|
+
by the band the taxpayer's income reaches — nil *rates*, not
|
|
233
|
+
deductions: nil-rated income still consumes band width (§6). Savings
|
|
234
|
+
income above the nil rates is taxed at the rUK band rates until the
|
|
235
|
+
separate savings rates take effect (2027/28, shipped as data then).
|
|
236
|
+
"""
|
|
237
|
+
|
|
238
|
+
starting_rate_limit: Money
|
|
239
|
+
psa_basic: Money
|
|
240
|
+
psa_higher: Money
|
|
241
|
+
psa_additional: Money
|
|
242
|
+
|
|
243
|
+
def __post_init__(self) -> None:
|
|
244
|
+
"""Require descending PSA tiers — the statutory shape."""
|
|
245
|
+
if not self.psa_basic >= self.psa_higher >= self.psa_additional:
|
|
246
|
+
_fail(
|
|
247
|
+
"SavingsRules", "PSA tiers must satisfy basic >= higher >= additional"
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
@dataclass(frozen=True, slots=True)
|
|
252
|
+
class DividendRate:
|
|
253
|
+
"""One dividend rate, aligned positionally with the rUK band ladder."""
|
|
254
|
+
|
|
255
|
+
name: str
|
|
256
|
+
rate: Rate
|
|
257
|
+
|
|
258
|
+
def __post_init__(self) -> None:
|
|
259
|
+
"""Reject unnamed rates."""
|
|
260
|
+
if not self.name:
|
|
261
|
+
_fail("DividendRate", "name must not be empty")
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
@dataclass(frozen=True, slots=True)
|
|
265
|
+
class DividendRules:
|
|
266
|
+
"""Dividend allowance and rates for one tax year (planning §6).
|
|
267
|
+
|
|
268
|
+
The allowance is a nil rate consuming band width (§6). ``rates``
|
|
269
|
+
map positionally onto the rUK income-tax bands (dividends are
|
|
270
|
+
UK-wide, never Scottish-banded) — one rate per band, enforced
|
|
271
|
+
against the rUK ladder by :class:`TaxYearFile`.
|
|
272
|
+
"""
|
|
273
|
+
|
|
274
|
+
allowance: Money
|
|
275
|
+
rates: tuple[DividendRate, ...]
|
|
276
|
+
|
|
277
|
+
def __post_init__(self) -> None:
|
|
278
|
+
"""Require at least one rate."""
|
|
279
|
+
if not self.rates:
|
|
280
|
+
_fail("DividendRules", "at least one dividend rate is required")
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
@dataclass(frozen=True, slots=True)
|
|
284
|
+
class TaxYearFile:
|
|
285
|
+
"""One fully validated ``tax_year_YYYY_YY.toml`` data file."""
|
|
286
|
+
|
|
287
|
+
schema_version: int
|
|
288
|
+
meta: TaxYearMeta
|
|
289
|
+
income_tax_ruk: IncomeTaxSchedule
|
|
290
|
+
income_tax_scotland: IncomeTaxSchedule
|
|
291
|
+
pension: PensionRules
|
|
292
|
+
isa: IsaRules
|
|
293
|
+
savings: SavingsRules
|
|
294
|
+
dividend: DividendRules
|
|
295
|
+
|
|
296
|
+
def __post_init__(self) -> None:
|
|
297
|
+
"""Reject unsupported versions and misaligned dividend rates."""
|
|
298
|
+
_require_schema_version(self.schema_version, "TaxYearFile")
|
|
299
|
+
if len(self.dividend.rates) != len(self.income_tax_ruk.bands):
|
|
300
|
+
_fail(
|
|
301
|
+
"TaxYearFile",
|
|
302
|
+
"dividend.rates must align one-to-one with the rUK bands",
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
def __hash__(self) -> int:
|
|
306
|
+
"""Hash the meta table only, not the whole figure tree.
|
|
307
|
+
|
|
308
|
+
Equal files always carry equal metas, so the hash/eq contract
|
|
309
|
+
holds; distinct files with the same meta merely collide, and
|
|
310
|
+
equality still decides. The synthesis cache
|
|
311
|
+
(:func:`~glidepath.regions.uk.extension.extend_tax_year`) hashes
|
|
312
|
+
its base file on every tax-year lookup, where the generated
|
|
313
|
+
field-tuple hash walked every figure in the file (planning §5.2).
|
|
314
|
+
"""
|
|
315
|
+
return hash(self.meta)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
@dataclass(frozen=True, slots=True)
|
|
319
|
+
class NmpaStep:
|
|
320
|
+
"""One effective-dated normal-minimum-pension-age value."""
|
|
321
|
+
|
|
322
|
+
age: int
|
|
323
|
+
effective_from: date | None
|
|
324
|
+
"""``None`` marks the baseline step already in force."""
|
|
325
|
+
|
|
326
|
+
def __post_init__(self) -> None:
|
|
327
|
+
"""Require a positive age."""
|
|
328
|
+
if self.age <= 0:
|
|
329
|
+
_fail("NmpaStep", "age must be positive")
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
@dataclass(frozen=True, slots=True)
|
|
333
|
+
class SpaAgeBand:
|
|
334
|
+
"""A DOB band whose state pension age is a years-and-months age."""
|
|
335
|
+
|
|
336
|
+
dob_from: date | None
|
|
337
|
+
dob_to: date | None
|
|
338
|
+
years: int
|
|
339
|
+
months: int
|
|
340
|
+
|
|
341
|
+
def __post_init__(self) -> None:
|
|
342
|
+
"""Require a plausible age and an ordered DOB range."""
|
|
343
|
+
owner = "SpaAgeBand"
|
|
344
|
+
_require_dob_order(self.dob_from, self.dob_to, owner)
|
|
345
|
+
if self.years <= 0:
|
|
346
|
+
_fail(owner, "years must be positive")
|
|
347
|
+
if not 0 <= self.months < _MONTHS_PER_YEAR:
|
|
348
|
+
_fail(owner, "months must be between 0 and 11")
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
@dataclass(frozen=True, slots=True)
|
|
352
|
+
class SpaDateBand:
|
|
353
|
+
"""A DOB band whose members reach state pension age on a fixed date."""
|
|
354
|
+
|
|
355
|
+
dob_from: date | None
|
|
356
|
+
dob_to: date | None
|
|
357
|
+
reaches_on: date
|
|
358
|
+
|
|
359
|
+
def __post_init__(self) -> None:
|
|
360
|
+
"""Require an ordered DOB range."""
|
|
361
|
+
_require_dob_order(self.dob_from, self.dob_to, "SpaDateBand")
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
type SpaBand = SpaAgeBand | SpaDateBand
|
|
365
|
+
"""One row of the state-pension-age timetable (age-based or date-based)."""
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
@dataclass(frozen=True, slots=True)
|
|
369
|
+
class LisaAges:
|
|
370
|
+
"""LISA age gates: open 18-39, contribute to 50, access at 60 (§6)."""
|
|
371
|
+
|
|
372
|
+
open_age_min: int
|
|
373
|
+
open_age_max: int
|
|
374
|
+
contribute_until_age: int
|
|
375
|
+
access_age: int
|
|
376
|
+
|
|
377
|
+
def __post_init__(self) -> None:
|
|
378
|
+
"""Require the gates in ascending order."""
|
|
379
|
+
ordered = (
|
|
380
|
+
0
|
|
381
|
+
< self.open_age_min
|
|
382
|
+
<= self.open_age_max
|
|
383
|
+
<= self.contribute_until_age
|
|
384
|
+
<= self.access_age
|
|
385
|
+
)
|
|
386
|
+
if not ordered:
|
|
387
|
+
_fail(
|
|
388
|
+
"LisaAges",
|
|
389
|
+
"ages must satisfy 0 < open_min <= open_max <= contribute <= access",
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
@dataclass(frozen=True, slots=True)
|
|
394
|
+
class StatePensionDeferral:
|
|
395
|
+
"""State pension deferral increment (post-2016 rules)."""
|
|
396
|
+
|
|
397
|
+
increment_rate: Rate
|
|
398
|
+
per_weeks: int
|
|
399
|
+
|
|
400
|
+
def __post_init__(self) -> None:
|
|
401
|
+
"""Require a positive increment earned over a positive interval."""
|
|
402
|
+
owner = "StatePensionDeferral"
|
|
403
|
+
if self.increment_rate.value <= 0:
|
|
404
|
+
_fail(owner, "increment_rate must be positive")
|
|
405
|
+
if self.per_weeks <= 0:
|
|
406
|
+
_fail(owner, "per_weeks must be positive")
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _validate_nmpa_steps(steps: tuple[NmpaStep, ...]) -> None:
|
|
410
|
+
"""The first step is the baseline; later steps are strictly dated."""
|
|
411
|
+
owner = "AgeRulesFile.nmpa"
|
|
412
|
+
if not steps:
|
|
413
|
+
_fail(owner, "at least one step is required")
|
|
414
|
+
if steps[0].effective_from is not None:
|
|
415
|
+
_fail(owner, "the first step is the baseline and must omit effective_from")
|
|
416
|
+
previous: date | None = None
|
|
417
|
+
for step in steps[1:]:
|
|
418
|
+
if step.effective_from is None:
|
|
419
|
+
_fail(owner, "only the first step may omit effective_from")
|
|
420
|
+
if previous is not None and step.effective_from <= previous:
|
|
421
|
+
_fail(owner, "steps must be in ascending effective_from order")
|
|
422
|
+
previous = step.effective_from
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _validate_spa_bands(bands: tuple[SpaBand, ...]) -> None:
|
|
426
|
+
"""SPA bands must tile the covered DOB range with no gaps or overlaps.
|
|
427
|
+
|
|
428
|
+
The first band may set ``dob_from``: coverage starts there, and age
|
|
429
|
+
logic must reject earlier dates of birth rather than guess. The last
|
|
430
|
+
band is always open-ended (future births).
|
|
431
|
+
"""
|
|
432
|
+
owner = "AgeRulesFile.state_pension_age"
|
|
433
|
+
if not bands:
|
|
434
|
+
_fail(owner, "at least one band is required")
|
|
435
|
+
if bands[-1].dob_to is not None:
|
|
436
|
+
_fail(owner, "the last band must omit dob_to (open-ended)")
|
|
437
|
+
for previous, current in pairwise(bands):
|
|
438
|
+
if previous.dob_to is None:
|
|
439
|
+
_fail(owner, "only the last band may omit dob_to")
|
|
440
|
+
if current.dob_from is None:
|
|
441
|
+
_fail(owner, "only the first band may omit dob_from")
|
|
442
|
+
if current.dob_from != previous.dob_to + timedelta(days=1):
|
|
443
|
+
_fail(
|
|
444
|
+
owner,
|
|
445
|
+
f"bands must be contiguous: {current.dob_from} does not follow"
|
|
446
|
+
f" {previous.dob_to}",
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
@dataclass(frozen=True, slots=True)
|
|
451
|
+
class AgeRulesFile:
|
|
452
|
+
"""The fully validated ``age_rules.toml`` data file."""
|
|
453
|
+
|
|
454
|
+
schema_version: int
|
|
455
|
+
meta: FileMeta
|
|
456
|
+
nmpa: tuple[NmpaStep, ...]
|
|
457
|
+
spa_bands: tuple[SpaBand, ...]
|
|
458
|
+
lisa: LisaAges
|
|
459
|
+
deferral: StatePensionDeferral
|
|
460
|
+
|
|
461
|
+
def __post_init__(self) -> None:
|
|
462
|
+
"""Validate the version, the NMPA schedule, and SPA band tiling."""
|
|
463
|
+
_require_schema_version(self.schema_version, "AgeRulesFile")
|
|
464
|
+
_validate_nmpa_steps(self.nmpa)
|
|
465
|
+
_validate_spa_bands(self.spa_bands)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
@dataclass(frozen=True, slots=True)
|
|
469
|
+
class ReturnsHistoryFile:
|
|
470
|
+
"""The fully validated ``returns_history.toml`` data file (9.18).
|
|
471
|
+
|
|
472
|
+
``series`` is the core historical-return series the backtest
|
|
473
|
+
engine replays (:mod:`glidepath.core.backtest`) — the series'
|
|
474
|
+
own invariants (contiguous ascending years, rates above -100%)
|
|
475
|
+
are enforced by its type; this wrapper adds the §5.3 provenance
|
|
476
|
+
side (``verified_on`` + sources).
|
|
477
|
+
"""
|
|
478
|
+
|
|
479
|
+
schema_version: int
|
|
480
|
+
meta: FileMeta
|
|
481
|
+
series: HistoricalSeries
|
|
482
|
+
|
|
483
|
+
def __post_init__(self) -> None:
|
|
484
|
+
"""Validate the version."""
|
|
485
|
+
_require_schema_version(self.schema_version, "ReturnsHistoryFile")
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
type AssumptionValue = Decimal | int | str | Mapping[str, AssumptionValue]
|
|
489
|
+
"""A shipped default: a number, an enum-like tag, or a structured table."""
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
class FrozenTable(Mapping[str, "AssumptionValue"]):
|
|
493
|
+
"""A read-only assumption table value that survives pickling.
|
|
494
|
+
|
|
495
|
+
Shipped structured defaults need an immutable mapping — consumers
|
|
496
|
+
type against ``Mapping`` and must never mutate a shared default —
|
|
497
|
+
but ``types.MappingProxyType`` cannot pickle, and a Monte Carlo
|
|
498
|
+
run ships the assumption set to worker processes (planning §5.2).
|
|
499
|
+
Equality (any mapping with the same items, via the ``Mapping``
|
|
500
|
+
mixin) and unhashability match the proxy it replaces.
|
|
501
|
+
"""
|
|
502
|
+
|
|
503
|
+
__slots__ = ("_entries",)
|
|
504
|
+
|
|
505
|
+
def __init__(self, entries: Mapping[str, AssumptionValue]) -> None:
|
|
506
|
+
"""Copy ``entries`` so no caller retains a mutating handle."""
|
|
507
|
+
self._entries: dict[str, AssumptionValue] = dict(entries)
|
|
508
|
+
|
|
509
|
+
def __getitem__(self, key: str) -> AssumptionValue:
|
|
510
|
+
"""The value stored under ``key``."""
|
|
511
|
+
return self._entries[key]
|
|
512
|
+
|
|
513
|
+
def __iter__(self) -> Iterator[str]:
|
|
514
|
+
"""Iterate the keys in insertion order."""
|
|
515
|
+
return iter(self._entries)
|
|
516
|
+
|
|
517
|
+
def __len__(self) -> int:
|
|
518
|
+
"""How many entries the table holds."""
|
|
519
|
+
return len(self._entries)
|
|
520
|
+
|
|
521
|
+
def __repr__(self) -> str:
|
|
522
|
+
"""Show the underlying entries."""
|
|
523
|
+
return f"{type(self).__name__}({self._entries!r})"
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
@dataclass(frozen=True, slots=True)
|
|
527
|
+
class AssumptionDefault:
|
|
528
|
+
"""One shipped default assumption with the basis it rests on (§7)."""
|
|
529
|
+
|
|
530
|
+
key: AssumptionKey
|
|
531
|
+
value: AssumptionValue
|
|
532
|
+
basis: str
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _validate_assumption_keys(defaults: tuple[AssumptionDefault, ...]) -> None:
|
|
536
|
+
"""The file must define every :class:`AssumptionKey` exactly once."""
|
|
537
|
+
owner = "AssumptionsFile"
|
|
538
|
+
seen = [entry.key for entry in defaults]
|
|
539
|
+
duplicates = sorted({key.value for key in seen if seen.count(key) > 1})
|
|
540
|
+
if duplicates:
|
|
541
|
+
_fail(owner, f"duplicate assumption keys: {', '.join(duplicates)}")
|
|
542
|
+
missing = sorted(key.value for key in AssumptionKey if key not in seen)
|
|
543
|
+
if missing:
|
|
544
|
+
_fail(owner, f"missing assumption keys: {', '.join(missing)}")
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
@dataclass(frozen=True, slots=True)
|
|
548
|
+
class AssumptionsFile:
|
|
549
|
+
"""The fully validated ``assumptions_default.toml`` data file."""
|
|
550
|
+
|
|
551
|
+
schema_version: int
|
|
552
|
+
meta: FileMeta
|
|
553
|
+
defaults: tuple[AssumptionDefault, ...]
|
|
554
|
+
|
|
555
|
+
def __post_init__(self) -> None:
|
|
556
|
+
"""Validate the version and assumption-key completeness."""
|
|
557
|
+
_require_schema_version(self.schema_version, "AssumptionsFile")
|
|
558
|
+
_validate_assumption_keys(self.defaults)
|
|
559
|
+
|
|
560
|
+
def get(self, key: AssumptionKey) -> AssumptionDefault:
|
|
561
|
+
"""Return the shipped default for ``key`` (always present)."""
|
|
562
|
+
by_key = {entry.key: entry for entry in self.defaults}
|
|
563
|
+
return by_key[key]
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""UK state pension scheme (roadmap 4.3; planning §5.1, §6).
|
|
2
|
+
|
|
3
|
+
Implements the core :class:`~glidepath.core.StatePensionScheme`
|
|
4
|
+
protocol. The official DWP forecast is the fact and the only route to
|
|
5
|
+
an amount (planning §5.1): it is authoritative, free, and instant to
|
|
6
|
+
obtain from gov.uk/check-state-pension, so this scheme never computes
|
|
7
|
+
what DWP has already computed — a record without a forecast is refused
|
|
8
|
+
with a demand for one, never guessed. The age figures — the SPA
|
|
9
|
+
timetable and the deferral increment — come from the shipped
|
|
10
|
+
``age_rules.toml`` (§5.3); nothing is hardcoded here (guard-tested).
|
|
11
|
+
|
|
12
|
+
The §5.1 rules, in order:
|
|
13
|
+
|
|
14
|
+
- The official forecast **is the fact**; any protected payment is the
|
|
15
|
+
CPI-only slice of it (validated by the core record).
|
|
16
|
+
- Deferral shifts the start date past SPA in whole months and earns
|
|
17
|
+
the increment: one ninth of 1% for each whole week deferred, payable
|
|
18
|
+
only once at least nine weeks are deferred (~5.8% per 52 weeks;
|
|
19
|
+
parameters shipped as data). The uplift is returned as a *fraction*
|
|
20
|
+
because it applies to the rate payable at claim — upratings earned
|
|
21
|
+
during deferment included — which only the engine knows; the engine
|
|
22
|
+
CPI-uprates the resulting increment from the claim onwards
|
|
23
|
+
(planning §5.1, §6).
|
|
24
|
+
|
|
25
|
+
Amounts are returned in the weekly rates the forecast states
|
|
26
|
+
(annualised at 52 weeks); uprating — from the forecast's own date to
|
|
27
|
+
the run start (§4.8) and onward from there — is the engine's concern,
|
|
28
|
+
governed by the ``policy.state_pension.uprating`` assumption
|
|
29
|
+
(planning §5.1, §7).
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from dataclasses import dataclass
|
|
33
|
+
from decimal import Decimal
|
|
34
|
+
from typing import TYPE_CHECKING
|
|
35
|
+
|
|
36
|
+
from glidepath.core import (
|
|
37
|
+
Money,
|
|
38
|
+
StatePensionEntitlement,
|
|
39
|
+
add_months,
|
|
40
|
+
deferral_months,
|
|
41
|
+
)
|
|
42
|
+
from glidepath.regions.uk.ages import UkAgeRules
|
|
43
|
+
|
|
44
|
+
if TYPE_CHECKING:
|
|
45
|
+
from datetime import date
|
|
46
|
+
|
|
47
|
+
from glidepath.core import StatePensionRecord
|
|
48
|
+
|
|
49
|
+
_ZERO = Money(Decimal(0))
|
|
50
|
+
_ZERO_RATE = Decimal(0)
|
|
51
|
+
_WEEKS_PER_YEAR = Decimal(52)
|
|
52
|
+
_DAYS_PER_WEEK = 7
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class UkStatePensionError(ValueError):
|
|
56
|
+
"""A state pension record the shipped UK rules cannot evaluate."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True, slots=True)
|
|
60
|
+
class UkStatePensionScheme:
|
|
61
|
+
"""UK implementation of the core ``StatePensionScheme`` protocol.
|
|
62
|
+
|
|
63
|
+
Holds the age rules (SPA timetable, deferral increment); the amount
|
|
64
|
+
itself is the record's stated DWP forecast, so no tax-year data is
|
|
65
|
+
needed (module docstring).
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
ages: UkAgeRules
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def from_shipped_data(cls) -> UkStatePensionScheme:
|
|
72
|
+
"""Build the scheme over the shipped age-rules data file."""
|
|
73
|
+
return cls(ages=UkAgeRules.from_shipped_data())
|
|
74
|
+
|
|
75
|
+
def entitlement(
|
|
76
|
+
self, record: StatePensionRecord, date_of_birth: date
|
|
77
|
+
) -> StatePensionEntitlement:
|
|
78
|
+
"""The record's entitlement in the rates its forecast states.
|
|
79
|
+
|
|
80
|
+
Raises:
|
|
81
|
+
UkStatePensionError: If the record has no official forecast
|
|
82
|
+
(the only route to an amount, planning §5.1).
|
|
83
|
+
UkAgeError: If the date of birth predates SPA timetable
|
|
84
|
+
coverage.
|
|
85
|
+
"""
|
|
86
|
+
spa_date = self.ages.state_pension_date(date_of_birth)
|
|
87
|
+
start_date = add_months(spa_date, deferral_months(record))
|
|
88
|
+
main_weekly, protected_weekly = self._weekly_amounts(record)
|
|
89
|
+
return StatePensionEntitlement(
|
|
90
|
+
start_date=start_date,
|
|
91
|
+
annual_amount=main_weekly * _WEEKS_PER_YEAR,
|
|
92
|
+
cpi_uprated_annual_amount=protected_weekly * _WEEKS_PER_YEAR,
|
|
93
|
+
deferral_uplift=self._deferral_uplift(spa_date, start_date),
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
@staticmethod
|
|
97
|
+
def _weekly_amounts(record: StatePensionRecord) -> tuple[Money, Money]:
|
|
98
|
+
"""The (policy-uprated, CPI-only) weekly amounts before deferral.
|
|
99
|
+
|
|
100
|
+
Raises:
|
|
101
|
+
UkStatePensionError: If the record has no official forecast.
|
|
102
|
+
"""
|
|
103
|
+
if record.forecast_weekly_amount is None:
|
|
104
|
+
msg = (
|
|
105
|
+
"a state pension record needs an official DWP forecast"
|
|
106
|
+
" — free and instant from gov.uk/check-state-pension"
|
|
107
|
+
" (planning §5.1)"
|
|
108
|
+
)
|
|
109
|
+
raise UkStatePensionError(msg)
|
|
110
|
+
protected = _ZERO
|
|
111
|
+
if record.protected_payment is not None:
|
|
112
|
+
protected = record.protected_payment.value
|
|
113
|
+
return record.forecast_weekly_amount.value - protected, protected
|
|
114
|
+
|
|
115
|
+
def _deferral_uplift(self, spa_date: date, start_date: date) -> Decimal:
|
|
116
|
+
"""The deferral increment fraction earned between SPA and start.
|
|
117
|
+
|
|
118
|
+
One ninth of 1% for each whole week deferred — the shipped
|
|
119
|
+
``increment_rate / per_weeks`` per week — payable only once at
|
|
120
|
+
least ``per_weeks`` whole weeks are deferred (the statutory
|
|
121
|
+
minimum; ~5.8% per 52 weeks, planning §6).
|
|
122
|
+
"""
|
|
123
|
+
deferral = self.ages.rules.deferral
|
|
124
|
+
weeks = (start_date - spa_date).days // _DAYS_PER_WEEK
|
|
125
|
+
if weeks < deferral.per_weeks:
|
|
126
|
+
return _ZERO_RATE
|
|
127
|
+
return deferral.increment_rate.value * (
|
|
128
|
+
Decimal(weeks) / Decimal(deferral.per_weeks)
|
|
129
|
+
)
|