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