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,579 @@
|
|
|
1
|
+
"""Scenario/Override model and resolution (roadmap 6.1; planning §4.3).
|
|
2
|
+
|
|
3
|
+
A scenario is a named list of typed :class:`Override` records over a base
|
|
4
|
+
plan — stored as deltas, so a scenario *is* its own diff and base-fact
|
|
5
|
+
corrections propagate to every what-if automatically. Targets are either
|
|
6
|
+
assumption keys or **decision variables** addressed by stable entity id +
|
|
7
|
+
field path, so overrides survive reordering and insertion.
|
|
8
|
+
|
|
9
|
+
The facts/decisions boundary is type-enforced (planning §4.3): the
|
|
10
|
+
field-path grammar names only decision variables — ``Decision[T]``
|
|
11
|
+
fields, plus the annuity purchase's plain enum choices (the record is
|
|
12
|
+
wholly a decision, §5.1) — and has no way to name a ``Fact``, so
|
|
13
|
+
nothing user-stated can be silently replaced. An override whose target
|
|
14
|
+
cannot be addressed in the plan (the entity is gone, the field path is
|
|
15
|
+
unknown, or the optional record it lives on is absent) is an *orphan*:
|
|
16
|
+
it flags the scenario invalid (:func:`scenario_orphans`) without
|
|
17
|
+
breaking anything else, until the user removes or retargets it.
|
|
18
|
+
|
|
19
|
+
Resolution (:func:`resolve_scenario`) computes effective inputs =
|
|
20
|
+
base ⊕ overrides: overridden assumptions carry ``SCENARIO_OVERRIDE``
|
|
21
|
+
provenance in-type; overridden decisions keep the base's ``recorded_on``
|
|
22
|
+
(resolution is pure — no clock reads, planning §4.6) and the resolution's
|
|
23
|
+
``applied`` record lists every override at its stable label, which is
|
|
24
|
+
where a decision override's ``SCENARIO_OVERRIDE`` provenance lives.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from collections.abc import Mapping
|
|
28
|
+
from dataclasses import dataclass, replace
|
|
29
|
+
from typing import TYPE_CHECKING, Any
|
|
30
|
+
|
|
31
|
+
from glidepath.core.provenance import (
|
|
32
|
+
Assumption,
|
|
33
|
+
AssumptionKey,
|
|
34
|
+
AssumptionSet,
|
|
35
|
+
Decision,
|
|
36
|
+
Provenance,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
if TYPE_CHECKING:
|
|
40
|
+
from glidepath.core.annuities import AnnuityPurchase
|
|
41
|
+
from glidepath.core.entities import EntityId, Household, Person, PlannedOutflow
|
|
42
|
+
from glidepath.core.pensions import DBPension
|
|
43
|
+
from glidepath.core.wrappers import Wrapper
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ScenarioError(ValueError):
|
|
47
|
+
"""A scenario that cannot be resolved against its base plan."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True, slots=True)
|
|
51
|
+
class AssumptionTarget:
|
|
52
|
+
"""An override target naming an assumption by its stable key."""
|
|
53
|
+
|
|
54
|
+
key: AssumptionKey
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True, slots=True)
|
|
58
|
+
class DecisionTarget:
|
|
59
|
+
"""An override target naming a decision variable (planning §4.3).
|
|
60
|
+
|
|
61
|
+
``entity_id`` is the persisted id of the person, wrapper, DB
|
|
62
|
+
pension, annuity purchase, or planned outflow the decision lives
|
|
63
|
+
on; ``field_path`` is the dotted path of the ``Decision`` field on
|
|
64
|
+
that entity. The addressable paths are exactly the decision
|
|
65
|
+
whitelist:
|
|
66
|
+
|
|
67
|
+
- person: ``target_retirement_age``,
|
|
68
|
+
``state_pension.deferral_years``
|
|
69
|
+
- wrapper: ``contributions.employee_amount``
|
|
70
|
+
- DB pension: ``taken_at_age``, ``commuted_fraction``,
|
|
71
|
+
``active_membership.active_until_age``
|
|
72
|
+
- annuity purchase: ``at_age``, ``fraction_of_pot``,
|
|
73
|
+
``annuity_type``, ``basis``
|
|
74
|
+
- planned outflow: ``amount_real``
|
|
75
|
+
|
|
76
|
+
Most paths name ``Decision[T]`` fields; the annuity purchase's
|
|
77
|
+
``annuity_type`` and ``basis`` are plain enum fields, addressable
|
|
78
|
+
because the record is wholly a decision (planning §5.1).
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
entity_id: EntityId
|
|
82
|
+
field_path: str
|
|
83
|
+
|
|
84
|
+
def __post_init__(self) -> None:
|
|
85
|
+
"""Reject an empty field path."""
|
|
86
|
+
if not self.field_path:
|
|
87
|
+
msg = "DecisionTarget.field_path must be non-empty"
|
|
88
|
+
raise ValueError(msg)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
type OverrideTarget = AssumptionTarget | DecisionTarget
|
|
92
|
+
"""What an override points at: an assumption key or a decision variable."""
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True, slots=True)
|
|
96
|
+
class Override:
|
|
97
|
+
"""One scenario delta: replace the target's value (planning §4.3).
|
|
98
|
+
|
|
99
|
+
``value`` must have exactly the runtime type of the base value it
|
|
100
|
+
replaces — checked at resolution, so a drifted override fails
|
|
101
|
+
loudly rather than corrupting a run. ``note`` says why ("what if I
|
|
102
|
+
retire at 60") and becomes the resolved decision's note.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
target: OverrideTarget
|
|
106
|
+
value: Any
|
|
107
|
+
note: str | None = None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass(frozen=True, slots=True)
|
|
111
|
+
class Scenario:
|
|
112
|
+
"""A named what-if: a list of overrides over the base plan (§4.3)."""
|
|
113
|
+
|
|
114
|
+
name: str
|
|
115
|
+
overrides: tuple[Override, ...] = ()
|
|
116
|
+
note: str | None = None
|
|
117
|
+
|
|
118
|
+
def __post_init__(self) -> None:
|
|
119
|
+
"""Reject an empty name and ambiguous duplicate targets."""
|
|
120
|
+
if not self.name:
|
|
121
|
+
msg = "Scenario.name must be non-empty"
|
|
122
|
+
raise ValueError(msg)
|
|
123
|
+
targets = [override.target for override in self.overrides]
|
|
124
|
+
if len(set(targets)) != len(targets):
|
|
125
|
+
msg = f"scenario {self.name!r} has two overrides on one target"
|
|
126
|
+
raise ValueError(msg)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass(frozen=True, slots=True)
|
|
130
|
+
class AppliedOverride:
|
|
131
|
+
"""One override applied during resolution, at its stable label.
|
|
132
|
+
|
|
133
|
+
The label uses the provenance grammar of
|
|
134
|
+
:func:`~glidepath.core.results.collect_plan_decisions` (e.g.
|
|
135
|
+
``person[<id>].target_retirement_age``) or the bare dotted
|
|
136
|
+
assumption key. Every applied override carries
|
|
137
|
+
``SCENARIO_OVERRIDE`` provenance (planning §4.3) — for assumptions
|
|
138
|
+
it is also stamped in-type on the resolved
|
|
139
|
+
:class:`~glidepath.core.provenance.Assumption`.
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
label: str
|
|
143
|
+
override: Override
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def provenance(self) -> Provenance:
|
|
147
|
+
"""Always ``SCENARIO_OVERRIDE`` (planning §4.3)."""
|
|
148
|
+
return Provenance.SCENARIO_OVERRIDE
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@dataclass(frozen=True, slots=True)
|
|
152
|
+
class ScenarioResolution:
|
|
153
|
+
"""Effective inputs for one scenario: base ⊕ overrides (§4.3).
|
|
154
|
+
|
|
155
|
+
``household`` and ``assumptions`` are the resolved inputs to hand
|
|
156
|
+
to the engine; ``applied`` lists every override at its stable
|
|
157
|
+
label, in the scenario's own order.
|
|
158
|
+
"""
|
|
159
|
+
|
|
160
|
+
scenario: Scenario
|
|
161
|
+
household: Household
|
|
162
|
+
assumptions: AssumptionSet
|
|
163
|
+
applied: tuple[AppliedOverride, ...]
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@dataclass(frozen=True, slots=True)
|
|
167
|
+
class DecisionTargetInfo:
|
|
168
|
+
"""One addressable decision target: its stable label and current value.
|
|
169
|
+
|
|
170
|
+
``value`` is the decision's bare value (the ``Decision`` wrapper
|
|
171
|
+
unwrapped; the annuity purchase's enum choices are already bare) —
|
|
172
|
+
what an override on this target would replace. UIs list these to
|
|
173
|
+
offer exactly the decision whitelist (planning §4.3) without
|
|
174
|
+
re-deriving it.
|
|
175
|
+
"""
|
|
176
|
+
|
|
177
|
+
target: DecisionTarget
|
|
178
|
+
label: str
|
|
179
|
+
value: Any
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def decision_target_catalogue(household: Household) -> tuple[DecisionTargetInfo, ...]:
|
|
183
|
+
"""Every addressable decision target, with its label and current value.
|
|
184
|
+
|
|
185
|
+
The same whitelist :func:`scenario_orphans` checks against, in the
|
|
186
|
+
same order — planned outflows first, then each person's targets.
|
|
187
|
+
"""
|
|
188
|
+
entities = _entities_by_id(household)
|
|
189
|
+
infos = []
|
|
190
|
+
for (entity_id, field_path), label in _decision_target_labels(household).items():
|
|
191
|
+
value: Any = entities[entity_id]
|
|
192
|
+
for segment in field_path.split("."):
|
|
193
|
+
value = getattr(value, segment)
|
|
194
|
+
if isinstance(value, Decision):
|
|
195
|
+
value = value.value
|
|
196
|
+
infos.append(
|
|
197
|
+
DecisionTargetInfo(
|
|
198
|
+
target=DecisionTarget(entity_id=entity_id, field_path=field_path),
|
|
199
|
+
label=label,
|
|
200
|
+
value=value,
|
|
201
|
+
)
|
|
202
|
+
)
|
|
203
|
+
return tuple(infos)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _entities_by_id(household: Household) -> dict[EntityId, Any]:
|
|
207
|
+
"""Every override-addressable entity in the household, by id."""
|
|
208
|
+
entities: dict[EntityId, Any] = {
|
|
209
|
+
outflow.id: outflow for outflow in household.planned_outflows
|
|
210
|
+
}
|
|
211
|
+
for person in household.persons:
|
|
212
|
+
entities[person.id] = person
|
|
213
|
+
entities.update({wrapper.id: wrapper for wrapper in person.wrappers})
|
|
214
|
+
entities.update({pension.id: pension for pension in person.db_pensions})
|
|
215
|
+
entities.update(
|
|
216
|
+
{purchase.id: purchase for purchase in person.annuity_purchases}
|
|
217
|
+
)
|
|
218
|
+
return entities
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def scenario_orphans(
|
|
222
|
+
scenario: Scenario, household: Household, assumptions: AssumptionSet
|
|
223
|
+
) -> tuple[Override, ...]:
|
|
224
|
+
"""The scenario's overrides whose targets cannot be addressed.
|
|
225
|
+
|
|
226
|
+
An orphan's target entity no longer exists, its field path is not
|
|
227
|
+
a whitelisted decision on that entity, the optional record it
|
|
228
|
+
lives on is absent (no state pension record, no contribution
|
|
229
|
+
schedule, no ``taken_at_age`` decision), or its assumption key has
|
|
230
|
+
no registered base assumption. Orphans flag the scenario invalid
|
|
231
|
+
without breaking file load (planning §4.3): callers surface them
|
|
232
|
+
for the user to remove or retarget.
|
|
233
|
+
"""
|
|
234
|
+
decision_labels = _decision_target_labels(household)
|
|
235
|
+
orphans = []
|
|
236
|
+
for override in scenario.overrides:
|
|
237
|
+
target = override.target
|
|
238
|
+
if isinstance(target, AssumptionTarget):
|
|
239
|
+
if target.key not in assumptions:
|
|
240
|
+
orphans.append(override)
|
|
241
|
+
elif (target.entity_id, target.field_path) not in decision_labels:
|
|
242
|
+
orphans.append(override)
|
|
243
|
+
return tuple(orphans)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def is_scenario_valid(
|
|
247
|
+
scenario: Scenario, household: Household, assumptions: AssumptionSet
|
|
248
|
+
) -> bool:
|
|
249
|
+
"""Whether every override target is addressable (no orphans)."""
|
|
250
|
+
return not scenario_orphans(scenario, household, assumptions)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def resolve_scenario(
|
|
254
|
+
household: Household, assumptions: AssumptionSet, scenario: Scenario
|
|
255
|
+
) -> ScenarioResolution:
|
|
256
|
+
"""Compute the scenario's effective inputs: base ⊕ overrides (§4.3).
|
|
257
|
+
|
|
258
|
+
Pure (planning §4.6): resolved decisions keep the base value's
|
|
259
|
+
``recorded_on``; resolved assumptions carry ``SCENARIO_OVERRIDE``
|
|
260
|
+
provenance with the base's default, source, and description intact.
|
|
261
|
+
The base household and assumption set are never mutated.
|
|
262
|
+
|
|
263
|
+
Raises:
|
|
264
|
+
ScenarioError: If any override is an orphan or its value's
|
|
265
|
+
runtime type differs from the base value it replaces.
|
|
266
|
+
ValueError: If an override value violates the target entity's
|
|
267
|
+
own invariants (e.g. a negative outflow amount).
|
|
268
|
+
"""
|
|
269
|
+
orphans = scenario_orphans(scenario, household, assumptions)
|
|
270
|
+
if orphans:
|
|
271
|
+
labels = ", ".join(_target_description(o.target) for o in orphans)
|
|
272
|
+
msg = f"scenario {scenario.name!r} has orphaned overrides: {labels}"
|
|
273
|
+
raise ScenarioError(msg)
|
|
274
|
+
decision_labels = _decision_target_labels(household)
|
|
275
|
+
picks: dict[tuple[EntityId, str], Override] = {}
|
|
276
|
+
applied = []
|
|
277
|
+
for override in scenario.overrides:
|
|
278
|
+
target = override.target
|
|
279
|
+
if isinstance(target, AssumptionTarget):
|
|
280
|
+
applied.append(AppliedOverride(label=target.key.value, override=override))
|
|
281
|
+
else:
|
|
282
|
+
key = (target.entity_id, target.field_path)
|
|
283
|
+
picks[key] = override
|
|
284
|
+
applied.append(
|
|
285
|
+
AppliedOverride(label=decision_labels[key], override=override)
|
|
286
|
+
)
|
|
287
|
+
resolved_household = _apply_to_household(household, picks)
|
|
288
|
+
resolved_assumptions = _apply_to_assumptions(assumptions, scenario)
|
|
289
|
+
return ScenarioResolution(
|
|
290
|
+
scenario=scenario,
|
|
291
|
+
household=resolved_household,
|
|
292
|
+
assumptions=resolved_assumptions,
|
|
293
|
+
applied=tuple(applied),
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _target_description(target: OverrideTarget) -> str:
|
|
298
|
+
"""A human-readable name for an override target in error messages."""
|
|
299
|
+
if isinstance(target, AssumptionTarget):
|
|
300
|
+
return target.key.value
|
|
301
|
+
return f"{target.entity_id}.{target.field_path}"
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _decision_target_labels(household: Household) -> dict[tuple[EntityId, str], str]:
|
|
305
|
+
"""Every addressable decision target, mapped to its stable label.
|
|
306
|
+
|
|
307
|
+
The keys are ``(entity_id, field_path)`` pairs — entity ids are
|
|
308
|
+
unique across the household (enforced by ``Household``), so a pair
|
|
309
|
+
is unambiguous. Paths through absent optional records are simply
|
|
310
|
+
not addressable and never appear.
|
|
311
|
+
"""
|
|
312
|
+
labels: dict[tuple[EntityId, str], str] = {}
|
|
313
|
+
for outflow in household.planned_outflows:
|
|
314
|
+
labels[(outflow.id, "amount_real")] = (
|
|
315
|
+
f"planned_outflow[{outflow.id}].amount_real"
|
|
316
|
+
)
|
|
317
|
+
for person in household.persons:
|
|
318
|
+
labels.update(_person_decision_target_labels(person))
|
|
319
|
+
return labels
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _person_decision_target_labels(person: Person) -> dict[tuple[EntityId, str], str]:
|
|
323
|
+
"""One person's addressable decision targets, mapped to labels."""
|
|
324
|
+
labels: dict[tuple[EntityId, str], str] = {}
|
|
325
|
+
labels[(person.id, "target_retirement_age")] = (
|
|
326
|
+
f"person[{person.id}].target_retirement_age"
|
|
327
|
+
)
|
|
328
|
+
if person.state_pension is not None:
|
|
329
|
+
labels[(person.id, "state_pension.deferral_years")] = (
|
|
330
|
+
f"person[{person.id}].state_pension.deferral_years"
|
|
331
|
+
)
|
|
332
|
+
for wrapper in person.wrappers:
|
|
333
|
+
if wrapper.contributions is not None:
|
|
334
|
+
labels[(wrapper.id, "contributions.employee_amount")] = (
|
|
335
|
+
f"wrapper[{wrapper.id}].contributions.employee_amount"
|
|
336
|
+
)
|
|
337
|
+
for pension in person.db_pensions:
|
|
338
|
+
if pension.taken_at_age is not None:
|
|
339
|
+
labels[(pension.id, "taken_at_age")] = (
|
|
340
|
+
f"db_pension[{pension.id}].taken_at_age"
|
|
341
|
+
)
|
|
342
|
+
labels[(pension.id, "commuted_fraction")] = (
|
|
343
|
+
f"db_pension[{pension.id}].commuted_fraction"
|
|
344
|
+
)
|
|
345
|
+
if (
|
|
346
|
+
pension.active_membership is not None
|
|
347
|
+
and pension.active_membership.active_until_age is not None
|
|
348
|
+
):
|
|
349
|
+
labels[(pension.id, "active_membership.active_until_age")] = (
|
|
350
|
+
f"db_pension[{pension.id}].active_membership.active_until_age"
|
|
351
|
+
)
|
|
352
|
+
for purchase in person.annuity_purchases:
|
|
353
|
+
for field in ("at_age", "fraction_of_pot", "annuity_type", "basis"):
|
|
354
|
+
labels[(purchase.id, field)] = f"annuity_purchase[{purchase.id}].{field}"
|
|
355
|
+
return labels
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _check_value_type(base_value: object, override_value: object, label: str) -> None:
|
|
359
|
+
"""Reject an override value shaped unlike the base value it replaces.
|
|
360
|
+
|
|
361
|
+
Scalars must match the base's exact runtime type (``bool`` is an
|
|
362
|
+
``int`` subtype but never a number here, §4.6 spirit). A mapping
|
|
363
|
+
base accepts *any* mapping: shipped structured defaults arrive as
|
|
364
|
+
read-only ``MappingProxyType`` views, while user overrides are
|
|
365
|
+
ordinarily plain dicts, and the structured-assumption parsers
|
|
366
|
+
accept either.
|
|
367
|
+
|
|
368
|
+
Raises:
|
|
369
|
+
ScenarioError: If the value's shape does not match.
|
|
370
|
+
"""
|
|
371
|
+
if isinstance(base_value, Mapping):
|
|
372
|
+
if isinstance(override_value, Mapping):
|
|
373
|
+
return
|
|
374
|
+
msg = (
|
|
375
|
+
f"override on {label} must hold a mapping,"
|
|
376
|
+
f" got {type(override_value).__name__}"
|
|
377
|
+
)
|
|
378
|
+
raise ScenarioError(msg)
|
|
379
|
+
if type(override_value) is not type(base_value):
|
|
380
|
+
msg = (
|
|
381
|
+
f"override on {label} must hold a {type(base_value).__name__},"
|
|
382
|
+
f" got {type(override_value).__name__}"
|
|
383
|
+
)
|
|
384
|
+
raise ScenarioError(msg)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _resolved_decision(
|
|
388
|
+
base: Decision[Any], override: Override, label: str
|
|
389
|
+
) -> Decision[Any]:
|
|
390
|
+
"""The decision with the override's value and note applied.
|
|
391
|
+
|
|
392
|
+
``recorded_on`` stays the base's — resolution reads no clock
|
|
393
|
+
(planning §4.6); the override's own provenance lives in the
|
|
394
|
+
resolution's ``applied`` record.
|
|
395
|
+
"""
|
|
396
|
+
_check_value_type(base.value, override.value, label)
|
|
397
|
+
return Decision(
|
|
398
|
+
value=override.value, recorded_on=base.recorded_on, note=override.note
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _apply_to_assumptions(
|
|
403
|
+
assumptions: AssumptionSet, scenario: Scenario
|
|
404
|
+
) -> AssumptionSet:
|
|
405
|
+
"""The assumption set with the scenario's assumption overrides applied."""
|
|
406
|
+
overrides = {
|
|
407
|
+
override.target.key: override
|
|
408
|
+
for override in scenario.overrides
|
|
409
|
+
if isinstance(override.target, AssumptionTarget)
|
|
410
|
+
}
|
|
411
|
+
if not overrides:
|
|
412
|
+
return assumptions
|
|
413
|
+
resolved = []
|
|
414
|
+
for key in assumptions.keys:
|
|
415
|
+
base = assumptions.get(key)
|
|
416
|
+
override = overrides.get(key)
|
|
417
|
+
if override is not None:
|
|
418
|
+
base = _overridden_assumption(base, override)
|
|
419
|
+
resolved.append(base)
|
|
420
|
+
return AssumptionSet(resolved)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _overridden_assumption(
|
|
424
|
+
base: Assumption[Any], override: Override
|
|
425
|
+
) -> Assumption[Any]:
|
|
426
|
+
"""The assumption re-stamped with the override's value (§4.3).
|
|
427
|
+
|
|
428
|
+
The shipped default, source, and description survive so the UI can
|
|
429
|
+
still answer "what would this have been?"; provenance becomes
|
|
430
|
+
``SCENARIO_OVERRIDE``.
|
|
431
|
+
"""
|
|
432
|
+
_check_value_type(base.value, override.value, base.key.value)
|
|
433
|
+
return replace(base, value=override.value, provenance=Provenance.SCENARIO_OVERRIDE)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _apply_to_household(
|
|
437
|
+
household: Household, picks: dict[tuple[EntityId, str], Override]
|
|
438
|
+
) -> Household:
|
|
439
|
+
"""The household with the picked decision overrides applied."""
|
|
440
|
+
changes: dict[str, Any] = {}
|
|
441
|
+
if picks:
|
|
442
|
+
changes["planned_outflows"] = tuple(
|
|
443
|
+
_apply_to_outflow(outflow, picks) for outflow in household.planned_outflows
|
|
444
|
+
)
|
|
445
|
+
changes["persons"] = tuple(
|
|
446
|
+
_apply_to_person(person, picks) for person in household.persons
|
|
447
|
+
)
|
|
448
|
+
return replace(household, **changes) if changes else household
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _apply_to_outflow(
|
|
452
|
+
outflow: PlannedOutflow, picks: dict[tuple[EntityId, str], Override]
|
|
453
|
+
) -> PlannedOutflow:
|
|
454
|
+
"""One planned outflow with its ``amount_real`` override applied."""
|
|
455
|
+
changes: dict[str, Any] = {}
|
|
456
|
+
override = picks.get((outflow.id, "amount_real"))
|
|
457
|
+
if override is not None:
|
|
458
|
+
label = f"planned_outflow[{outflow.id}].amount_real"
|
|
459
|
+
changes["amount_real"] = _resolved_decision(
|
|
460
|
+
outflow.amount_real, override, label
|
|
461
|
+
)
|
|
462
|
+
return replace(outflow, **changes) if changes else outflow
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _apply_to_person(
|
|
466
|
+
person: Person, picks: dict[tuple[EntityId, str], Override]
|
|
467
|
+
) -> Person:
|
|
468
|
+
"""One person with every decision override on them applied."""
|
|
469
|
+
changes: dict[str, Any] = {}
|
|
470
|
+
override = picks.get((person.id, "target_retirement_age"))
|
|
471
|
+
if override is not None:
|
|
472
|
+
changes["target_retirement_age"] = _resolved_decision(
|
|
473
|
+
person.target_retirement_age,
|
|
474
|
+
override,
|
|
475
|
+
f"person[{person.id}].target_retirement_age",
|
|
476
|
+
)
|
|
477
|
+
if person.state_pension is not None:
|
|
478
|
+
override = picks.get((person.id, "state_pension.deferral_years"))
|
|
479
|
+
if override is not None:
|
|
480
|
+
deferral = _resolved_decision(
|
|
481
|
+
person.state_pension.deferral_years,
|
|
482
|
+
override,
|
|
483
|
+
f"person[{person.id}].state_pension.deferral_years",
|
|
484
|
+
)
|
|
485
|
+
changes["state_pension"] = replace(
|
|
486
|
+
person.state_pension, deferral_years=deferral
|
|
487
|
+
)
|
|
488
|
+
wrappers = tuple(_apply_to_wrapper(wrapper, picks) for wrapper in person.wrappers)
|
|
489
|
+
if wrappers != person.wrappers:
|
|
490
|
+
changes["wrappers"] = wrappers
|
|
491
|
+
pensions = tuple(_apply_to_db_pension(p, picks) for p in person.db_pensions)
|
|
492
|
+
if pensions != person.db_pensions:
|
|
493
|
+
changes["db_pensions"] = pensions
|
|
494
|
+
purchases = tuple(
|
|
495
|
+
_apply_to_annuity_purchase(p, picks) for p in person.annuity_purchases
|
|
496
|
+
)
|
|
497
|
+
if purchases != person.annuity_purchases:
|
|
498
|
+
changes["annuity_purchases"] = purchases
|
|
499
|
+
return replace(person, **changes) if changes else person
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _apply_to_wrapper(
|
|
503
|
+
wrapper: Wrapper, picks: dict[tuple[EntityId, str], Override]
|
|
504
|
+
) -> Wrapper:
|
|
505
|
+
"""One wrapper with its employee-contribution override applied."""
|
|
506
|
+
changes: dict[str, Any] = {}
|
|
507
|
+
override = picks.get((wrapper.id, "contributions.employee_amount"))
|
|
508
|
+
if override is not None and wrapper.contributions is not None:
|
|
509
|
+
label = f"wrapper[{wrapper.id}].contributions.employee_amount"
|
|
510
|
+
changes["contributions"] = replace(
|
|
511
|
+
wrapper.contributions,
|
|
512
|
+
employee_amount=_resolved_decision(
|
|
513
|
+
wrapper.contributions.employee_amount, override, label
|
|
514
|
+
),
|
|
515
|
+
)
|
|
516
|
+
return replace(wrapper, **changes) if changes else wrapper
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _apply_to_db_pension(
|
|
520
|
+
pension: DBPension, picks: dict[tuple[EntityId, str], Override]
|
|
521
|
+
) -> DBPension:
|
|
522
|
+
"""One DB pension with its decision overrides applied."""
|
|
523
|
+
changes: dict[str, Any] = {}
|
|
524
|
+
override = picks.get((pension.id, "taken_at_age"))
|
|
525
|
+
if override is not None and pension.taken_at_age is not None:
|
|
526
|
+
changes["taken_at_age"] = _resolved_decision(
|
|
527
|
+
pension.taken_at_age, override, f"db_pension[{pension.id}].taken_at_age"
|
|
528
|
+
)
|
|
529
|
+
override = picks.get((pension.id, "commuted_fraction"))
|
|
530
|
+
if override is not None:
|
|
531
|
+
changes["commuted_fraction"] = _resolved_decision(
|
|
532
|
+
pension.commuted_fraction,
|
|
533
|
+
override,
|
|
534
|
+
f"db_pension[{pension.id}].commuted_fraction",
|
|
535
|
+
)
|
|
536
|
+
override = picks.get((pension.id, "active_membership.active_until_age"))
|
|
537
|
+
membership = pension.active_membership
|
|
538
|
+
if (
|
|
539
|
+
override is not None
|
|
540
|
+
and membership is not None
|
|
541
|
+
and membership.active_until_age is not None
|
|
542
|
+
):
|
|
543
|
+
changes["active_membership"] = replace(
|
|
544
|
+
membership,
|
|
545
|
+
active_until_age=_resolved_decision(
|
|
546
|
+
membership.active_until_age,
|
|
547
|
+
override,
|
|
548
|
+
f"db_pension[{pension.id}].active_membership.active_until_age",
|
|
549
|
+
),
|
|
550
|
+
)
|
|
551
|
+
return replace(pension, **changes) if changes else pension
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def _apply_to_annuity_purchase(
|
|
555
|
+
purchase: AnnuityPurchase, picks: dict[tuple[EntityId, str], Override]
|
|
556
|
+
) -> AnnuityPurchase:
|
|
557
|
+
"""One annuity purchase with its decision overrides applied.
|
|
558
|
+
|
|
559
|
+
``annuity_type`` and ``basis`` are plain enum fields — the whole
|
|
560
|
+
record is a decision (planning §5.1) — so their overrides replace
|
|
561
|
+
the value directly; the override's note lives only in the
|
|
562
|
+
resolution's ``applied`` record.
|
|
563
|
+
"""
|
|
564
|
+
changes: dict[str, Any] = {}
|
|
565
|
+
for field in ("at_age", "fraction_of_pot"):
|
|
566
|
+
override = picks.get((purchase.id, field))
|
|
567
|
+
if override is not None:
|
|
568
|
+
changes[field] = _resolved_decision(
|
|
569
|
+
getattr(purchase, field),
|
|
570
|
+
override,
|
|
571
|
+
f"annuity_purchase[{purchase.id}].{field}",
|
|
572
|
+
)
|
|
573
|
+
for field in ("annuity_type", "basis"):
|
|
574
|
+
override = picks.get((purchase.id, field))
|
|
575
|
+
if override is not None:
|
|
576
|
+
label = f"annuity_purchase[{purchase.id}].{field}"
|
|
577
|
+
_check_value_type(getattr(purchase, field), override.value, label)
|
|
578
|
+
changes[field] = override.value
|
|
579
|
+
return replace(purchase, **changes) if changes else purchase
|