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,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
+ )