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,747 @@
1
+ """Strict reader for ``.glidepath.json`` (roadmap 6.2; planning §4.5).
2
+
3
+ Everything fails loudly with a document path in the message: unknown
4
+ keys, missing keys, mistyped values, JSON floats (money is ``Decimal``,
5
+ never float — planning §4.6), and any entity invariant violated by the
6
+ stored data. Migration (roadmap 6.4) runs before decoding, so this
7
+ module only ever reads the current schema version.
8
+ """
9
+
10
+ import json
11
+ from typing import TYPE_CHECKING
12
+
13
+ from glidepath.core import (
14
+ AnnuityPurchase,
15
+ AssetAllocation,
16
+ AssumptionKey,
17
+ AssumptionTarget,
18
+ ContributionSchedule,
19
+ DBActiveMembership,
20
+ DBPension,
21
+ Decision,
22
+ DecisionTarget,
23
+ EntityId,
24
+ Fact,
25
+ FactorTable,
26
+ FeeSchedule,
27
+ GlidePathConfig,
28
+ GlidePathPoint,
29
+ Household,
30
+ Override,
31
+ Person,
32
+ PlannedOutflow,
33
+ Rate,
34
+ RevaluationBasis,
35
+ Scenario,
36
+ SpendingPlan,
37
+ StatePensionRecord,
38
+ TaxResidencyId,
39
+ Wrapper,
40
+ WrapperKindId,
41
+ )
42
+ from glidepath.persistence.document import (
43
+ SCHEMA_VERSION,
44
+ AssumptionOverride,
45
+ PersistenceError,
46
+ PlanDocument,
47
+ )
48
+ from glidepath.persistence.migrations import apply_migrations
49
+ from glidepath.persistence.values import (
50
+ ANNUITY_BASIS_TOKENS,
51
+ ANNUITY_TYPE_TOKENS,
52
+ LIFE_STAGE_TOKENS,
53
+ RELIEF_MECHANIC_TOKENS,
54
+ REVALUATION_REFERENCE_TOKENS,
55
+ SEX_TOKENS,
56
+ decode_value,
57
+ parse_date,
58
+ parse_datetime,
59
+ parse_decimal,
60
+ parse_int,
61
+ parse_money,
62
+ parse_str,
63
+ )
64
+
65
+ if TYPE_CHECKING:
66
+ from collections.abc import Callable
67
+ from decimal import Decimal
68
+ from pathlib import Path
69
+
70
+ from glidepath.core import LifeStage, Sex
71
+
72
+
73
+ def loads_plan(text: str) -> PlanDocument:
74
+ """Parse canonical ``.glidepath.json`` text into a document.
75
+
76
+ Older schema versions are upgraded through the migration harness
77
+ (roadmap 6.4) before strict decoding.
78
+
79
+ Raises:
80
+ PersistenceError: If the text is not valid JSON, needs a
81
+ migration this build lacks, or violates the schema.
82
+ """
83
+ raw = _parse_json(text)
84
+ return _document(apply_migrations(raw))
85
+
86
+
87
+ def load_plan(path: Path) -> PlanDocument:
88
+ """Read and parse the plan document stored at ``path``.
89
+
90
+ Raises:
91
+ PersistenceError: As :func:`loads_plan`, or if the file is not
92
+ UTF-8 text (a defective document, same as invalid JSON).
93
+ OSError: If the file cannot be read.
94
+ """
95
+ try:
96
+ text = path.read_text(encoding="utf-8")
97
+ except UnicodeDecodeError as exc:
98
+ msg = "plan file is not UTF-8 text"
99
+ raise PersistenceError(msg) from exc
100
+ return loads_plan(text)
101
+
102
+
103
+ def _reject_float(text: str) -> object:
104
+ """Refuse JSON floats: money and rates travel as strings (§4.6)."""
105
+ msg = f"JSON floats are not permitted in a plan document, got {text!r}"
106
+ raise PersistenceError(msg)
107
+
108
+
109
+ def _parse_json(text: str) -> dict[str, object]:
110
+ """Parse the raw JSON text into a top-level object."""
111
+ try:
112
+ raw: object = json.loads(text, parse_float=_reject_float)
113
+ except json.JSONDecodeError as error:
114
+ msg = f"not valid JSON: {error}"
115
+ raise PersistenceError(msg) from error
116
+ if not isinstance(raw, dict):
117
+ msg = f"document root must be an object, got {type(raw).__name__}"
118
+ raise PersistenceError(msg)
119
+ return raw
120
+
121
+
122
+ class _Reader:
123
+ """One JSON object being decoded strictly, with a document path."""
124
+
125
+ __slots__ = ("_entries", "path")
126
+
127
+ def __init__(self, raw: object, path: str) -> None:
128
+ """Require an object and copy its entries for consumption."""
129
+ if not isinstance(raw, dict):
130
+ msg = f"{path}: expected an object, got {type(raw).__name__}"
131
+ raise PersistenceError(msg)
132
+ self._entries: dict[str, object] = dict(raw)
133
+ self.path = path
134
+
135
+ def take(self, key: str) -> object:
136
+ """Consume a required key (its value may still be null).
137
+
138
+ Raises:
139
+ PersistenceError: If the key is absent.
140
+ """
141
+ if key not in self._entries:
142
+ msg = f"{self.path}: missing required key {key!r}"
143
+ raise PersistenceError(msg)
144
+ return self._entries.pop(key)
145
+
146
+ def at(self, key: str) -> str:
147
+ """The document path of ``key`` under this object."""
148
+ return f"{self.path}.{key}"
149
+
150
+ def finish(self) -> None:
151
+ """Reject any keys the schema does not define.
152
+
153
+ Raises:
154
+ PersistenceError: If unconsumed keys remain.
155
+ """
156
+ if self._entries:
157
+ unknown = ", ".join(sorted(self._entries))
158
+ msg = f"{self.path}: unknown keys: {unknown}"
159
+ raise PersistenceError(msg)
160
+
161
+
162
+ def _built[T](factory: Callable[[], T], path: str) -> T:
163
+ """Construct an entity, converting invariant errors to schema errors."""
164
+ try:
165
+ return factory()
166
+ except PersistenceError:
167
+ raise
168
+ except ValueError as error:
169
+ msg = f"{path}: {error}"
170
+ raise PersistenceError(msg) from error
171
+
172
+
173
+ def _sequence[T](
174
+ raw: object, path: str, decode: Callable[[object, str], T]
175
+ ) -> tuple[T, ...]:
176
+ """Decode a JSON array element by element."""
177
+ if not isinstance(raw, list):
178
+ msg = f"{path}: expected an array, got {type(raw).__name__}"
179
+ raise PersistenceError(msg)
180
+ return tuple(decode(entry, f"{path}[{index}]") for index, entry in enumerate(raw))
181
+
182
+
183
+ def _optional[T](
184
+ raw: object, path: str, decode: Callable[[object, str], T]
185
+ ) -> T | None:
186
+ """Decode a nullable value."""
187
+ return None if raw is None else decode(raw, path)
188
+
189
+
190
+ def _optional_str(raw: object, path: str) -> str | None:
191
+ """Decode a nullable string (a fact or decision note)."""
192
+ return None if raw is None else parse_str(raw, path)
193
+
194
+
195
+ def _fact[T](raw: object, path: str, value: Callable[[object, str], T]) -> Fact[T]:
196
+ """Decode a user-stated fact with its provenance fields."""
197
+ reader = _Reader(raw, path)
198
+ decoded = value(reader.take("value"), reader.at("value"))
199
+ as_of = parse_date(reader.take("as_of"), reader.at("as_of"))
200
+ recorded_on = parse_datetime(reader.take("recorded_on"), reader.at("recorded_on"))
201
+ note = _optional_str(reader.take("note"), reader.at("note"))
202
+ reader.finish()
203
+ return _built(
204
+ lambda: Fact(value=decoded, as_of=as_of, recorded_on=recorded_on, note=note),
205
+ path,
206
+ )
207
+
208
+
209
+ def _decision[T](
210
+ raw: object, path: str, value: Callable[[object, str], T]
211
+ ) -> Decision[T]:
212
+ """Decode a user choice with its provenance fields."""
213
+ reader = _Reader(raw, path)
214
+ decoded = value(reader.take("value"), reader.at("value"))
215
+ recorded_on = parse_datetime(reader.take("recorded_on"), reader.at("recorded_on"))
216
+ note = _optional_str(reader.take("note"), reader.at("note"))
217
+ reader.finish()
218
+ return _built(
219
+ lambda: Decision(value=decoded, recorded_on=recorded_on, note=note), path
220
+ )
221
+
222
+
223
+ def _sex(raw: object, path: str) -> Sex:
224
+ """Decode a longevity-default sex token."""
225
+ return SEX_TOKENS.member(raw, path)
226
+
227
+
228
+ def _rate(raw: object, path: str) -> Rate:
229
+ """Decode an annual rate stored as a decimal string."""
230
+ return Rate(parse_decimal(raw, path))
231
+
232
+
233
+ def _entity_id(raw: object, path: str) -> EntityId:
234
+ """Decode a stable entity id."""
235
+ text = parse_str(raw, path)
236
+ if not text:
237
+ msg = f"{path}: entity ids must be non-empty"
238
+ raise PersistenceError(msg)
239
+ return EntityId(text)
240
+
241
+
242
+ def _assumption_key(raw: object, path: str) -> AssumptionKey:
243
+ """Decode a dotted assumption key.
244
+
245
+ Raises:
246
+ PersistenceError: If the key is not in the stable catalogue.
247
+ """
248
+ text = parse_str(raw, path)
249
+ try:
250
+ return AssumptionKey(text)
251
+ except ValueError:
252
+ msg = f"{path}: unknown assumption key {text!r}"
253
+ raise PersistenceError(msg) from None
254
+
255
+
256
+ def _document(raw: dict[str, object]) -> PlanDocument:
257
+ """Decode the whole migrated document."""
258
+ reader = _Reader(raw, "document")
259
+ version = parse_int(reader.take("schema_version"), reader.at("schema_version"))
260
+ if version != SCHEMA_VERSION:
261
+ msg = (
262
+ f"document.schema_version: expected {SCHEMA_VERSION} after"
263
+ f" migration, got {version}"
264
+ )
265
+ raise PersistenceError(msg)
266
+ region = parse_str(reader.take("region"), reader.at("region"))
267
+ resolved_against = parse_str(
268
+ reader.take("assumptions_resolved_against"),
269
+ reader.at("assumptions_resolved_against"),
270
+ )
271
+ household = _household(reader.take("household"), reader.at("household"))
272
+ overrides = _sequence(
273
+ reader.take("assumption_overrides"),
274
+ reader.at("assumption_overrides"),
275
+ _assumption_override,
276
+ )
277
+ scenarios = _sequence(reader.take("scenarios"), reader.at("scenarios"), _scenario)
278
+ reader.finish()
279
+ return _built(
280
+ lambda: PlanDocument(
281
+ region=region,
282
+ assumptions_resolved_against=resolved_against,
283
+ household=household,
284
+ assumption_overrides=overrides,
285
+ scenarios=scenarios,
286
+ ),
287
+ "document",
288
+ )
289
+
290
+
291
+ def _assumption_override(raw: object, path: str) -> AssumptionOverride:
292
+ """Decode one stored assumption override."""
293
+ reader = _Reader(raw, path)
294
+ key = _assumption_key(reader.take("key"), reader.at("key"))
295
+ recorded_on = parse_datetime(reader.take("recorded_on"), reader.at("recorded_on"))
296
+ source = parse_str(reader.take("source"), reader.at("source"))
297
+ value = decode_value(reader.take("value"), reader.at("value"))
298
+ reader.finish()
299
+ return _built(
300
+ lambda: AssumptionOverride(
301
+ key=key, value=value, source=source, recorded_on=recorded_on
302
+ ),
303
+ path,
304
+ )
305
+
306
+
307
+ def _scenario(raw: object, path: str) -> Scenario:
308
+ """Decode one named what-if."""
309
+ reader = _Reader(raw, path)
310
+ name = parse_str(reader.take("name"), reader.at("name"))
311
+ note = _optional_str(reader.take("note"), reader.at("note"))
312
+ overrides = _sequence(reader.take("overrides"), reader.at("overrides"), _override)
313
+ reader.finish()
314
+ return _built(lambda: Scenario(name=name, overrides=overrides, note=note), path)
315
+
316
+
317
+ def _override(raw: object, path: str) -> Override:
318
+ """Decode one scenario override."""
319
+ reader = _Reader(raw, path)
320
+ note = _optional_str(reader.take("note"), reader.at("note"))
321
+ target = _target(reader.take("target"), reader.at("target"))
322
+ value = decode_value(reader.take("value"), reader.at("value"))
323
+ reader.finish()
324
+ return Override(target=target, value=value, note=note)
325
+
326
+
327
+ def _target(raw: object, path: str) -> AssumptionTarget | DecisionTarget:
328
+ """Decode an override target of either kind."""
329
+ reader = _Reader(raw, path)
330
+ kind = parse_str(reader.take("kind"), reader.at("kind"))
331
+ if kind == "assumption":
332
+ key = _assumption_key(reader.take("key"), reader.at("key"))
333
+ reader.finish()
334
+ return AssumptionTarget(key=key)
335
+ if kind == "decision":
336
+ entity_id = _entity_id(reader.take("entity_id"), reader.at("entity_id"))
337
+ field_path = parse_str(reader.take("field_path"), reader.at("field_path"))
338
+ reader.finish()
339
+ return _built(
340
+ lambda: DecisionTarget(entity_id=entity_id, field_path=field_path), path
341
+ )
342
+ msg = f"{path}.kind: expected 'assumption' or 'decision', got {kind!r}"
343
+ raise PersistenceError(msg)
344
+
345
+
346
+ def _household(raw: object, path: str) -> Household:
347
+ """Decode the household: persons plus shared economics."""
348
+ reader = _Reader(raw, path)
349
+ persons = _sequence(reader.take("persons"), reader.at("persons"), _person)
350
+ outflows = _sequence(
351
+ reader.take("planned_outflows"),
352
+ reader.at("planned_outflows"),
353
+ _planned_outflow,
354
+ )
355
+ spending = _optional(reader.take("spending"), reader.at("spending"), _spending_plan)
356
+ reader.finish()
357
+ return _built(
358
+ lambda: Household(
359
+ persons=persons, spending=spending, planned_outflows=outflows
360
+ ),
361
+ path,
362
+ )
363
+
364
+
365
+ def _person(raw: object, path: str) -> Person:
366
+ """Decode one person and everything that hangs off them."""
367
+ reader = _Reader(raw, path)
368
+ person_id = _entity_id(reader.take("id"), reader.at("id"))
369
+ date_of_birth = _fact(
370
+ reader.take("date_of_birth"), reader.at("date_of_birth"), parse_date
371
+ )
372
+ retirement_age = _decision(
373
+ reader.take("target_retirement_age"),
374
+ reader.at("target_retirement_age"),
375
+ parse_int,
376
+ )
377
+ residency = TaxResidencyId(
378
+ parse_str(reader.take("tax_residency"), reader.at("tax_residency"))
379
+ )
380
+ sex = _optional_fact(reader, "sex_for_longevity", _sex)
381
+ income = _optional_fact(reader, "employment_income", parse_money)
382
+ mpaa = _optional_fact(reader, "mpaa_triggered_on", parse_date)
383
+ lsa_used = _optional_fact(reader, "lsa_used", parse_money)
384
+ wrappers = _sequence(reader.take("wrappers"), reader.at("wrappers"), _wrapper)
385
+ pensions = _sequence(
386
+ reader.take("db_pensions"), reader.at("db_pensions"), _db_pension
387
+ )
388
+ purchases = _sequence(
389
+ reader.take("annuity_purchases"),
390
+ reader.at("annuity_purchases"),
391
+ _annuity_purchase,
392
+ )
393
+ state_pension = _optional(
394
+ reader.take("state_pension"), reader.at("state_pension"), _state_pension
395
+ )
396
+ glide_path = _optional(
397
+ reader.take("glide_path"), reader.at("glide_path"), _glide_path
398
+ )
399
+ reader.finish()
400
+ return _built(
401
+ lambda: Person(
402
+ id=person_id,
403
+ date_of_birth=date_of_birth,
404
+ target_retirement_age=retirement_age,
405
+ tax_residency=residency,
406
+ sex_for_longevity=sex,
407
+ employment_income=income,
408
+ mpaa_triggered_on=mpaa,
409
+ lsa_used=lsa_used,
410
+ wrappers=wrappers,
411
+ db_pensions=pensions,
412
+ annuity_purchases=purchases,
413
+ state_pension=state_pension,
414
+ glide_path=glide_path,
415
+ ),
416
+ path,
417
+ )
418
+
419
+
420
+ def _optional_fact[T](
421
+ reader: _Reader, key: str, value: Callable[[object, str], T]
422
+ ) -> Fact[T] | None:
423
+ """Consume a nullable fact field from ``reader``."""
424
+ raw = reader.take(key)
425
+ path = reader.at(key)
426
+ return None if raw is None else _fact(raw, path, value)
427
+
428
+
429
+ def _wrapper(raw: object, path: str) -> Wrapper:
430
+ """Decode one account of an opaque region-defined kind."""
431
+ reader = _Reader(raw, path)
432
+ wrapper_id = _entity_id(reader.take("id"), reader.at("id"))
433
+ kind = WrapperKindId(parse_str(reader.take("kind"), reader.at("kind")))
434
+ balance = _fact(reader.take("balance"), reader.at("balance"), parse_money)
435
+ crystallised = _optional_fact(reader, "crystallised_balance", parse_money)
436
+ contributions = _optional(
437
+ reader.take("contributions"),
438
+ reader.at("contributions"),
439
+ _contribution_schedule,
440
+ )
441
+ allocation = _optional(
442
+ reader.take("allocation"), reader.at("allocation"), _allocation
443
+ )
444
+ fees = _optional(reader.take("fees"), reader.at("fees"), _fee_schedule)
445
+ reader.finish()
446
+ return _built(
447
+ lambda: Wrapper(
448
+ id=wrapper_id,
449
+ kind=kind,
450
+ balance=balance,
451
+ crystallised_balance=crystallised,
452
+ contributions=contributions,
453
+ allocation=allocation,
454
+ fees=fees,
455
+ ),
456
+ path,
457
+ )
458
+
459
+
460
+ def _contribution_schedule(raw: object, path: str) -> ContributionSchedule:
461
+ """Decode one wrapper's planned annual contributions."""
462
+ reader = _Reader(raw, path)
463
+ employee = _decision(
464
+ reader.take("employee_amount"), reader.at("employee_amount"), parse_money
465
+ )
466
+ employer = _optional_fact(reader, "employer_amount", parse_money)
467
+ mechanic_raw = reader.take("relief_mechanic")
468
+ mechanic = (
469
+ None
470
+ if mechanic_raw is None
471
+ else RELIEF_MECHANIC_TOKENS.member(mechanic_raw, reader.at("relief_mechanic"))
472
+ )
473
+ escalation = _optional(
474
+ reader.take("escalation"), reader.at("escalation"), _assumption_key
475
+ )
476
+ reader.finish()
477
+ return _built(
478
+ lambda: ContributionSchedule(
479
+ employee_amount=employee,
480
+ employer_amount=employer,
481
+ relief_mechanic=mechanic,
482
+ escalation=escalation,
483
+ ),
484
+ path,
485
+ )
486
+
487
+
488
+ def _allocation(raw: object, path: str) -> AssetAllocation:
489
+ """Decode portfolio weights over the three priced asset classes."""
490
+ reader = _Reader(raw, path)
491
+ equity = parse_decimal(reader.take("equity"), reader.at("equity"))
492
+ bonds = parse_decimal(reader.take("bonds"), reader.at("bonds"))
493
+ cash = parse_decimal(reader.take("cash"), reader.at("cash"))
494
+ reader.finish()
495
+ return _built(lambda: AssetAllocation(equity=equity, bonds=bonds, cash=cash), path)
496
+
497
+
498
+ def _fee_schedule(raw: object, path: str) -> FeeSchedule:
499
+ """Decode one wrapper's annual percentage fees."""
500
+ reader = _Reader(raw, path)
501
+ platform = _rate(reader.take("platform"), reader.at("platform"))
502
+ fund = _rate(reader.take("fund"), reader.at("fund"))
503
+ reader.finish()
504
+ return _built(lambda: FeeSchedule(platform=platform, fund=fund), path)
505
+
506
+
507
+ def _db_pension(raw: object, path: str) -> DBPension:
508
+ """Decode one DB entitlement."""
509
+ reader = _Reader(raw, path)
510
+ pension_id = _entity_id(reader.take("id"), reader.at("id"))
511
+ accrued = _fact(
512
+ reader.take("accrued_annual_pension"),
513
+ reader.at("accrued_annual_pension"),
514
+ parse_money,
515
+ )
516
+ statement_date = parse_date(
517
+ reader.take("statement_date"), reader.at("statement_date")
518
+ )
519
+ npa = _fact(
520
+ reader.take("normal_pension_age"),
521
+ reader.at("normal_pension_age"),
522
+ parse_int,
523
+ )
524
+ basis = _revaluation_basis(
525
+ reader.take("revaluation_basis"), reader.at("revaluation_basis")
526
+ )
527
+ factors = _factor_table(
528
+ reader.take("early_late_factors"), reader.at("early_late_factors")
529
+ )
530
+ commuted = _decision(
531
+ reader.take("commuted_fraction"),
532
+ reader.at("commuted_fraction"),
533
+ parse_decimal,
534
+ )
535
+ commutation_raw = reader.take("commutation_factor")
536
+ commutation = (
537
+ None
538
+ if commutation_raw is None
539
+ else _fact(commutation_raw, reader.at("commutation_factor"), parse_decimal)
540
+ )
541
+ taken_raw = reader.take("taken_at_age")
542
+ taken = (
543
+ None
544
+ if taken_raw is None
545
+ else _decision(taken_raw, reader.at("taken_at_age"), parse_int)
546
+ )
547
+ membership = _optional(
548
+ reader.take("active_membership"),
549
+ reader.at("active_membership"),
550
+ _active_membership,
551
+ )
552
+ reader.finish()
553
+ return _built(
554
+ lambda: DBPension(
555
+ id=pension_id,
556
+ accrued_annual_pension=accrued,
557
+ statement_date=statement_date,
558
+ normal_pension_age=npa,
559
+ revaluation_basis=basis,
560
+ early_late_factors=factors,
561
+ commuted_fraction=commuted,
562
+ commutation_factor=commutation,
563
+ taken_at_age=taken,
564
+ active_membership=membership,
565
+ ),
566
+ path,
567
+ )
568
+
569
+
570
+ def _active_membership(raw: object, path: str) -> DBActiveMembership:
571
+ """Decode active CARE-style accrual on a DB entitlement (9.6)."""
572
+ reader = _Reader(raw, path)
573
+ accrual_rate = _fact(
574
+ reader.take("accrual_rate"), reader.at("accrual_rate"), parse_decimal
575
+ )
576
+ salary = _fact(
577
+ reader.take("pensionable_salary"),
578
+ reader.at("pensionable_salary"),
579
+ parse_money,
580
+ )
581
+ until_raw = reader.take("active_until_age")
582
+ until = (
583
+ None
584
+ if until_raw is None
585
+ else _decision(until_raw, reader.at("active_until_age"), parse_int)
586
+ )
587
+ reader.finish()
588
+ return _built(
589
+ lambda: DBActiveMembership(
590
+ accrual_rate=accrual_rate,
591
+ pensionable_salary=salary,
592
+ active_until_age=until,
593
+ ),
594
+ path,
595
+ )
596
+
597
+
598
+ def _revaluation_basis(raw: object, path: str) -> RevaluationBasis:
599
+ """Decode how a DB entitlement revalues."""
600
+ reader = _Reader(raw, path)
601
+ reference = REVALUATION_REFERENCE_TOKENS.member(
602
+ reader.take("reference"), reader.at("reference")
603
+ )
604
+ cap = _optional(reader.take("cap"), reader.at("cap"), _rate)
605
+ fixed_rate = _optional(reader.take("fixed_rate"), reader.at("fixed_rate"), _rate)
606
+ reader.finish()
607
+ return _built(
608
+ lambda: RevaluationBasis(reference=reference, cap=cap, fixed_rate=fixed_rate),
609
+ path,
610
+ )
611
+
612
+
613
+ def _factor_table(raw: object, path: str) -> FactorTable:
614
+ """Decode a whole-year-age → factor table."""
615
+ if not isinstance(raw, dict):
616
+ msg = f"{path}: expected an object, got {type(raw).__name__}"
617
+ raise PersistenceError(msg)
618
+ factors: dict[int, Decimal] = {}
619
+ for age_text, factor_raw in raw.items():
620
+ try:
621
+ age = int(age_text)
622
+ except ValueError:
623
+ msg = f"{path}: ages must be whole years, got {age_text!r}"
624
+ raise PersistenceError(msg) from None
625
+ factors[age] = parse_decimal(factor_raw, f"{path}.{age_text}")
626
+ return _built(lambda: FactorTable(factors=factors), path)
627
+
628
+
629
+ def _annuity_purchase(raw: object, path: str) -> AnnuityPurchase:
630
+ """Decode one planned annuity purchase."""
631
+ reader = _Reader(raw, path)
632
+ purchase_id = _entity_id(reader.take("id"), reader.at("id"))
633
+ at_age = _decision(reader.take("at_age"), reader.at("at_age"), parse_int)
634
+ fraction = _decision(
635
+ reader.take("fraction_of_pot"), reader.at("fraction_of_pot"), parse_decimal
636
+ )
637
+ annuity_type = ANNUITY_TYPE_TOKENS.member(
638
+ reader.take("annuity_type"), reader.at("annuity_type")
639
+ )
640
+ basis = ANNUITY_BASIS_TOKENS.member(reader.take("basis"), reader.at("basis"))
641
+ reader.finish()
642
+ return _built(
643
+ lambda: AnnuityPurchase(
644
+ id=purchase_id,
645
+ at_age=at_age,
646
+ fraction_of_pot=fraction,
647
+ annuity_type=annuity_type,
648
+ basis=basis,
649
+ ),
650
+ path,
651
+ )
652
+
653
+
654
+ def _state_pension(raw: object, path: str) -> StatePensionRecord:
655
+ """Decode one person's state pension position."""
656
+ reader = _Reader(raw, path)
657
+ forecast = _optional_fact(reader, "forecast_weekly_amount", parse_money)
658
+ protected = _optional_fact(reader, "protected_payment", parse_money)
659
+ deferral = _decision(
660
+ reader.take("deferral_years"), reader.at("deferral_years"), parse_decimal
661
+ )
662
+ reader.finish()
663
+ return _built(
664
+ lambda: StatePensionRecord(
665
+ forecast_weekly_amount=forecast,
666
+ protected_payment=protected,
667
+ deferral_years=deferral,
668
+ ),
669
+ path,
670
+ )
671
+
672
+
673
+ def _glide_path(raw: object, path: str) -> GlidePathConfig:
674
+ """Decode a person's own glide path."""
675
+ reader = _Reader(raw, path)
676
+ points = _sequence(reader.take("points"), reader.at("points"), _glide_point)
677
+ reader.finish()
678
+ return _built(lambda: GlidePathConfig(points=points), path)
679
+
680
+
681
+ def _glide_point(raw: object, path: str) -> GlidePathPoint:
682
+ """Decode one glide-path knot."""
683
+ reader = _Reader(raw, path)
684
+ years = parse_int(
685
+ reader.take("years_to_retirement"), reader.at("years_to_retirement")
686
+ )
687
+ allocation = _allocation(reader.take("allocation"), reader.at("allocation"))
688
+ reader.finish()
689
+ return _built(
690
+ lambda: GlidePathPoint(years_to_retirement=years, allocation=allocation), path
691
+ )
692
+
693
+
694
+ def _spending_plan(raw: object, path: str) -> SpendingPlan:
695
+ """Decode the household's retirement spending need."""
696
+ reader = _Reader(raw, path)
697
+ annual = _fact(
698
+ reader.take("annual_spending_real"),
699
+ reader.at("annual_spending_real"),
700
+ parse_money,
701
+ )
702
+ multipliers = _optional(
703
+ reader.take("stage_multipliers"),
704
+ reader.at("stage_multipliers"),
705
+ _stage_multipliers,
706
+ )
707
+ reader.finish()
708
+ return _built(
709
+ lambda: SpendingPlan(
710
+ annual_spending_real=annual, stage_multipliers=multipliers
711
+ ),
712
+ path,
713
+ )
714
+
715
+
716
+ def _stage_multipliers(raw: object, path: str) -> dict[LifeStage, Decimal]:
717
+ """Decode per-life-stage spending multipliers."""
718
+ if not isinstance(raw, dict):
719
+ msg = f"{path}: expected an object, got {type(raw).__name__}"
720
+ raise PersistenceError(msg)
721
+ return {
722
+ LIFE_STAGE_TOKENS.member(token, f"{path}.{token}"): parse_decimal(
723
+ value, f"{path}.{token}"
724
+ )
725
+ for token, value in raw.items()
726
+ }
727
+
728
+
729
+ def _planned_outflow(raw: object, path: str) -> PlannedOutflow:
730
+ """Decode one dated one-off outflow."""
731
+ reader = _Reader(raw, path)
732
+ outflow_id = _entity_id(reader.take("id"), reader.at("id"))
733
+ label = parse_str(reader.take("label"), reader.at("label"))
734
+ amount = _decision(
735
+ reader.take("amount_real"), reader.at("amount_real"), parse_money
736
+ )
737
+ at_reader = _Reader(reader.take("at_age_of"), reader.at("at_age_of"))
738
+ person_id = _entity_id(at_reader.take("person_id"), at_reader.at("person_id"))
739
+ age = parse_int(at_reader.take("age"), at_reader.at("age"))
740
+ at_reader.finish()
741
+ reader.finish()
742
+ return _built(
743
+ lambda: PlannedOutflow(
744
+ id=outflow_id, label=label, amount_real=amount, at_age_of=(person_id, age)
745
+ ),
746
+ path,
747
+ )