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,704 @@
1
+ """Strict TOML loader for the UK region data files (planning §5.3).
2
+
3
+ ``importlib.resources`` + stdlib ``tomllib`` only. Loading is strict:
4
+
5
+ - money and rates are TOML **strings** parsed to ``Decimal`` — a bare
6
+ float (or int) in a money position is a load error;
7
+ - every file declares ``schema_version`` and carries a mandatory
8
+ ``[meta]`` table with ``verified_on`` + ``sources``;
9
+ - unknown keys anywhere are load errors.
10
+
11
+ Failures raise :class:`~glidepath.regions.uk.schema.DataFileError` with a
12
+ ``file.section.key`` context string.
13
+ """
14
+
15
+ import hashlib
16
+ import re
17
+ import tomllib
18
+ from datetime import date, datetime
19
+ from decimal import Decimal, InvalidOperation
20
+ from importlib import resources
21
+ from typing import TYPE_CHECKING, NoReturn
22
+
23
+ from glidepath.core import AssumptionKey, HistoricalSeries, HistoricalYear, Money, Rate
24
+ from glidepath.regions.uk.schema import (
25
+ SCHEMA_VERSION,
26
+ AgeRulesFile,
27
+ AssumptionDefault,
28
+ AssumptionsFile,
29
+ DataFileError,
30
+ DividendRate,
31
+ DividendRules,
32
+ FileMeta,
33
+ FrozenTable,
34
+ IncomeTaxSchedule,
35
+ IsaRules,
36
+ LisaAges,
37
+ NmpaStep,
38
+ PensionRules,
39
+ ReturnsHistoryFile,
40
+ SavingsRules,
41
+ SpaAgeBand,
42
+ SpaBand,
43
+ SpaDateBand,
44
+ StatePensionDeferral,
45
+ TaxBand,
46
+ TaxYearFile,
47
+ TaxYearMeta,
48
+ tax_year_label,
49
+ )
50
+
51
+ if TYPE_CHECKING:
52
+ from importlib.resources.abc import Traversable
53
+
54
+ from glidepath.regions.uk.schema import AssumptionValue
55
+
56
+ AGE_RULES_FILENAME = "age_rules.toml"
57
+ ASSUMPTIONS_FILENAME = "assumptions_default.toml"
58
+ RETURNS_HISTORY_FILENAME = "returns_history.toml"
59
+
60
+ _DATA_ANCHOR = "glidepath.regions.uk"
61
+ _TAX_YEAR_FILE = re.compile(r"tax_year_(\d{4})_(\d{2})\.toml")
62
+ _MUST_NOT_BE_EMPTY = "must not be empty"
63
+
64
+
65
+ def _fail(context: str, problem: str) -> NoReturn:
66
+ """Raise a :class:`DataFileError` locating ``problem`` at ``context``."""
67
+ msg = f"{context}: {problem}"
68
+ raise DataFileError(msg)
69
+
70
+
71
+ class _Table:
72
+ """Strict view over one TOML table: every key must be consumed."""
73
+
74
+ __slots__ = ("_context", "_entries")
75
+
76
+ def __init__(self, raw: object, context: str) -> None:
77
+ """Wrap ``raw`` if it is a TOML table, else fail."""
78
+ if not isinstance(raw, dict):
79
+ _fail(context, f"expected a TOML table, got {type(raw).__name__}")
80
+ self._entries: dict[str, object] = dict(raw)
81
+ self._context = context
82
+
83
+ def take(self, key: str) -> object:
84
+ """Remove and return the required ``key``."""
85
+ if key not in self._entries:
86
+ _fail(self._context, f"missing required key {key!r}")
87
+ return self._entries.pop(key)
88
+
89
+ def take_optional(self, key: str) -> object | None:
90
+ """Remove and return ``key``, or ``None`` if absent."""
91
+ return self._entries.pop(key, None)
92
+
93
+ def finish(self) -> None:
94
+ """Fail if any key was never consumed (unknown keys)."""
95
+ if self._entries:
96
+ unknown = ", ".join(sorted(self._entries))
97
+ _fail(self._context, f"unknown keys: {unknown}")
98
+
99
+
100
+ def _decimal_string(raw: object, context: str) -> Decimal:
101
+ """Parse a money/rate figure, which must be a TOML string (§5.3)."""
102
+ if isinstance(raw, float):
103
+ _fail(context, 'float-typed number; write money and rates as strings ("0.25")')
104
+ if not isinstance(raw, str):
105
+ type_name = type(raw).__name__
106
+ _fail(context, f"money and rates must be TOML strings, got {type_name}")
107
+ try:
108
+ value = Decimal(raw)
109
+ except InvalidOperation:
110
+ _fail(context, f"not a valid decimal number: {raw!r}")
111
+ if not value.is_finite():
112
+ _fail(context, "number must be finite")
113
+ return value
114
+
115
+
116
+ def _money(raw: object, context: str) -> Money:
117
+ """Parse a non-negative monetary amount."""
118
+ value = _decimal_string(raw, context)
119
+ if value < 0:
120
+ _fail(context, "money amounts must be non-negative")
121
+ return Money(value)
122
+
123
+
124
+ def _fraction(raw: object, context: str) -> Rate:
125
+ """Parse a rate that must lie in [0, 1]."""
126
+ value = _decimal_string(raw, context)
127
+ if not Decimal(0) <= value <= Decimal(1):
128
+ _fail(context, "rates must be fractions between 0 and 1")
129
+ return Rate(value)
130
+
131
+
132
+ def _integer(raw: object, context: str, *, minimum: int) -> int:
133
+ """Parse an integer of at least ``minimum`` (bools rejected)."""
134
+ if isinstance(raw, bool) or not isinstance(raw, int):
135
+ _fail(context, f"expected an integer, got {type(raw).__name__}")
136
+ if raw < minimum:
137
+ _fail(context, f"must be at least {minimum}")
138
+ return raw
139
+
140
+
141
+ def _plain_date(raw: object, context: str) -> date:
142
+ """Parse a TOML local date (a datetime with a time part is an error)."""
143
+ if isinstance(raw, datetime):
144
+ _fail(context, "expected a calendar date without a time part")
145
+ if not isinstance(raw, date):
146
+ _fail(context, f"expected a TOML date, got {type(raw).__name__}")
147
+ return raw
148
+
149
+
150
+ def _optional_date(raw: object | None, context: str) -> date | None:
151
+ """Parse an optional TOML local date."""
152
+ return None if raw is None else _plain_date(raw, context)
153
+
154
+
155
+ def _string(raw: object, context: str) -> str:
156
+ """Parse a non-empty string."""
157
+ if not isinstance(raw, str):
158
+ _fail(context, f"expected a string, got {type(raw).__name__}")
159
+ if not raw:
160
+ _fail(context, _MUST_NOT_BE_EMPTY)
161
+ return raw
162
+
163
+
164
+ def _array(raw: object, context: str) -> list[object]:
165
+ """Parse a non-empty TOML array."""
166
+ if not isinstance(raw, list):
167
+ _fail(context, f"expected an array, got {type(raw).__name__}")
168
+ if not raw:
169
+ _fail(context, _MUST_NOT_BE_EMPTY)
170
+ return raw
171
+
172
+
173
+ def _sources(raw: object, context: str) -> tuple[str, ...]:
174
+ """Parse the mandatory list of https source URLs."""
175
+ entries = _array(raw, context)
176
+ result: list[str] = []
177
+ for index, entry in enumerate(entries):
178
+ url = _string(entry, f"{context}[{index}]")
179
+ if not url.startswith("https://"):
180
+ _fail(f"{context}[{index}]", "sources must be https:// URLs")
181
+ result.append(url)
182
+ return tuple(result)
183
+
184
+
185
+ def _load_document(text: str, context: str) -> _Table:
186
+ """Parse ``text`` as TOML into a strict root table."""
187
+ try:
188
+ document = tomllib.loads(text)
189
+ except tomllib.TOMLDecodeError as error:
190
+ _fail(context, f"invalid TOML: {error}")
191
+ return _Table(document, context)
192
+
193
+
194
+ def _take_schema_version(root: _Table, context: str) -> int:
195
+ """Consume ``schema_version``; unsupported versions fail before parsing."""
196
+ version = _integer(
197
+ root.take("schema_version"), f"{context}.schema_version", minimum=1
198
+ )
199
+ if version != SCHEMA_VERSION:
200
+ _fail(
201
+ f"{context}.schema_version",
202
+ f"schema_version {version} is not supported ({SCHEMA_VERSION})",
203
+ )
204
+ return version
205
+
206
+
207
+ def _parse_file_meta(raw: object, context: str) -> FileMeta:
208
+ """Parse the ``[meta]`` table of a non-tax-year file."""
209
+ table = _Table(raw, context)
210
+ verified_on = _plain_date(table.take("verified_on"), f"{context}.verified_on")
211
+ sources = _sources(table.take("sources"), f"{context}.sources")
212
+ table.finish()
213
+ return FileMeta(verified_on=verified_on, sources=sources)
214
+
215
+
216
+ def _parse_tax_year_meta(raw: object, context: str) -> TaxYearMeta:
217
+ """Parse the ``[meta]`` table of a tax-year file."""
218
+ table = _Table(raw, context)
219
+ meta = TaxYearMeta(
220
+ tax_year=_string(table.take("tax_year"), f"{context}.tax_year"),
221
+ start_date=_plain_date(table.take("start_date"), f"{context}.start_date"),
222
+ end_date=_plain_date(table.take("end_date"), f"{context}.end_date"),
223
+ verified_on=_plain_date(table.take("verified_on"), f"{context}.verified_on"),
224
+ sources=_sources(table.take("sources"), f"{context}.sources"),
225
+ )
226
+ table.finish()
227
+ return meta
228
+
229
+
230
+ def _parse_band(raw: object, context: str) -> TaxBand:
231
+ """Parse one income-tax band."""
232
+ table = _Table(raw, context)
233
+ name = _string(table.take("name"), f"{context}.name")
234
+ rate = _fraction(table.take("rate"), f"{context}.rate")
235
+ upper_raw = table.take_optional("upper")
236
+ upper = None if upper_raw is None else _money(upper_raw, f"{context}.upper")
237
+ table.finish()
238
+ return TaxBand(name=name, rate=rate, upper=upper)
239
+
240
+
241
+ def _parse_income_tax_schedule(raw: object, context: str) -> IncomeTaxSchedule:
242
+ """Parse one regime's allowance, taper, and band table."""
243
+ table = _Table(raw, context)
244
+ personal_allowance = _money(
245
+ table.take("personal_allowance"), f"{context}.personal_allowance"
246
+ )
247
+ pa_taper_threshold = _money(
248
+ table.take("pa_taper_threshold"), f"{context}.pa_taper_threshold"
249
+ )
250
+ pa_taper_rate = _fraction(table.take("pa_taper_rate"), f"{context}.pa_taper_rate")
251
+ bands_raw = _array(table.take("bands"), f"{context}.bands")
252
+ bands = tuple(
253
+ _parse_band(item, f"{context}.bands[{index}]")
254
+ for index, item in enumerate(bands_raw)
255
+ )
256
+ table.finish()
257
+ return IncomeTaxSchedule(
258
+ personal_allowance=personal_allowance,
259
+ pa_taper_threshold=pa_taper_threshold,
260
+ pa_taper_rate=pa_taper_rate,
261
+ bands=bands,
262
+ )
263
+
264
+
265
+ def _parse_pension(raw: object, context: str) -> PensionRules:
266
+ """Parse the ``[pension]`` table."""
267
+ table = _Table(raw, context)
268
+ rules = PensionRules(
269
+ annual_allowance=_money(
270
+ table.take("annual_allowance"), f"{context}.annual_allowance"
271
+ ),
272
+ aa_taper_threshold_income=_money(
273
+ table.take("aa_taper_threshold_income"),
274
+ f"{context}.aa_taper_threshold_income",
275
+ ),
276
+ aa_taper_adjusted_income=_money(
277
+ table.take("aa_taper_adjusted_income"),
278
+ f"{context}.aa_taper_adjusted_income",
279
+ ),
280
+ aa_taper_rate=_fraction(
281
+ table.take("aa_taper_rate"), f"{context}.aa_taper_rate"
282
+ ),
283
+ aa_taper_floor=_money(
284
+ table.take("aa_taper_floor"), f"{context}.aa_taper_floor"
285
+ ),
286
+ mpaa=_money(table.take("mpaa"), f"{context}.mpaa"),
287
+ aa_carry_forward_years=_integer(
288
+ table.take("aa_carry_forward_years"),
289
+ f"{context}.aa_carry_forward_years",
290
+ minimum=0,
291
+ ),
292
+ scheme_pays_min_charge=_money(
293
+ table.take("scheme_pays_min_charge"),
294
+ f"{context}.scheme_pays_min_charge",
295
+ ),
296
+ member_relief_basic_amount=_money(
297
+ table.take("member_relief_basic_amount"),
298
+ f"{context}.member_relief_basic_amount",
299
+ ),
300
+ member_relief_max_age=_integer(
301
+ table.take("member_relief_max_age"),
302
+ f"{context}.member_relief_max_age",
303
+ minimum=1,
304
+ ),
305
+ relief_at_source_rate=_fraction(
306
+ table.take("relief_at_source_rate"), f"{context}.relief_at_source_rate"
307
+ ),
308
+ tax_free_lump_sum_fraction=_fraction(
309
+ table.take("tax_free_lump_sum_fraction"),
310
+ f"{context}.tax_free_lump_sum_fraction",
311
+ ),
312
+ lump_sum_allowance=_money(
313
+ table.take("lump_sum_allowance"), f"{context}.lump_sum_allowance"
314
+ ),
315
+ lump_sum_death_benefit_allowance=_money(
316
+ table.take("lump_sum_death_benefit_allowance"),
317
+ f"{context}.lump_sum_death_benefit_allowance",
318
+ ),
319
+ db_valuation_factor=_integer(
320
+ table.take("db_valuation_factor"),
321
+ f"{context}.db_valuation_factor",
322
+ minimum=1,
323
+ ),
324
+ )
325
+ table.finish()
326
+ return rules
327
+
328
+
329
+ def _parse_isa(raw: object, context: str) -> IsaRules:
330
+ """Parse the ``[isa]`` table."""
331
+ table = _Table(raw, context)
332
+ rules = IsaRules(
333
+ annual_allowance=_money(
334
+ table.take("annual_allowance"), f"{context}.annual_allowance"
335
+ ),
336
+ lisa_allowance=_money(
337
+ table.take("lisa_allowance"), f"{context}.lisa_allowance"
338
+ ),
339
+ lisa_bonus_rate=_fraction(
340
+ table.take("lisa_bonus_rate"), f"{context}.lisa_bonus_rate"
341
+ ),
342
+ lisa_withdrawal_charge=_fraction(
343
+ table.take("lisa_withdrawal_charge"), f"{context}.lisa_withdrawal_charge"
344
+ ),
345
+ )
346
+ table.finish()
347
+ return rules
348
+
349
+
350
+ def _parse_savings(raw: object, context: str) -> SavingsRules:
351
+ """Parse the ``[savings]`` table."""
352
+ table = _Table(raw, context)
353
+ rules = SavingsRules(
354
+ starting_rate_limit=_money(
355
+ table.take("starting_rate_limit"), f"{context}.starting_rate_limit"
356
+ ),
357
+ psa_basic=_money(table.take("psa_basic"), f"{context}.psa_basic"),
358
+ psa_higher=_money(table.take("psa_higher"), f"{context}.psa_higher"),
359
+ psa_additional=_money(
360
+ table.take("psa_additional"), f"{context}.psa_additional"
361
+ ),
362
+ )
363
+ table.finish()
364
+ return rules
365
+
366
+
367
+ def _parse_dividend_rate(raw: object, context: str) -> DividendRate:
368
+ """Parse one entry of the ``dividend.rates`` array."""
369
+ table = _Table(raw, context)
370
+ name = _string(table.take("name"), f"{context}.name")
371
+ rate = _fraction(table.take("rate"), f"{context}.rate")
372
+ table.finish()
373
+ return DividendRate(name=name, rate=rate)
374
+
375
+
376
+ def _parse_dividend(raw: object, context: str) -> DividendRules:
377
+ """Parse the ``[dividend]`` table."""
378
+ table = _Table(raw, context)
379
+ allowance = _money(table.take("allowance"), f"{context}.allowance")
380
+ rates_raw = _array(table.take("rates"), f"{context}.rates")
381
+ rates = tuple(
382
+ _parse_dividend_rate(item, f"{context}.rates[{index}]")
383
+ for index, item in enumerate(rates_raw)
384
+ )
385
+ table.finish()
386
+ return DividendRules(allowance=allowance, rates=rates)
387
+
388
+
389
+ def parse_tax_year(text: str, *, context: str = "<tax-year data>") -> TaxYearFile:
390
+ """Parse and strictly validate one tax-year TOML document."""
391
+ root = _load_document(text, context)
392
+ schema_version = _take_schema_version(root, context)
393
+ meta = _parse_tax_year_meta(root.take("meta"), f"{context}.meta")
394
+ income_tax = _Table(root.take("income_tax"), f"{context}.income_tax")
395
+ ruk = _parse_income_tax_schedule(
396
+ income_tax.take("ruk"), f"{context}.income_tax.ruk"
397
+ )
398
+ scotland = _parse_income_tax_schedule(
399
+ income_tax.take("scotland"), f"{context}.income_tax.scotland"
400
+ )
401
+ income_tax.finish()
402
+ pension = _parse_pension(root.take("pension"), f"{context}.pension")
403
+ isa = _parse_isa(root.take("isa"), f"{context}.isa")
404
+ savings = _parse_savings(root.take("savings"), f"{context}.savings")
405
+ dividend = _parse_dividend(root.take("dividend"), f"{context}.dividend")
406
+ root.finish()
407
+ return TaxYearFile(
408
+ schema_version=schema_version,
409
+ meta=meta,
410
+ income_tax_ruk=ruk,
411
+ income_tax_scotland=scotland,
412
+ pension=pension,
413
+ isa=isa,
414
+ savings=savings,
415
+ dividend=dividend,
416
+ )
417
+
418
+
419
+ def _parse_nmpa_step(raw: object, context: str) -> NmpaStep:
420
+ """Parse one effective-dated NMPA step."""
421
+ table = _Table(raw, context)
422
+ age = _integer(table.take("age"), f"{context}.age", minimum=1)
423
+ effective_from = _optional_date(
424
+ table.take_optional("effective_from"), f"{context}.effective_from"
425
+ )
426
+ table.finish()
427
+ return NmpaStep(age=age, effective_from=effective_from)
428
+
429
+
430
+ def _parse_spa_band(raw: object, context: str) -> SpaBand:
431
+ """Parse one SPA timetable band (age-based xor date-based)."""
432
+ table = _Table(raw, context)
433
+ dob_from = _optional_date(table.take_optional("dob_from"), f"{context}.dob_from")
434
+ dob_to = _optional_date(table.take_optional("dob_to"), f"{context}.dob_to")
435
+ age_raw = table.take_optional("age")
436
+ reaches_raw = table.take_optional("reaches_on")
437
+ table.finish()
438
+ if (age_raw is None) == (reaches_raw is None):
439
+ _fail(context, "exactly one of 'age' and 'reaches_on' is required")
440
+ if age_raw is None:
441
+ reaches_on = _plain_date(reaches_raw, f"{context}.reaches_on")
442
+ return SpaDateBand(dob_from=dob_from, dob_to=dob_to, reaches_on=reaches_on)
443
+ age_table = _Table(age_raw, f"{context}.age")
444
+ years = _integer(age_table.take("years"), f"{context}.age.years", minimum=1)
445
+ months_raw = age_table.take_optional("months")
446
+ months = 0
447
+ if months_raw is not None:
448
+ months = _integer(months_raw, f"{context}.age.months", minimum=0)
449
+ age_table.finish()
450
+ return SpaAgeBand(dob_from=dob_from, dob_to=dob_to, years=years, months=months)
451
+
452
+
453
+ def _parse_lisa(raw: object, context: str) -> LisaAges:
454
+ """Parse the ``[lisa]`` age gates."""
455
+ table = _Table(raw, context)
456
+
457
+ def gate(key: str) -> int:
458
+ """Take one required age gate."""
459
+ return _integer(table.take(key), f"{context}.{key}", minimum=1)
460
+
461
+ ages = LisaAges(
462
+ open_age_min=gate("open_age_min"),
463
+ open_age_max=gate("open_age_max"),
464
+ contribute_until_age=gate("contribute_until_age"),
465
+ access_age=gate("access_age"),
466
+ )
467
+ table.finish()
468
+ return ages
469
+
470
+
471
+ def _parse_deferral(raw: object, context: str) -> StatePensionDeferral:
472
+ """Parse the ``[state_pension_deferral]`` table."""
473
+ table = _Table(raw, context)
474
+ deferral = StatePensionDeferral(
475
+ increment_rate=_fraction(
476
+ table.take("increment_rate"), f"{context}.increment_rate"
477
+ ),
478
+ per_weeks=_integer(table.take("per_weeks"), f"{context}.per_weeks", minimum=1),
479
+ )
480
+ table.finish()
481
+ return deferral
482
+
483
+
484
+ def parse_age_rules(text: str, *, context: str = "<age-rules data>") -> AgeRulesFile:
485
+ """Parse and strictly validate an age-rules TOML document."""
486
+ root = _load_document(text, context)
487
+ schema_version = _take_schema_version(root, context)
488
+ meta = _parse_file_meta(root.take("meta"), f"{context}.meta")
489
+ nmpa_table = _Table(root.take("nmpa"), f"{context}.nmpa")
490
+ steps_raw = _array(nmpa_table.take("steps"), f"{context}.nmpa.steps")
491
+ nmpa = tuple(
492
+ _parse_nmpa_step(item, f"{context}.nmpa.steps[{index}]")
493
+ for index, item in enumerate(steps_raw)
494
+ )
495
+ nmpa_table.finish()
496
+ spa_table = _Table(root.take("state_pension_age"), f"{context}.state_pension_age")
497
+ bands_raw = _array(spa_table.take("bands"), f"{context}.state_pension_age.bands")
498
+ spa_bands = tuple(
499
+ _parse_spa_band(item, f"{context}.state_pension_age.bands[{index}]")
500
+ for index, item in enumerate(bands_raw)
501
+ )
502
+ spa_table.finish()
503
+ lisa = _parse_lisa(root.take("lisa"), f"{context}.lisa")
504
+ deferral = _parse_deferral(
505
+ root.take("state_pension_deferral"), f"{context}.state_pension_deferral"
506
+ )
507
+ root.finish()
508
+ return AgeRulesFile(
509
+ schema_version=schema_version,
510
+ meta=meta,
511
+ nmpa=nmpa,
512
+ spa_bands=spa_bands,
513
+ lisa=lisa,
514
+ deferral=deferral,
515
+ )
516
+
517
+
518
+ def _signed_rate(raw: object, context: str) -> Decimal:
519
+ """Parse an annual rate that may be negative but must exceed -100%.
520
+
521
+ Historical returns and inflation are frequently negative and can
522
+ exceed +100%, so neither ``_fraction`` nor ``_money`` fits; -100%
523
+ or worse can never be recomposed into a real rate
524
+ (:mod:`glidepath.core.backtest`).
525
+ """
526
+ value = _decimal_string(raw, context)
527
+ if value <= Decimal(-1):
528
+ _fail(context, "rates must be greater than -1 (-100%)")
529
+ return value
530
+
531
+
532
+ def _parse_history_year(raw: object, context: str) -> HistoricalYear:
533
+ """Parse one observed year of the return series."""
534
+ table = _Table(raw, context)
535
+ year = _integer(table.take("year"), f"{context}.year", minimum=1)
536
+ equity = _signed_rate(table.take("equity"), f"{context}.equity")
537
+ bonds = _signed_rate(table.take("bonds"), f"{context}.bonds")
538
+ cash = _signed_rate(table.take("cash"), f"{context}.cash")
539
+ cpi = _signed_rate(table.take("cpi"), f"{context}.cpi")
540
+ table.finish()
541
+ return HistoricalYear(year=year, equity=equity, bonds=bonds, cash=cash, cpi=cpi)
542
+
543
+
544
+ def parse_returns_history(
545
+ text: str, *, context: str = "<returns-history data>"
546
+ ) -> ReturnsHistoryFile:
547
+ """Parse and strictly validate a returns-history TOML document."""
548
+ root = _load_document(text, context)
549
+ schema_version = _take_schema_version(root, context)
550
+ meta = _parse_file_meta(root.take("meta"), f"{context}.meta")
551
+ returns_table = _Table(root.take("returns"), f"{context}.returns")
552
+ series_context = f"{context}.returns.series"
553
+ entries_raw = _array(returns_table.take("series"), series_context)
554
+ years = tuple(
555
+ _parse_history_year(item, f"{series_context}[{index}]")
556
+ for index, item in enumerate(entries_raw)
557
+ )
558
+ returns_table.finish()
559
+ root.finish()
560
+ try:
561
+ series = HistoricalSeries(years=years)
562
+ except ValueError as error:
563
+ _fail(series_context, str(error))
564
+ return ReturnsHistoryFile(schema_version=schema_version, meta=meta, series=series)
565
+
566
+
567
+ def _assumption_value(raw: object, context: str) -> AssumptionValue:
568
+ """Parse a default's value: Decimal string, integer, tag, or table."""
569
+ if isinstance(raw, bool):
570
+ _fail(context, "booleans are not valid assumption values")
571
+ if isinstance(raw, int):
572
+ return raw
573
+ if isinstance(raw, float):
574
+ _fail(context, 'float-typed number; write numeric values as strings ("0.02")')
575
+ if isinstance(raw, str):
576
+ return _decimal_or_tag(raw, context)
577
+ if isinstance(raw, dict):
578
+ if not raw:
579
+ _fail(context, _MUST_NOT_BE_EMPTY)
580
+ items: dict[str, AssumptionValue] = {
581
+ key: _assumption_value(item, f"{context}.{key}")
582
+ for key, item in raw.items()
583
+ }
584
+ return FrozenTable(items)
585
+ _fail(context, f"unsupported value type: {type(raw).__name__}")
586
+
587
+
588
+ def _decimal_or_tag(raw: str, context: str) -> Decimal | str:
589
+ """Parse a string value: numeric strings become ``Decimal``."""
590
+ if not raw:
591
+ _fail(context, _MUST_NOT_BE_EMPTY)
592
+ try:
593
+ value = Decimal(raw)
594
+ except InvalidOperation:
595
+ return raw
596
+ if not value.is_finite():
597
+ _fail(context, "number must be finite")
598
+ return value
599
+
600
+
601
+ def _parse_assumption(raw: object, context: str) -> AssumptionDefault:
602
+ """Parse one ``[[assumption]]`` entry."""
603
+ table = _Table(raw, context)
604
+ key_text = _string(table.take("key"), f"{context}.key")
605
+ try:
606
+ key = AssumptionKey(key_text)
607
+ except ValueError:
608
+ _fail(f"{context}.key", f"unknown assumption key {key_text!r}")
609
+ value = _assumption_value(table.take("value"), f"{context}.value")
610
+ basis = _string(table.take("basis"), f"{context}.basis")
611
+ table.finish()
612
+ return AssumptionDefault(key=key, value=value, basis=basis)
613
+
614
+
615
+ def parse_default_assumptions(
616
+ text: str, *, context: str = "<assumptions data>"
617
+ ) -> AssumptionsFile:
618
+ """Parse and strictly validate a default-assumptions TOML document."""
619
+ root = _load_document(text, context)
620
+ schema_version = _take_schema_version(root, context)
621
+ meta = _parse_file_meta(root.take("meta"), f"{context}.meta")
622
+ entries = _array(root.take("assumption"), f"{context}.assumption")
623
+ defaults = tuple(
624
+ _parse_assumption(item, f"{context}.assumption[{index}]")
625
+ for index, item in enumerate(entries)
626
+ )
627
+ root.finish()
628
+ return AssumptionsFile(schema_version=schema_version, meta=meta, defaults=defaults)
629
+
630
+
631
+ def _data_directory() -> Traversable:
632
+ """The packaged ``data/`` directory of the UK region."""
633
+ return resources.files(_DATA_ANCHOR).joinpath("data")
634
+
635
+
636
+ def _read_data_file(filename: str) -> str:
637
+ """Read one shipped data file as UTF-8 text."""
638
+ target = _data_directory().joinpath(filename)
639
+ try:
640
+ return target.read_text(encoding="utf-8")
641
+ except OSError:
642
+ _fail(filename, "no such shipped UK data file")
643
+
644
+
645
+ def tax_year_filename(start_year: int) -> str:
646
+ """The canonical data filename for the tax year starting ``start_year``."""
647
+ return f"tax_year_{start_year}_{(start_year + 1) % 100:02d}.toml"
648
+
649
+
650
+ def data_file_digest(filename: str) -> str:
651
+ """A short SHA-256 digest of one shipped data file's text.
652
+
653
+ Part of the region data version (planning §4.6): ``verified_on``
654
+ dates alone cannot tell two same-day revisions of a file apart, so
655
+ the content digest makes the version string change whenever the
656
+ data does.
657
+ """
658
+ text = _read_data_file(filename)
659
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()[:12]
660
+
661
+
662
+ def available_tax_years() -> tuple[int, ...]:
663
+ """Start years of every shipped ``tax_year_*.toml``, ascending."""
664
+ years: list[int] = []
665
+ for entry in _data_directory().iterdir():
666
+ match = _TAX_YEAR_FILE.fullmatch(entry.name)
667
+ if match is None:
668
+ continue
669
+ start_year = int(match.group(1))
670
+ if int(match.group(2)) == (start_year + 1) % 100:
671
+ years.append(start_year)
672
+ return tuple(sorted(years))
673
+
674
+
675
+ def load_tax_year(start_year: int) -> TaxYearFile:
676
+ """Load the shipped file for the tax year starting 6 April ``start_year``."""
677
+ filename = tax_year_filename(start_year)
678
+ parsed = parse_tax_year(_read_data_file(filename), context=filename)
679
+ expected = tax_year_label(start_year)
680
+ if parsed.meta.tax_year != expected:
681
+ claimed = parsed.meta.tax_year
682
+ _fail(filename, f"file claims tax year {claimed!r}, not {expected!r}")
683
+ return parsed
684
+
685
+
686
+ def load_age_rules() -> AgeRulesFile:
687
+ """Load the shipped ``age_rules.toml``."""
688
+ return parse_age_rules(
689
+ _read_data_file(AGE_RULES_FILENAME), context=AGE_RULES_FILENAME
690
+ )
691
+
692
+
693
+ def load_default_assumptions() -> AssumptionsFile:
694
+ """Load the shipped ``assumptions_default.toml``."""
695
+ return parse_default_assumptions(
696
+ _read_data_file(ASSUMPTIONS_FILENAME), context=ASSUMPTIONS_FILENAME
697
+ )
698
+
699
+
700
+ def load_returns_history() -> ReturnsHistoryFile:
701
+ """Load the shipped ``returns_history.toml``."""
702
+ return parse_returns_history(
703
+ _read_data_file(RETURNS_HISTORY_FILENAME), context=RETURNS_HISTORY_FILENAME
704
+ )