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,264 @@
|
|
|
1
|
+
"""Household and person entities (planning §4.4, §5.1 skeleton).
|
|
2
|
+
|
|
3
|
+
The schema models ``Household{persons: 1..2}`` now — UK tax is individual,
|
|
4
|
+
so computation is per-person anyway, and placing shared economics at
|
|
5
|
+
household level avoids a schema + engine migration when couples activate
|
|
6
|
+
(roadmap 9.4). v1 validates exactly one person via
|
|
7
|
+
:func:`validate_household_v1`.
|
|
8
|
+
|
|
9
|
+
Wrappers attach to :class:`Person` as of roadmap 3.1, the glide-path
|
|
10
|
+
config as of 3.5, household spending as of 4.1, DB pensions and state
|
|
11
|
+
pension records as of 4.2/4.3, and household planned outflows as of 5.4.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import uuid
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from decimal import Decimal
|
|
17
|
+
from enum import Enum, auto
|
|
18
|
+
from typing import TYPE_CHECKING, NewType
|
|
19
|
+
|
|
20
|
+
from glidepath.core.glide import LifeStage
|
|
21
|
+
from glidepath.core.money import Money
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from collections.abc import Mapping
|
|
25
|
+
from datetime import date
|
|
26
|
+
|
|
27
|
+
from glidepath.core.annuities import AnnuityPurchase
|
|
28
|
+
from glidepath.core.glide import GlidePathConfig
|
|
29
|
+
from glidepath.core.pensions import DBPension
|
|
30
|
+
from glidepath.core.provenance import Decision, Fact
|
|
31
|
+
from glidepath.core.state_pension import StatePensionRecord
|
|
32
|
+
from glidepath.core.wrappers import Wrapper
|
|
33
|
+
|
|
34
|
+
EntityId = NewType("EntityId", str)
|
|
35
|
+
"""Stable persisted identifier.
|
|
36
|
+
|
|
37
|
+
Scenario overrides target entities by id + field path (planning §4.3), so
|
|
38
|
+
ids must survive reordering and insertion; couples support needs them too.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
TaxResidencyId = NewType("TaxResidencyId", str)
|
|
42
|
+
"""Opaque region-defined residency id (e.g. ``"uk.ruk"``, ``"uk.scotland"``).
|
|
43
|
+
|
|
44
|
+
The core never interprets it; the region's tax system does (planning §4.2).
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
_MIN_PERSONS = 1
|
|
48
|
+
_MAX_PERSONS = 2
|
|
49
|
+
_ZERO = Money(Decimal(0))
|
|
50
|
+
_ZERO_MULTIPLIER = Decimal(0)
|
|
51
|
+
_RETIREMENT_STAGES = frozenset(
|
|
52
|
+
{LifeStage.DECUMULATION, LifeStage.GO_GO, LifeStage.SLOW_GO, LifeStage.NO_GO}
|
|
53
|
+
)
|
|
54
|
+
"""The spending-multiplier keys reachable in retirement (planning §5.1)."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def new_entity_id() -> EntityId:
|
|
58
|
+
"""Generate a fresh stable id.
|
|
59
|
+
|
|
60
|
+
For plan-edit time only — the engine itself never creates entities
|
|
61
|
+
during a run (planning §4.6 purity).
|
|
62
|
+
"""
|
|
63
|
+
return EntityId(str(uuid.uuid4()))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class Sex(Enum):
|
|
67
|
+
"""Sex used solely for longevity defaults (planning §5.1)."""
|
|
68
|
+
|
|
69
|
+
FEMALE = auto()
|
|
70
|
+
MALE = auto()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True, slots=True)
|
|
74
|
+
class Person:
|
|
75
|
+
"""One person in a household (planning §5.1, Phase 1 skeleton).
|
|
76
|
+
|
|
77
|
+
Everything taxed or age-gated hangs off a person; shared economics
|
|
78
|
+
hang off the household (planning §4.4).
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
id: EntityId
|
|
82
|
+
date_of_birth: Fact[date]
|
|
83
|
+
target_retirement_age: Decision[int]
|
|
84
|
+
tax_residency: TaxResidencyId
|
|
85
|
+
sex_for_longevity: Fact[Sex] | None = None
|
|
86
|
+
employment_income: Fact[Money] | None = None
|
|
87
|
+
mpaa_triggered_on: Fact[date] | None = None
|
|
88
|
+
"""Date pension benefits were first flexibly accessed, if ever.
|
|
89
|
+
|
|
90
|
+
A pre-plan fact (planning §5.1): once set, the region's
|
|
91
|
+
money-purchase contribution limit applies from that date on
|
|
92
|
+
(roadmap 3.3). An in-plan first flexible access records the
|
|
93
|
+
trigger in the period results instead (roadmap 5.2); when this
|
|
94
|
+
fact is present it wins.
|
|
95
|
+
"""
|
|
96
|
+
lsa_used: Fact[Money] | None = None
|
|
97
|
+
"""Tax-free lump sum allowance already used before the plan.
|
|
98
|
+
|
|
99
|
+
A pre-plan fact (planning §5.1): the run's tax-free-cash ledger is
|
|
100
|
+
seeded with it, reducing the headroom under the region's lifetime
|
|
101
|
+
cap (roadmap 5.2). ``None`` means none used.
|
|
102
|
+
"""
|
|
103
|
+
wrappers: tuple[Wrapper, ...] = ()
|
|
104
|
+
db_pensions: tuple[DBPension, ...] = ()
|
|
105
|
+
"""DB entitlements — deferred or actively accruing (roadmap 4.2, 9.6)."""
|
|
106
|
+
annuity_purchases: tuple[AnnuityPurchase, ...] = ()
|
|
107
|
+
"""Planned annuity purchases — decision records (roadmap 5.5)."""
|
|
108
|
+
state_pension: StatePensionRecord | None = None
|
|
109
|
+
"""This person's state pension record (roadmap 4.3).
|
|
110
|
+
|
|
111
|
+
``None`` means no state pension is modelled for this person.
|
|
112
|
+
"""
|
|
113
|
+
glide_path: GlidePathConfig | None = None
|
|
114
|
+
"""This person's glide path, if they overrode the default.
|
|
115
|
+
|
|
116
|
+
``None`` means the ``glidepath.default_shape`` assumption supplies
|
|
117
|
+
the factor table (planning §7, roadmap 3.5).
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
def __post_init__(self) -> None:
|
|
121
|
+
"""Require distinct entity ids (they are override targets, §4.3)."""
|
|
122
|
+
ids = [wrapper.id for wrapper in self.wrappers]
|
|
123
|
+
ids += [pension.id for pension in self.db_pensions]
|
|
124
|
+
ids += [purchase.id for purchase in self.annuity_purchases]
|
|
125
|
+
if len(set(ids)) != len(ids):
|
|
126
|
+
msg = (
|
|
127
|
+
"a person's wrappers, DB pensions, and annuity purchases"
|
|
128
|
+
" must have distinct EntityIds"
|
|
129
|
+
)
|
|
130
|
+
raise ValueError(msg)
|
|
131
|
+
if self.lsa_used is not None and self.lsa_used.value < _ZERO:
|
|
132
|
+
msg = "Person.lsa_used must be non-negative"
|
|
133
|
+
raise ValueError(msg)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass(frozen=True, slots=True)
|
|
137
|
+
class SpendingPlan:
|
|
138
|
+
"""The household's retirement spending need (planning §5.1, §5.2).
|
|
139
|
+
|
|
140
|
+
``annual_spending_real`` is a *net* (after-tax) need in today's
|
|
141
|
+
money — the engine inflates it by the run's CPI path and grosses
|
|
142
|
+
withdrawals up against the tax system (planning §5.2 step 4).
|
|
143
|
+
``stage_multipliers`` optionally scales the need across retirement
|
|
144
|
+
(planning §5.1): the go-go/slow-go/no-go sub-stage keys bind to
|
|
145
|
+
their decades, ``DECUMULATION`` covers any sub-stage without its
|
|
146
|
+
own key, and an absent stage means a multiplier of 1. Spending is
|
|
147
|
+
modelled only in retirement, so accumulation-stage keys — which
|
|
148
|
+
could never bind — are rejected rather than silently ignored
|
|
149
|
+
(issue #114).
|
|
150
|
+
"""
|
|
151
|
+
|
|
152
|
+
annual_spending_real: Fact[Money]
|
|
153
|
+
stage_multipliers: Mapping[LifeStage, Decimal] | None = None
|
|
154
|
+
|
|
155
|
+
def __post_init__(self) -> None:
|
|
156
|
+
"""Reject negative spending and unusable multipliers."""
|
|
157
|
+
if self.annual_spending_real.value < _ZERO:
|
|
158
|
+
msg = "SpendingPlan.annual_spending_real must be non-negative"
|
|
159
|
+
raise ValueError(msg)
|
|
160
|
+
multipliers = self.stage_multipliers or {}
|
|
161
|
+
if any(value <= _ZERO_MULTIPLIER for value in multipliers.values()):
|
|
162
|
+
msg = "SpendingPlan.stage_multipliers must be positive"
|
|
163
|
+
raise ValueError(msg)
|
|
164
|
+
unusable = set(multipliers) - _RETIREMENT_STAGES
|
|
165
|
+
if unusable:
|
|
166
|
+
names = ", ".join(sorted(stage.name for stage in unusable))
|
|
167
|
+
msg = (
|
|
168
|
+
"SpendingPlan.stage_multipliers bind only in retirement"
|
|
169
|
+
" (GO_GO, SLOW_GO, NO_GO, or DECUMULATION for the whole);"
|
|
170
|
+
f" got {names}"
|
|
171
|
+
)
|
|
172
|
+
raise ValueError(msg)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@dataclass(frozen=True, slots=True)
|
|
176
|
+
class PlannedOutflow:
|
|
177
|
+
"""One dated one-off outflow — wholly a decision (planning §5.1).
|
|
178
|
+
|
|
179
|
+
A mortgage payoff, gift, or purchase: a *net* cash need on top of
|
|
180
|
+
the spending plan, hitting the period in which the referenced
|
|
181
|
+
person attains the stated age and funded tax-aware through the
|
|
182
|
+
withdrawal machinery (roadmap 5.4). ``amount_real`` is in today's
|
|
183
|
+
money; the engine inflates it by the run's CPI path.
|
|
184
|
+
"""
|
|
185
|
+
|
|
186
|
+
id: EntityId
|
|
187
|
+
label: str
|
|
188
|
+
amount_real: Decision[Money]
|
|
189
|
+
at_age_of: tuple[EntityId, int]
|
|
190
|
+
|
|
191
|
+
def __post_init__(self) -> None:
|
|
192
|
+
"""Reject a negative amount or age."""
|
|
193
|
+
if self.amount_real.value < _ZERO:
|
|
194
|
+
msg = "PlannedOutflow.amount_real must be non-negative"
|
|
195
|
+
raise ValueError(msg)
|
|
196
|
+
if self.at_age_of[1] < 0:
|
|
197
|
+
msg = "PlannedOutflow.at_age_of age must be non-negative"
|
|
198
|
+
raise ValueError(msg)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@dataclass(frozen=True, slots=True)
|
|
202
|
+
class Household:
|
|
203
|
+
"""One or two persons plus shared economics (planning §4.4, §5.1).
|
|
204
|
+
|
|
205
|
+
The 1..2 bound is the schema-level invariant (planning §4.4); the
|
|
206
|
+
stricter v1 single-person rule is :func:`validate_household_v1`.
|
|
207
|
+
``spending`` is the household-level retirement spending need;
|
|
208
|
+
``None`` means decumulation spending withdrawals are not modelled.
|
|
209
|
+
``planned_outflows`` are household-level dated one-offs (roadmap
|
|
210
|
+
5.4), funded through the withdrawal machinery whether or not a
|
|
211
|
+
spending plan is present.
|
|
212
|
+
"""
|
|
213
|
+
|
|
214
|
+
persons: tuple[Person, ...]
|
|
215
|
+
spending: SpendingPlan | None = None
|
|
216
|
+
planned_outflows: tuple[PlannedOutflow, ...] = ()
|
|
217
|
+
|
|
218
|
+
def __post_init__(self) -> None:
|
|
219
|
+
"""Enforce the 1..2 bound, distinct entity ids, and outflow targets.
|
|
220
|
+
|
|
221
|
+
Scenario overrides target entities by id + field path (planning
|
|
222
|
+
§4.3), so ids must be unambiguous across the whole household —
|
|
223
|
+
two persons' wrappers, DB pensions, annuity purchases, or
|
|
224
|
+
planned outflows may not share an id, nor may any share one
|
|
225
|
+
with a person. A planned outflow must reference a person in
|
|
226
|
+
this household.
|
|
227
|
+
"""
|
|
228
|
+
if not _MIN_PERSONS <= len(self.persons) <= _MAX_PERSONS:
|
|
229
|
+
msg = f"a household holds 1 or 2 persons, got {len(self.persons)}"
|
|
230
|
+
raise ValueError(msg)
|
|
231
|
+
ids = [person.id for person in self.persons]
|
|
232
|
+
ids += [wrapper.id for person in self.persons for wrapper in person.wrappers]
|
|
233
|
+
ids += [pension.id for person in self.persons for pension in person.db_pensions]
|
|
234
|
+
ids += [
|
|
235
|
+
purchase.id
|
|
236
|
+
for person in self.persons
|
|
237
|
+
for purchase in person.annuity_purchases
|
|
238
|
+
]
|
|
239
|
+
ids += [outflow.id for outflow in self.planned_outflows]
|
|
240
|
+
if len(set(ids)) != len(ids):
|
|
241
|
+
msg = (
|
|
242
|
+
"household entities (persons, wrappers, DB pensions, annuity"
|
|
243
|
+
" purchases, planned outflows) must have distinct EntityIds"
|
|
244
|
+
)
|
|
245
|
+
raise ValueError(msg)
|
|
246
|
+
person_ids = {person.id for person in self.persons}
|
|
247
|
+
for outflow in self.planned_outflows:
|
|
248
|
+
if outflow.at_age_of[0] not in person_ids:
|
|
249
|
+
msg = (
|
|
250
|
+
f"planned outflow {outflow.id} references person"
|
|
251
|
+
f" {outflow.at_age_of[0]}, who is not in this household"
|
|
252
|
+
)
|
|
253
|
+
raise ValueError(msg)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def validate_household_v1(household: Household) -> None:
|
|
257
|
+
"""Enforce the v1 single-person restriction (planning §4.4).
|
|
258
|
+
|
|
259
|
+
Raises:
|
|
260
|
+
ValueError: If the household does not hold exactly one person.
|
|
261
|
+
"""
|
|
262
|
+
if len(household.persons) != _MIN_PERSONS:
|
|
263
|
+
msg = "v1 supports exactly one person per household (planning §4.4)"
|
|
264
|
+
raise ValueError(msg)
|
glidepath/core/glide.py
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""Life stages and glide-path allocation (roadmap 3.5; planning §5.1, §7).
|
|
2
|
+
|
|
3
|
+
The projection moves a person through ``EARLY_ACCUMULATION →
|
|
4
|
+
MID_ACCUMULATION → PRE_RETIREMENT → GO_GO → SLOW_GO → NO_GO``. Stage is
|
|
5
|
+
*derived* each period from years-to-target-retirement — never stored —
|
|
6
|
+
and the glide path maps years-to-retirement to an asset allocation by
|
|
7
|
+
interpolating a factor table (:class:`GlidePathConfig`).
|
|
8
|
+
|
|
9
|
+
Stage boundaries (planning §5.1): retirement — the target retirement
|
|
10
|
+
age attained by the period's first day (years-to-retirement ≤ 0,
|
|
11
|
+
matching the §4.1 gate convention) — splits into the go-go/slow-go/no-go
|
|
12
|
+
sub-stages at one and two decades in (the retirement-smile convention;
|
|
13
|
+
``DECUMULATION`` remains their umbrella for whole-retirement spending
|
|
14
|
+
multipliers); ``PRE_RETIREMENT`` inside the table's de-risking window —
|
|
15
|
+
the years at which the allocation starts changing (zero for a constant
|
|
16
|
+
table, which never de-risks); the ``EARLY`` / ``MID`` split falls at
|
|
17
|
+
twice that window.
|
|
18
|
+
|
|
19
|
+
The default shape ships as the ``glidepath.default_shape`` assumption
|
|
20
|
+
(planning §7), overridable per person; :func:`glide_path_from_shape`
|
|
21
|
+
turns that structured value into a config.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from decimal import Decimal
|
|
26
|
+
from enum import Enum, auto
|
|
27
|
+
from itertools import pairwise
|
|
28
|
+
from typing import TYPE_CHECKING
|
|
29
|
+
|
|
30
|
+
from glidepath.core.investments import AssetAllocation
|
|
31
|
+
from glidepath.core.periods import age_on
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING:
|
|
34
|
+
from collections.abc import Mapping
|
|
35
|
+
from datetime import date
|
|
36
|
+
|
|
37
|
+
from glidepath.core.periods import Period
|
|
38
|
+
|
|
39
|
+
_ZERO = Decimal(0)
|
|
40
|
+
_ONE = Decimal(1)
|
|
41
|
+
|
|
42
|
+
_SHAPE_LINEAR = "linear"
|
|
43
|
+
_SHAPE_HOLD = "hold"
|
|
44
|
+
|
|
45
|
+
_RETIREMENT_STAGE_YEARS = 10
|
|
46
|
+
"""Decade width of the go-go/slow-go retirement sub-stages (planning §5.1)."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class LifeStage(Enum):
|
|
50
|
+
"""The stages a person moves through (planning §5.1); always derived."""
|
|
51
|
+
|
|
52
|
+
EARLY_ACCUMULATION = auto()
|
|
53
|
+
MID_ACCUMULATION = auto()
|
|
54
|
+
PRE_RETIREMENT = auto()
|
|
55
|
+
"""Inside the glide path's de-risking window."""
|
|
56
|
+
DECUMULATION = auto()
|
|
57
|
+
"""Retirement as a whole — the go-go/slow-go/no-go umbrella.
|
|
58
|
+
|
|
59
|
+
Never derived (the sub-stages partition retirement); retained as
|
|
60
|
+
the whole-retirement spending-multiplier key (planning §5.1).
|
|
61
|
+
"""
|
|
62
|
+
GO_GO = auto()
|
|
63
|
+
"""The first decade with the retirement age attained (§4.1 gate)."""
|
|
64
|
+
SLOW_GO = auto()
|
|
65
|
+
"""The second decade of retirement."""
|
|
66
|
+
NO_GO = auto()
|
|
67
|
+
"""Retirement beyond its second decade."""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True, slots=True)
|
|
71
|
+
class GlidePathPoint:
|
|
72
|
+
"""One knot of the factor table: the allocation held at this distance."""
|
|
73
|
+
|
|
74
|
+
years_to_retirement: int
|
|
75
|
+
allocation: AssetAllocation
|
|
76
|
+
|
|
77
|
+
def __post_init__(self) -> None:
|
|
78
|
+
"""Reject knots after retirement; the 0 knot holds through drawdown."""
|
|
79
|
+
if self.years_to_retirement < 0:
|
|
80
|
+
msg = "GlidePathPoint.years_to_retirement must be non-negative"
|
|
81
|
+
raise ValueError(msg)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True, slots=True)
|
|
85
|
+
class GlidePathConfig:
|
|
86
|
+
"""A years-to-retirement → allocation factor table (planning §5.1).
|
|
87
|
+
|
|
88
|
+
Knots are strictly ascending by ``years_to_retirement``. Between
|
|
89
|
+
knots the allocation interpolates linearly per asset class; beyond
|
|
90
|
+
the highest knot it holds that knot's allocation, and at or past
|
|
91
|
+
retirement it holds the lowest knot's — the "held through drawdown"
|
|
92
|
+
behaviour of the default shape (planning §7).
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
points: tuple[GlidePathPoint, ...]
|
|
96
|
+
|
|
97
|
+
def __post_init__(self) -> None:
|
|
98
|
+
"""Require at least one knot, strictly ascending."""
|
|
99
|
+
if not self.points:
|
|
100
|
+
msg = "GlidePathConfig requires at least one point"
|
|
101
|
+
raise ValueError(msg)
|
|
102
|
+
ascending = all(
|
|
103
|
+
lower.years_to_retirement < upper.years_to_retirement
|
|
104
|
+
for lower, upper in pairwise(self.points)
|
|
105
|
+
)
|
|
106
|
+
if not ascending:
|
|
107
|
+
msg = "GlidePathConfig points must strictly ascend by years_to_retirement"
|
|
108
|
+
raise ValueError(msg)
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def derisk_window_years(self) -> int:
|
|
112
|
+
"""Years before retirement at which the allocation starts changing.
|
|
113
|
+
|
|
114
|
+
The lowest knot of the top plateau — the run of highest knots
|
|
115
|
+
sharing the final allocation — since above it the table is
|
|
116
|
+
constant. Zero when every knot holds the same allocation: a
|
|
117
|
+
constant table never de-risks.
|
|
118
|
+
"""
|
|
119
|
+
top = self.points[-1].allocation
|
|
120
|
+
window = self.points[-1].years_to_retirement
|
|
121
|
+
for point in reversed(self.points[:-1]):
|
|
122
|
+
if point.allocation != top:
|
|
123
|
+
return window
|
|
124
|
+
window = point.years_to_retirement
|
|
125
|
+
return 0
|
|
126
|
+
|
|
127
|
+
def allocation_at(self, years_to_retirement: int) -> AssetAllocation:
|
|
128
|
+
"""The allocation held at ``years_to_retirement`` (may be negative).
|
|
129
|
+
|
|
130
|
+
Clamps beyond the table at both ends; interpolates linearly
|
|
131
|
+
between knots.
|
|
132
|
+
"""
|
|
133
|
+
first, last = self.points[0], self.points[-1]
|
|
134
|
+
if years_to_retirement <= first.years_to_retirement:
|
|
135
|
+
return first.allocation
|
|
136
|
+
if years_to_retirement >= last.years_to_retirement:
|
|
137
|
+
return last.allocation
|
|
138
|
+
lower, upper = next(
|
|
139
|
+
pair
|
|
140
|
+
for pair in pairwise(self.points)
|
|
141
|
+
if years_to_retirement <= pair[1].years_to_retirement
|
|
142
|
+
)
|
|
143
|
+
return _interpolate(lower, upper, years_to_retirement)
|
|
144
|
+
|
|
145
|
+
def stage_at(self, years_to_retirement: int) -> LifeStage:
|
|
146
|
+
"""Derive the life stage at ``years_to_retirement`` (planning §5.1).
|
|
147
|
+
|
|
148
|
+
A constant-allocation table (a single knot, or knots all holding
|
|
149
|
+
the same allocation) has a zero de-risking window, so
|
|
150
|
+
``PRE_RETIREMENT`` is unreachable and accumulation runs straight
|
|
151
|
+
into retirement's go-go sub-stage.
|
|
152
|
+
"""
|
|
153
|
+
if years_to_retirement <= 0:
|
|
154
|
+
return _retirement_stage_at(years_to_retirement)
|
|
155
|
+
window = self.derisk_window_years
|
|
156
|
+
if years_to_retirement <= window:
|
|
157
|
+
return LifeStage.PRE_RETIREMENT
|
|
158
|
+
if years_to_retirement <= 2 * window:
|
|
159
|
+
return LifeStage.MID_ACCUMULATION
|
|
160
|
+
return LifeStage.EARLY_ACCUMULATION
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _retirement_stage_at(years_to_retirement: int) -> LifeStage:
|
|
164
|
+
"""The go-go/slow-go/no-go sub-stage once retirement is attained.
|
|
165
|
+
|
|
166
|
+
Sub-stage boundaries fall one and two decades into retirement —
|
|
167
|
+
for typical retirement ages this lands on the 75/85 boundaries the
|
|
168
|
+
retirement-smile literature uses. Like the ``EARLY``/``MID`` split,
|
|
169
|
+
only the spending multipliers bind to the result (planning §5.1),
|
|
170
|
+
so a simple decade rule suffices.
|
|
171
|
+
"""
|
|
172
|
+
years_retired = -years_to_retirement
|
|
173
|
+
if years_retired < _RETIREMENT_STAGE_YEARS:
|
|
174
|
+
return LifeStage.GO_GO
|
|
175
|
+
if years_retired < 2 * _RETIREMENT_STAGE_YEARS:
|
|
176
|
+
return LifeStage.SLOW_GO
|
|
177
|
+
return LifeStage.NO_GO
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _interpolate(
|
|
181
|
+
lower: GlidePathPoint, upper: GlidePathPoint, years_to_retirement: int
|
|
182
|
+
) -> AssetAllocation:
|
|
183
|
+
"""Linearly interpolate between two knots at an interior year.
|
|
184
|
+
|
|
185
|
+
The fraction and weights carry full context precision — factors are
|
|
186
|
+
never explicitly quantized (planning §4.6, the same convention as
|
|
187
|
+
``prorata_fraction``). Cash is derived as the residual so the
|
|
188
|
+
weights sum to exactly 1 despite any rounding at context precision;
|
|
189
|
+
interior interpolants sit far enough inside [0, 1] that the residual
|
|
190
|
+
cannot go negative for a valid table.
|
|
191
|
+
"""
|
|
192
|
+
span = upper.years_to_retirement - lower.years_to_retirement
|
|
193
|
+
offset = years_to_retirement - lower.years_to_retirement
|
|
194
|
+
fraction = Decimal(offset) / Decimal(span)
|
|
195
|
+
|
|
196
|
+
def weight(start: Decimal, end: Decimal) -> Decimal:
|
|
197
|
+
"""One class's weight at ``fraction`` of the way from lower to upper."""
|
|
198
|
+
return start + (end - start) * fraction
|
|
199
|
+
|
|
200
|
+
equity = weight(lower.allocation.equity, upper.allocation.equity)
|
|
201
|
+
bonds = weight(lower.allocation.bonds, upper.allocation.bonds)
|
|
202
|
+
return AssetAllocation(equity=equity, bonds=bonds, cash=_ONE - equity - bonds)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def years_to_target_retirement(
|
|
206
|
+
date_of_birth: date, target_retirement_age: int, period: Period
|
|
207
|
+
) -> int:
|
|
208
|
+
"""Whole years from ``period``'s first day to the target retirement age.
|
|
209
|
+
|
|
210
|
+
Zero or negative once the age is attained by the period's first day
|
|
211
|
+
— the same gate convention as §4.1, so the retirement period itself
|
|
212
|
+
is already ``DECUMULATION``.
|
|
213
|
+
"""
|
|
214
|
+
return target_retirement_age - age_on(date_of_birth, period.start)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _shape_fraction(shape: Mapping[str, object], key: str) -> Decimal:
|
|
218
|
+
"""Take a required fraction in [0, 1] from the shape mapping."""
|
|
219
|
+
raw = shape[key]
|
|
220
|
+
if not isinstance(raw, Decimal):
|
|
221
|
+
msg = f"glide-path shape {key!r} must be a Decimal fraction"
|
|
222
|
+
raise TypeError(msg)
|
|
223
|
+
if not _ZERO <= raw <= _ONE:
|
|
224
|
+
msg = f"glide-path shape {key!r} must lie between 0 and 1"
|
|
225
|
+
raise ValueError(msg)
|
|
226
|
+
return raw
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _shape_tag(shape: Mapping[str, object], key: str, supported: str) -> None:
|
|
230
|
+
"""Require the shape tag ``key`` to hold the one supported value."""
|
|
231
|
+
raw = shape[key]
|
|
232
|
+
if raw != supported:
|
|
233
|
+
msg = f"glide-path shape {key!r} supports only {supported!r}, got {raw!r}"
|
|
234
|
+
raise ValueError(msg)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def glide_path_from_shape(shape: Mapping[str, object]) -> GlidePathConfig:
|
|
238
|
+
"""Build a config from the ``glidepath.default_shape`` value (planning §7).
|
|
239
|
+
|
|
240
|
+
The shape is the structured assumption value: ``equity_start`` held
|
|
241
|
+
until ``derisk_years_before_retirement`` years out, then a
|
|
242
|
+
``linear`` transition to ``equity_at_retirement``, ``hold`` through
|
|
243
|
+
drawdown — the remainder of each allocation in bonds. Only those
|
|
244
|
+
tags are supported; anything else here is a data error, not a
|
|
245
|
+
default.
|
|
246
|
+
|
|
247
|
+
Raises:
|
|
248
|
+
KeyError: If a required shape key is missing.
|
|
249
|
+
TypeError: If a shape value has the wrong type.
|
|
250
|
+
ValueError: If keys are unknown or values out of range.
|
|
251
|
+
"""
|
|
252
|
+
expected = {
|
|
253
|
+
"equity_start",
|
|
254
|
+
"derisk_years_before_retirement",
|
|
255
|
+
"equity_at_retirement",
|
|
256
|
+
"transition",
|
|
257
|
+
"in_drawdown",
|
|
258
|
+
}
|
|
259
|
+
unknown = set(shape) - expected
|
|
260
|
+
if unknown:
|
|
261
|
+
msg = f"unknown glide-path shape keys: {', '.join(sorted(unknown))}"
|
|
262
|
+
raise ValueError(msg)
|
|
263
|
+
equity_start = _shape_fraction(shape, "equity_start")
|
|
264
|
+
equity_at_retirement = _shape_fraction(shape, "equity_at_retirement")
|
|
265
|
+
_shape_tag(shape, "transition", _SHAPE_LINEAR)
|
|
266
|
+
_shape_tag(shape, "in_drawdown", _SHAPE_HOLD)
|
|
267
|
+
derisk_years = shape["derisk_years_before_retirement"]
|
|
268
|
+
if isinstance(derisk_years, bool) or not isinstance(derisk_years, int):
|
|
269
|
+
msg = "glide-path shape 'derisk_years_before_retirement' must be an integer"
|
|
270
|
+
raise TypeError(msg)
|
|
271
|
+
if derisk_years < 1:
|
|
272
|
+
msg = "glide-path shape 'derisk_years_before_retirement' must be at least 1"
|
|
273
|
+
raise ValueError(msg)
|
|
274
|
+
return GlidePathConfig(
|
|
275
|
+
points=(
|
|
276
|
+
GlidePathPoint(
|
|
277
|
+
years_to_retirement=0,
|
|
278
|
+
allocation=AssetAllocation(
|
|
279
|
+
equity=equity_at_retirement, bonds=_ONE - equity_at_retirement
|
|
280
|
+
),
|
|
281
|
+
),
|
|
282
|
+
GlidePathPoint(
|
|
283
|
+
years_to_retirement=derisk_years,
|
|
284
|
+
allocation=AssetAllocation(
|
|
285
|
+
equity=equity_start, bonds=_ONE - equity_start
|
|
286
|
+
),
|
|
287
|
+
),
|
|
288
|
+
)
|
|
289
|
+
)
|