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,101 @@
|
|
|
1
|
+
"""The ``.glidepath.json`` plan document model (roadmap 6.2; planning §4.5).
|
|
2
|
+
|
|
3
|
+
One JSON document per plan holds everything the user owns: the household
|
|
4
|
+
(facts and decisions), the scenarios over it, and the user's assumption
|
|
5
|
+
*overrides* — never resolved assumption values. Shipped defaults
|
|
6
|
+
re-resolve on every load against the region data the app carries, and
|
|
7
|
+
the document records the region data version it was last resolved
|
|
8
|
+
against so a default change surfaces visibly instead of silently
|
|
9
|
+
re-pricing the plan (planning §4.5).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import TYPE_CHECKING, Any
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from datetime import datetime
|
|
17
|
+
|
|
18
|
+
from glidepath.core import AssumptionKey, Household, Scenario
|
|
19
|
+
|
|
20
|
+
SCHEMA_VERSION = 4
|
|
21
|
+
"""The schema version this build reads and writes (planning §4.5).
|
|
22
|
+
|
|
23
|
+
v2 (roadmap 9.6): every DB pension carries an ``active_membership``
|
|
24
|
+
key — ``null`` for a deferred entitlement.
|
|
25
|
+
|
|
26
|
+
v3 (#97): the state pension record loses the qualifying-years
|
|
27
|
+
derivation fields (``ni_record_start``, ``qualifying_years``,
|
|
28
|
+
``planned_extra_years``) — the official DWP forecast is the only
|
|
29
|
+
route to an amount.
|
|
30
|
+
|
|
31
|
+
v4 (#129): the spending plan loses the accumulation-stage multiplier
|
|
32
|
+
keys (``early_accumulation``, ``mid_accumulation``,
|
|
33
|
+
``pre_retirement``) — spending is modelled only in retirement, so
|
|
34
|
+
they never bound (#114 retired the tokens without a migration).
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class PersistenceError(ValueError):
|
|
39
|
+
"""A plan document that cannot be encoded, decoded, or resolved."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True, slots=True)
|
|
43
|
+
class AssumptionOverride:
|
|
44
|
+
"""One stored user override of a shipped default (planning §4.5).
|
|
45
|
+
|
|
46
|
+
Only the override is persisted — the effective assumption is rebuilt
|
|
47
|
+
on load from the current shipped default plus this record, carrying
|
|
48
|
+
``USER_OVERRIDE`` provenance. ``source`` is the user's stated basis
|
|
49
|
+
for departing from the default; ``recorded_on`` is when they did.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
key: AssumptionKey
|
|
53
|
+
value: Any
|
|
54
|
+
source: str
|
|
55
|
+
recorded_on: datetime
|
|
56
|
+
|
|
57
|
+
def __post_init__(self) -> None:
|
|
58
|
+
"""Reject an empty source and a naive timestamp."""
|
|
59
|
+
if not self.source:
|
|
60
|
+
msg = "AssumptionOverride.source must be non-empty"
|
|
61
|
+
raise ValueError(msg)
|
|
62
|
+
moment = self.recorded_on
|
|
63
|
+
if moment.tzinfo is None or moment.tzinfo.utcoffset(moment) is None:
|
|
64
|
+
msg = "AssumptionOverride.recorded_on must be timezone-aware"
|
|
65
|
+
raise ValueError(msg)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True, slots=True)
|
|
69
|
+
class PlanDocument:
|
|
70
|
+
"""Everything one ``.glidepath.json`` file stores (planning §4.5).
|
|
71
|
+
|
|
72
|
+
``region`` names the region module whose shipped data the plan
|
|
73
|
+
resolves against (e.g. ``"uk"``); ``assumptions_resolved_against``
|
|
74
|
+
is that region's data content version at the last resolution, so a
|
|
75
|
+
later load against newer data can tell the user their defaults
|
|
76
|
+
moved. The schema version is a wire-format detail stamped by the
|
|
77
|
+
writer, not part of the in-memory model.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
region: str
|
|
81
|
+
assumptions_resolved_against: str
|
|
82
|
+
household: Household
|
|
83
|
+
assumption_overrides: tuple[AssumptionOverride, ...] = ()
|
|
84
|
+
scenarios: tuple[Scenario, ...] = ()
|
|
85
|
+
|
|
86
|
+
def __post_init__(self) -> None:
|
|
87
|
+
"""Reject empty identifiers and ambiguous duplicates."""
|
|
88
|
+
if not self.region:
|
|
89
|
+
msg = "PlanDocument.region must be non-empty"
|
|
90
|
+
raise ValueError(msg)
|
|
91
|
+
if not self.assumptions_resolved_against:
|
|
92
|
+
msg = "PlanDocument.assumptions_resolved_against must be non-empty"
|
|
93
|
+
raise ValueError(msg)
|
|
94
|
+
keys = [override.key for override in self.assumption_overrides]
|
|
95
|
+
if len(set(keys)) != len(keys):
|
|
96
|
+
msg = "PlanDocument.assumption_overrides must target distinct keys"
|
|
97
|
+
raise ValueError(msg)
|
|
98
|
+
names = [scenario.name for scenario in self.scenarios]
|
|
99
|
+
if len(set(names)) != len(names):
|
|
100
|
+
msg = "PlanDocument.scenarios must have distinct names"
|
|
101
|
+
raise ValueError(msg)
|
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
"""Canonical writer for ``.glidepath.json`` (roadmap 6.2; planning §4.5).
|
|
2
|
+
|
|
3
|
+
Deterministic output for clean diffs: ``schema_version`` stamped in,
|
|
4
|
+
keys sorted, 2-space indent, LF line endings, ``Decimal`` as strings,
|
|
5
|
+
ISO-8601 timezone-aware datetimes. A given document always serializes
|
|
6
|
+
to the same bytes and a load→save round trip is byte-stable; value
|
|
7
|
+
representations are preserved exactly (a ``Decimal`` keeps the
|
|
8
|
+
exponent the user stated, a datetime its offset), so two values that
|
|
9
|
+
compare equal but were written differently keep their distinct
|
|
10
|
+
spellings. Only the user's assumption *overrides* are written;
|
|
11
|
+
defaults re-resolve on load against shipped region data.
|
|
12
|
+
|
|
13
|
+
The writer never produces a file the reader rejects: everything the
|
|
14
|
+
strict reader refuses that the domain model does not already rule out
|
|
15
|
+
(non-finite decimals, booleans in whole-number fields, empty entity
|
|
16
|
+
ids) is rejected here too.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import contextlib
|
|
20
|
+
import json
|
|
21
|
+
from typing import TYPE_CHECKING
|
|
22
|
+
|
|
23
|
+
from glidepath.core import (
|
|
24
|
+
AssumptionTarget,
|
|
25
|
+
ContributionSchedule,
|
|
26
|
+
DBPension,
|
|
27
|
+
Household,
|
|
28
|
+
Person,
|
|
29
|
+
PlannedOutflow,
|
|
30
|
+
Scenario,
|
|
31
|
+
SpendingPlan,
|
|
32
|
+
StatePensionRecord,
|
|
33
|
+
Wrapper,
|
|
34
|
+
)
|
|
35
|
+
from glidepath.persistence.document import (
|
|
36
|
+
SCHEMA_VERSION,
|
|
37
|
+
PersistenceError,
|
|
38
|
+
PlanDocument,
|
|
39
|
+
)
|
|
40
|
+
from glidepath.persistence.values import (
|
|
41
|
+
ANNUITY_BASIS_TOKENS,
|
|
42
|
+
ANNUITY_TYPE_TOKENS,
|
|
43
|
+
LIFE_STAGE_TOKENS,
|
|
44
|
+
RELIEF_MECHANIC_TOKENS,
|
|
45
|
+
REVALUATION_REFERENCE_TOKENS,
|
|
46
|
+
SEX_TOKENS,
|
|
47
|
+
encode_value,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
if TYPE_CHECKING:
|
|
51
|
+
from collections.abc import Callable, Mapping
|
|
52
|
+
from datetime import date
|
|
53
|
+
from decimal import Decimal
|
|
54
|
+
from pathlib import Path
|
|
55
|
+
|
|
56
|
+
from glidepath.core import (
|
|
57
|
+
AnnuityPurchase,
|
|
58
|
+
AssetAllocation,
|
|
59
|
+
DBActiveMembership,
|
|
60
|
+
Decision,
|
|
61
|
+
EntityId,
|
|
62
|
+
Fact,
|
|
63
|
+
FeeSchedule,
|
|
64
|
+
GlidePathConfig,
|
|
65
|
+
LifeStage,
|
|
66
|
+
Money,
|
|
67
|
+
Override,
|
|
68
|
+
RevaluationBasis,
|
|
69
|
+
Sex,
|
|
70
|
+
)
|
|
71
|
+
from glidepath.persistence.document import AssumptionOverride
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def dumps_plan(document: PlanDocument) -> str:
|
|
75
|
+
"""The document as canonical ``.glidepath.json`` text (planning §4.5).
|
|
76
|
+
|
|
77
|
+
Raises:
|
|
78
|
+
PersistenceError: If an override value's type is outside the
|
|
79
|
+
persistable vocabulary.
|
|
80
|
+
"""
|
|
81
|
+
payload = _document_payload(document)
|
|
82
|
+
return json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def save_plan(document: PlanDocument, path: Path) -> None:
|
|
86
|
+
"""Write the document to ``path`` in canonical form.
|
|
87
|
+
|
|
88
|
+
Serialization completes before any file is opened, so a document
|
|
89
|
+
that cannot be encoded never touches the disk; the text is then
|
|
90
|
+
written to a sibling temporary file and moved over ``path`` only
|
|
91
|
+
once the write has closed cleanly, so a mid-write failure (disk
|
|
92
|
+
full, power loss) can never truncate the last saved plan.
|
|
93
|
+
``newline=""`` disables platform newline translation so the file
|
|
94
|
+
carries LF endings on every platform (planning §4.5).
|
|
95
|
+
"""
|
|
96
|
+
text = dumps_plan(document)
|
|
97
|
+
temp = path.with_name(path.name + ".tmp")
|
|
98
|
+
try:
|
|
99
|
+
with temp.open("w", encoding="utf-8", newline="") as handle:
|
|
100
|
+
handle.write(text)
|
|
101
|
+
temp.replace(path)
|
|
102
|
+
finally:
|
|
103
|
+
with contextlib.suppress(OSError):
|
|
104
|
+
temp.unlink(missing_ok=True)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _document_payload(document: PlanDocument) -> dict[str, object]:
|
|
108
|
+
"""The whole document as a JSON-ready payload."""
|
|
109
|
+
return {
|
|
110
|
+
"schema_version": SCHEMA_VERSION,
|
|
111
|
+
"region": document.region,
|
|
112
|
+
"assumptions_resolved_against": document.assumptions_resolved_against,
|
|
113
|
+
"household": _household(document.household),
|
|
114
|
+
"assumption_overrides": [
|
|
115
|
+
_assumption_override(override, f"assumption_overrides[{index}]")
|
|
116
|
+
for index, override in enumerate(document.assumption_overrides)
|
|
117
|
+
],
|
|
118
|
+
"scenarios": [
|
|
119
|
+
_scenario(scenario, f"scenarios[{index}]")
|
|
120
|
+
for index, scenario in enumerate(document.scenarios)
|
|
121
|
+
],
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _assumption_override(override: AssumptionOverride, path: str) -> dict[str, object]:
|
|
126
|
+
"""One stored assumption override."""
|
|
127
|
+
return {
|
|
128
|
+
"key": override.key.value,
|
|
129
|
+
"recorded_on": override.recorded_on.isoformat(),
|
|
130
|
+
"source": override.source,
|
|
131
|
+
"value": encode_value(override.value, f"{path}.value"),
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _scenario(scenario: Scenario, path: str) -> dict[str, object]:
|
|
136
|
+
"""One named what-if with its override deltas."""
|
|
137
|
+
return {
|
|
138
|
+
"name": scenario.name,
|
|
139
|
+
"note": scenario.note,
|
|
140
|
+
"overrides": [
|
|
141
|
+
_override(override, f"{path}.overrides[{index}]")
|
|
142
|
+
for index, override in enumerate(scenario.overrides)
|
|
143
|
+
],
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _override(override: Override, path: str) -> dict[str, object]:
|
|
148
|
+
"""One scenario override: its target and replacement value."""
|
|
149
|
+
target = override.target
|
|
150
|
+
if isinstance(target, AssumptionTarget):
|
|
151
|
+
target_payload: dict[str, object] = {
|
|
152
|
+
"kind": "assumption",
|
|
153
|
+
"key": target.key.value,
|
|
154
|
+
}
|
|
155
|
+
else:
|
|
156
|
+
target_payload = {
|
|
157
|
+
"kind": "decision",
|
|
158
|
+
"entity_id": _entity_id(target.entity_id),
|
|
159
|
+
"field_path": target.field_path,
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
"note": override.note,
|
|
163
|
+
"target": target_payload,
|
|
164
|
+
"value": encode_value(override.value, f"{path}.value"),
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _household(household: Household) -> dict[str, object]:
|
|
169
|
+
"""The household: persons plus shared economics."""
|
|
170
|
+
return {
|
|
171
|
+
"persons": [_person(person) for person in household.persons],
|
|
172
|
+
"planned_outflows": [
|
|
173
|
+
_planned_outflow(outflow) for outflow in household.planned_outflows
|
|
174
|
+
],
|
|
175
|
+
"spending": _optional(household.spending, _spending_plan),
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _person(person: Person) -> dict[str, object]:
|
|
180
|
+
"""One person and everything that hangs off them."""
|
|
181
|
+
return {
|
|
182
|
+
"id": _entity_id(person.id),
|
|
183
|
+
"date_of_birth": _fact(person.date_of_birth, _date_value),
|
|
184
|
+
"target_retirement_age": _decision(person.target_retirement_age, _int_value),
|
|
185
|
+
"tax_residency": str(person.tax_residency),
|
|
186
|
+
"sex_for_longevity": _optional_fact(person.sex_for_longevity, _sex_value),
|
|
187
|
+
"employment_income": _optional_fact(person.employment_income, _money_value),
|
|
188
|
+
"mpaa_triggered_on": _optional_fact(person.mpaa_triggered_on, _date_value),
|
|
189
|
+
"lsa_used": _optional_fact(person.lsa_used, _money_value),
|
|
190
|
+
"wrappers": [_wrapper(wrapper) for wrapper in person.wrappers],
|
|
191
|
+
"db_pensions": [_db_pension(pension) for pension in person.db_pensions],
|
|
192
|
+
"annuity_purchases": [
|
|
193
|
+
_annuity_purchase(purchase) for purchase in person.annuity_purchases
|
|
194
|
+
],
|
|
195
|
+
"state_pension": _optional(person.state_pension, _state_pension),
|
|
196
|
+
"glide_path": _optional(person.glide_path, _glide_path),
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _wrapper(wrapper: Wrapper) -> dict[str, object]:
|
|
201
|
+
"""One account, of an opaque region-defined kind."""
|
|
202
|
+
return {
|
|
203
|
+
"id": _entity_id(wrapper.id),
|
|
204
|
+
"kind": str(wrapper.kind),
|
|
205
|
+
"balance": _fact(wrapper.balance, _money_value),
|
|
206
|
+
"crystallised_balance": _optional_fact(
|
|
207
|
+
wrapper.crystallised_balance, _money_value
|
|
208
|
+
),
|
|
209
|
+
"contributions": _optional(wrapper.contributions, _contribution_schedule),
|
|
210
|
+
"allocation": _optional(wrapper.allocation, _allocation),
|
|
211
|
+
"fees": _optional(wrapper.fees, _fee_schedule),
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _contribution_schedule(schedule: ContributionSchedule) -> dict[str, object]:
|
|
216
|
+
"""One wrapper's planned annual contributions."""
|
|
217
|
+
mechanic = schedule.relief_mechanic
|
|
218
|
+
escalation = schedule.escalation
|
|
219
|
+
return {
|
|
220
|
+
"employee_amount": _decision(schedule.employee_amount, _money_value),
|
|
221
|
+
"employer_amount": _optional_fact(schedule.employer_amount, _money_value),
|
|
222
|
+
"relief_mechanic": (
|
|
223
|
+
None if mechanic is None else RELIEF_MECHANIC_TOKENS.token(mechanic)
|
|
224
|
+
),
|
|
225
|
+
"escalation": None if escalation is None else escalation.value,
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _allocation(allocation: AssetAllocation) -> dict[str, object]:
|
|
230
|
+
"""Portfolio weights over the three priced asset classes."""
|
|
231
|
+
return {
|
|
232
|
+
"equity": str(allocation.equity),
|
|
233
|
+
"bonds": str(allocation.bonds),
|
|
234
|
+
"cash": str(allocation.cash),
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _fee_schedule(fees: FeeSchedule) -> dict[str, object]:
|
|
239
|
+
"""One wrapper's annual percentage fees."""
|
|
240
|
+
return {"platform": str(fees.platform.value), "fund": str(fees.fund.value)}
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _db_pension(pension: DBPension) -> dict[str, object]:
|
|
244
|
+
"""One DB entitlement's scheme facts and choices."""
|
|
245
|
+
return {
|
|
246
|
+
"id": _entity_id(pension.id),
|
|
247
|
+
"accrued_annual_pension": _fact(pension.accrued_annual_pension, _money_value),
|
|
248
|
+
"statement_date": pension.statement_date.isoformat(),
|
|
249
|
+
"normal_pension_age": _fact(pension.normal_pension_age, _int_value),
|
|
250
|
+
"revaluation_basis": _revaluation_basis(pension.revaluation_basis),
|
|
251
|
+
"early_late_factors": _factor_table(pension.early_late_factors.factors),
|
|
252
|
+
"commuted_fraction": _decision(pension.commuted_fraction, _decimal_value),
|
|
253
|
+
"commutation_factor": _optional_fact(
|
|
254
|
+
pension.commutation_factor, _decimal_value
|
|
255
|
+
),
|
|
256
|
+
"taken_at_age": _optional_decision(pension.taken_at_age, _int_value),
|
|
257
|
+
"active_membership": _optional(pension.active_membership, _active_membership),
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _active_membership(membership: DBActiveMembership) -> dict[str, object]:
|
|
262
|
+
"""Active CARE-style accrual on a DB entitlement (roadmap 9.6)."""
|
|
263
|
+
return {
|
|
264
|
+
"accrual_rate": _fact(membership.accrual_rate, _decimal_value),
|
|
265
|
+
"pensionable_salary": _fact(membership.pensionable_salary, _money_value),
|
|
266
|
+
"active_until_age": _optional_decision(membership.active_until_age, _int_value),
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _revaluation_basis(basis: RevaluationBasis) -> dict[str, object]:
|
|
271
|
+
"""How a DB entitlement revalues."""
|
|
272
|
+
return {
|
|
273
|
+
"reference": REVALUATION_REFERENCE_TOKENS.token(basis.reference),
|
|
274
|
+
"cap": None if basis.cap is None else str(basis.cap.value),
|
|
275
|
+
"fixed_rate": None if basis.fixed_rate is None else str(basis.fixed_rate.value),
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _factor_table(factors: Mapping[int, Decimal]) -> dict[str, str]:
|
|
280
|
+
"""A whole-year-age → factor table, ages as JSON object keys."""
|
|
281
|
+
return {str(age): str(factor) for age, factor in factors.items()}
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _annuity_purchase(purchase: AnnuityPurchase) -> dict[str, object]:
|
|
285
|
+
"""One planned annuity purchase — wholly a decision record."""
|
|
286
|
+
return {
|
|
287
|
+
"id": _entity_id(purchase.id),
|
|
288
|
+
"at_age": _decision(purchase.at_age, _int_value),
|
|
289
|
+
"fraction_of_pot": _decision(purchase.fraction_of_pot, _decimal_value),
|
|
290
|
+
"annuity_type": ANNUITY_TYPE_TOKENS.token(purchase.annuity_type),
|
|
291
|
+
"basis": ANNUITY_BASIS_TOKENS.token(purchase.basis),
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _state_pension(record: StatePensionRecord) -> dict[str, object]:
|
|
296
|
+
"""One person's state pension position."""
|
|
297
|
+
return {
|
|
298
|
+
"forecast_weekly_amount": _optional_fact(
|
|
299
|
+
record.forecast_weekly_amount, _money_value
|
|
300
|
+
),
|
|
301
|
+
"protected_payment": _optional_fact(record.protected_payment, _money_value),
|
|
302
|
+
"deferral_years": _decision(record.deferral_years, _decimal_value),
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _glide_path(config: GlidePathConfig) -> dict[str, object]:
|
|
307
|
+
"""A person's own glide path: the factor-table knots in order."""
|
|
308
|
+
return {
|
|
309
|
+
"points": [
|
|
310
|
+
{
|
|
311
|
+
"years_to_retirement": _int_value(point.years_to_retirement),
|
|
312
|
+
"allocation": _allocation(point.allocation),
|
|
313
|
+
}
|
|
314
|
+
for point in config.points
|
|
315
|
+
]
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _spending_plan(spending: SpendingPlan) -> dict[str, object]:
|
|
320
|
+
"""The household's retirement spending need."""
|
|
321
|
+
multipliers = spending.stage_multipliers
|
|
322
|
+
return {
|
|
323
|
+
"annual_spending_real": _fact(spending.annual_spending_real, _money_value),
|
|
324
|
+
"stage_multipliers": (
|
|
325
|
+
None if multipliers is None else _stage_multipliers(multipliers)
|
|
326
|
+
),
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _stage_multipliers(multipliers: Mapping[LifeStage, Decimal]) -> dict[str, str]:
|
|
331
|
+
"""Per-life-stage spending multipliers, stages as stable tokens."""
|
|
332
|
+
return {
|
|
333
|
+
LIFE_STAGE_TOKENS.token(stage): str(value)
|
|
334
|
+
for stage, value in multipliers.items()
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _planned_outflow(outflow: PlannedOutflow) -> dict[str, object]:
|
|
339
|
+
"""One dated one-off outflow."""
|
|
340
|
+
person_id, age = outflow.at_age_of
|
|
341
|
+
return {
|
|
342
|
+
"id": _entity_id(outflow.id),
|
|
343
|
+
"label": outflow.label,
|
|
344
|
+
"amount_real": _decision(outflow.amount_real, _money_value),
|
|
345
|
+
"at_age_of": {"person_id": _entity_id(person_id), "age": _int_value(age)},
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _fact[T](fact: Fact[T], value: Callable[[T], object]) -> dict[str, object]:
|
|
350
|
+
"""A user-stated fact with its provenance fields."""
|
|
351
|
+
return {
|
|
352
|
+
"value": value(fact.value),
|
|
353
|
+
"as_of": fact.as_of.isoformat(),
|
|
354
|
+
"recorded_on": fact.recorded_on.isoformat(),
|
|
355
|
+
"note": fact.note,
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _optional_fact[T](
|
|
360
|
+
fact: Fact[T] | None, value: Callable[[T], object]
|
|
361
|
+
) -> dict[str, object] | None:
|
|
362
|
+
"""A fact the schema allows to be absent."""
|
|
363
|
+
return None if fact is None else _fact(fact, value)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _decision[T](
|
|
367
|
+
decision: Decision[T], value: Callable[[T], object]
|
|
368
|
+
) -> dict[str, object]:
|
|
369
|
+
"""A user choice with its provenance fields."""
|
|
370
|
+
return {
|
|
371
|
+
"value": value(decision.value),
|
|
372
|
+
"recorded_on": decision.recorded_on.isoformat(),
|
|
373
|
+
"note": decision.note,
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _optional_decision[T](
|
|
378
|
+
decision: Decision[T] | None, value: Callable[[T], object]
|
|
379
|
+
) -> dict[str, object] | None:
|
|
380
|
+
"""A decision the schema allows to be absent."""
|
|
381
|
+
return None if decision is None else _decision(decision, value)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _optional[T](
|
|
385
|
+
value: T | None, encode: Callable[[T], dict[str, object]]
|
|
386
|
+
) -> dict[str, object] | None:
|
|
387
|
+
"""An optional nested record."""
|
|
388
|
+
return None if value is None else encode(value)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _money_value(money: Money) -> str:
|
|
392
|
+
"""A monetary amount as its exact decimal string."""
|
|
393
|
+
return str(money.amount)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _decimal_value(value: Decimal) -> str:
|
|
397
|
+
"""A decimal figure as its exact string."""
|
|
398
|
+
return str(value)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _int_value(value: int) -> int:
|
|
402
|
+
"""A whole number, natively JSON (``bool`` rejected, as ever).
|
|
403
|
+
|
|
404
|
+
Raises:
|
|
405
|
+
PersistenceError: If the value is a smuggled boolean — the
|
|
406
|
+
strict reader would refuse it on reload.
|
|
407
|
+
"""
|
|
408
|
+
if isinstance(value, bool):
|
|
409
|
+
msg = "booleans are not persisted whole numbers"
|
|
410
|
+
raise PersistenceError(msg)
|
|
411
|
+
return value
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _entity_id(entity_id: EntityId) -> str:
|
|
415
|
+
"""A stable entity id, required non-empty so the reader accepts it.
|
|
416
|
+
|
|
417
|
+
Raises:
|
|
418
|
+
PersistenceError: If the id is empty.
|
|
419
|
+
"""
|
|
420
|
+
if not entity_id:
|
|
421
|
+
msg = "entity ids must be non-empty"
|
|
422
|
+
raise PersistenceError(msg)
|
|
423
|
+
return str(entity_id)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _date_value(value: date) -> str:
|
|
427
|
+
"""A calendar date in ISO-8601 form."""
|
|
428
|
+
return value.isoformat()
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _sex_value(value: Sex) -> str:
|
|
432
|
+
"""The longevity-default sex as its stable token."""
|
|
433
|
+
return SEX_TOKENS.token(value)
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Versioned schema migrations for ``.glidepath.json`` (roadmap 6.4; §4.5).
|
|
2
|
+
|
|
3
|
+
The harness exists from day one — the accepted cost of the §4.5
|
|
4
|
+
persistence decision — so a future schema change is a registered
|
|
5
|
+
upgrader, not a file-format fork. Upgraders are keyed by the schema
|
|
6
|
+
version they *read* and each returns the document exactly one version
|
|
7
|
+
higher; loading applies them in sequence from the file's version up to
|
|
8
|
+
:data:`~glidepath.persistence.document.SCHEMA_VERSION`. A file already
|
|
9
|
+
at the current version passes through untouched.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from types import MappingProxyType
|
|
13
|
+
from typing import TYPE_CHECKING, Any
|
|
14
|
+
|
|
15
|
+
from glidepath.persistence.document import SCHEMA_VERSION, PersistenceError
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from collections.abc import Callable, Mapping
|
|
19
|
+
|
|
20
|
+
type RawDocument = dict[str, Any]
|
|
21
|
+
"""A parsed ``.glidepath.json`` document before strict decoding."""
|
|
22
|
+
|
|
23
|
+
type Upgrader = Callable[[RawDocument], RawDocument]
|
|
24
|
+
"""One registered migration: reads version *n*, returns version *n + 1*."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _upgrade_v1_to_v2(raw: RawDocument) -> RawDocument:
|
|
28
|
+
"""v2 adds ``active_membership`` to every DB pension (roadmap 9.6).
|
|
29
|
+
|
|
30
|
+
A v1 file predates active DB accrual, so every pension it holds is
|
|
31
|
+
deferred — the new key decodes as ``null``. The document is
|
|
32
|
+
upgraded in place at the parsed-JSON layer; a malformed document
|
|
33
|
+
passes through shape-checked, for the strict decoder to reject
|
|
34
|
+
with a proper path-carrying error.
|
|
35
|
+
"""
|
|
36
|
+
household = raw.get("household")
|
|
37
|
+
persons = household.get("persons") if isinstance(household, dict) else []
|
|
38
|
+
for person in persons if isinstance(persons, list) else []:
|
|
39
|
+
pensions = person.get("db_pensions") if isinstance(person, dict) else []
|
|
40
|
+
for pension in pensions if isinstance(pensions, list) else []:
|
|
41
|
+
if isinstance(pension, dict):
|
|
42
|
+
pension["active_membership"] = None
|
|
43
|
+
raw[_VERSION_KEY] = 2
|
|
44
|
+
return raw
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _upgrade_v2_to_v3(raw: RawDocument) -> RawDocument:
|
|
48
|
+
"""v3 drops the state pension qualifying-years derivation (#97).
|
|
49
|
+
|
|
50
|
+
The official DWP forecast is the only route to a state pension
|
|
51
|
+
amount, so the fields that existed solely to feed the derivation —
|
|
52
|
+
the qualifying-years count, the NI record start date, and the
|
|
53
|
+
planned extra accrual years — are removed from the record. A
|
|
54
|
+
migrated record without a forecast still loads (the deferral
|
|
55
|
+
choice is kept); projecting it fails with a clear demand for a
|
|
56
|
+
forecast, and the facts form requires one on the next save.
|
|
57
|
+
"""
|
|
58
|
+
household = raw.get("household")
|
|
59
|
+
persons = household.get("persons") if isinstance(household, dict) else []
|
|
60
|
+
for person in persons if isinstance(persons, list) else []:
|
|
61
|
+
record = person.get("state_pension") if isinstance(person, dict) else None
|
|
62
|
+
if isinstance(record, dict):
|
|
63
|
+
for key in ("ni_record_start", "qualifying_years", "planned_extra_years"):
|
|
64
|
+
record.pop(key, None)
|
|
65
|
+
raw[_VERSION_KEY] = 3
|
|
66
|
+
return raw
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _upgrade_v3_to_v4(raw: RawDocument) -> RawDocument:
|
|
70
|
+
"""v4 drops the accumulation-stage spending multipliers (#129).
|
|
71
|
+
|
|
72
|
+
Spending is modelled only in retirement, so the accumulation-stage
|
|
73
|
+
keys older builds accepted and wrote never scaled anything —
|
|
74
|
+
dropping them loses no behaviour. #114 retired the tokens from the
|
|
75
|
+
``SpendingPlan`` invariant without a migration, so a genuine v1-era
|
|
76
|
+
file carrying them failed to load until this step.
|
|
77
|
+
"""
|
|
78
|
+
household = raw.get("household")
|
|
79
|
+
spending = household.get("spending") if isinstance(household, dict) else None
|
|
80
|
+
multipliers = (
|
|
81
|
+
spending.get("stage_multipliers") if isinstance(spending, dict) else None
|
|
82
|
+
)
|
|
83
|
+
if isinstance(multipliers, dict):
|
|
84
|
+
for key in ("early_accumulation", "mid_accumulation", "pre_retirement"):
|
|
85
|
+
multipliers.pop(key, None)
|
|
86
|
+
raw[_VERSION_KEY] = 4
|
|
87
|
+
return raw
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
UPGRADERS: Mapping[int, Upgrader] = MappingProxyType(
|
|
91
|
+
{1: _upgrade_v1_to_v2, 2: _upgrade_v2_to_v3, 3: _upgrade_v3_to_v4}
|
|
92
|
+
)
|
|
93
|
+
"""The registered upgraders, keyed by the version each reads."""
|
|
94
|
+
|
|
95
|
+
_VERSION_KEY = "schema_version"
|
|
96
|
+
_FLOOR_VERSION = 1
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def document_schema_version(raw: RawDocument) -> int:
|
|
100
|
+
"""The document's declared schema version, validated.
|
|
101
|
+
|
|
102
|
+
Raises:
|
|
103
|
+
PersistenceError: If the key is missing, not a whole number, or
|
|
104
|
+
below the version-1 floor.
|
|
105
|
+
"""
|
|
106
|
+
if _VERSION_KEY not in raw:
|
|
107
|
+
msg = f"document is missing required key {_VERSION_KEY!r}"
|
|
108
|
+
raise PersistenceError(msg)
|
|
109
|
+
version = raw[_VERSION_KEY]
|
|
110
|
+
if isinstance(version, bool) or not isinstance(version, int):
|
|
111
|
+
msg = f"{_VERSION_KEY} must be a whole number, got {type(version).__name__}"
|
|
112
|
+
raise PersistenceError(msg)
|
|
113
|
+
if version < _FLOOR_VERSION:
|
|
114
|
+
msg = f"{_VERSION_KEY} must be at least {_FLOOR_VERSION}, got {version}"
|
|
115
|
+
raise PersistenceError(msg)
|
|
116
|
+
return version
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def apply_migrations(
|
|
120
|
+
raw: RawDocument,
|
|
121
|
+
*,
|
|
122
|
+
upgraders: Mapping[int, Upgrader] = UPGRADERS,
|
|
123
|
+
target: int = SCHEMA_VERSION,
|
|
124
|
+
) -> RawDocument:
|
|
125
|
+
"""Upgrade a parsed document to ``target``, one version at a time.
|
|
126
|
+
|
|
127
|
+
A document already at ``target`` is returned as-is (the v1→v1
|
|
128
|
+
no-op). ``upgraders`` and ``target`` are injectable so the harness
|
|
129
|
+
itself is testable ahead of any real schema change.
|
|
130
|
+
|
|
131
|
+
Raises:
|
|
132
|
+
PersistenceError: If the document declares a version newer than
|
|
133
|
+
``target``, no upgrader is registered for a needed step, or
|
|
134
|
+
an upgrader fails to step exactly one version.
|
|
135
|
+
"""
|
|
136
|
+
version = document_schema_version(raw)
|
|
137
|
+
if version > target:
|
|
138
|
+
msg = (
|
|
139
|
+
f"document schema version {version} is newer than this build"
|
|
140
|
+
f" reads (up to {target}); upgrade glidepath to open it"
|
|
141
|
+
)
|
|
142
|
+
raise PersistenceError(msg)
|
|
143
|
+
while version < target:
|
|
144
|
+
upgrader = upgraders.get(version)
|
|
145
|
+
if upgrader is None:
|
|
146
|
+
msg = f"no migration is registered from schema version {version}"
|
|
147
|
+
raise PersistenceError(msg)
|
|
148
|
+
raw = upgrader(raw)
|
|
149
|
+
upgraded_version = document_schema_version(raw)
|
|
150
|
+
if upgraded_version != version + 1:
|
|
151
|
+
msg = (
|
|
152
|
+
f"migration from schema version {version} produced"
|
|
153
|
+
f" version {upgraded_version}; upgraders must step"
|
|
154
|
+
" exactly one version"
|
|
155
|
+
)
|
|
156
|
+
raise PersistenceError(msg)
|
|
157
|
+
version = upgraded_version
|
|
158
|
+
return raw
|