glidepath 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. glidepath/__init__.py +3 -0
  2. glidepath/app/__init__.py +364 -0
  3. glidepath/app/backtest.py +281 -0
  4. glidepath/app/charts.py +759 -0
  5. glidepath/app/copy.py +174 -0
  6. glidepath/app/display.py +148 -0
  7. glidepath/app/drawdown.py +436 -0
  8. glidepath/app/example.py +66 -0
  9. glidepath/app/exports.py +487 -0
  10. glidepath/app/files.py +249 -0
  11. glidepath/app/firstrun.py +114 -0
  12. glidepath/app/forms.py +1750 -0
  13. glidepath/app/inspector.py +506 -0
  14. glidepath/app/labels.py +66 -0
  15. glidepath/app/montecarlo.py +399 -0
  16. glidepath/app/plan.py +354 -0
  17. glidepath/app/retirement.py +446 -0
  18. glidepath/app/scenarios.py +831 -0
  19. glidepath/app/shell.py +185 -0
  20. glidepath/app/tables.py +138 -0
  21. glidepath/core/__init__.py +390 -0
  22. glidepath/core/annuities.py +240 -0
  23. glidepath/core/backtest.py +514 -0
  24. glidepath/core/comparison.py +278 -0
  25. glidepath/core/config.py +82 -0
  26. glidepath/core/contributions.py +337 -0
  27. glidepath/core/engine.py +2811 -0
  28. glidepath/core/entities.py +264 -0
  29. glidepath/core/glide.py +289 -0
  30. glidepath/core/investments.py +175 -0
  31. glidepath/core/money.py +107 -0
  32. glidepath/core/montecarlo.py +609 -0
  33. glidepath/core/pensions.py +298 -0
  34. glidepath/core/periods.py +367 -0
  35. glidepath/core/provenance.py +271 -0
  36. glidepath/core/randomness.py +128 -0
  37. glidepath/core/region.py +46 -0
  38. glidepath/core/reporting.py +231 -0
  39. glidepath/core/results.py +504 -0
  40. glidepath/core/retirement.py +291 -0
  41. glidepath/core/returns.py +312 -0
  42. glidepath/core/scenarios.py +579 -0
  43. glidepath/core/state_pension.py +264 -0
  44. glidepath/core/tax.py +139 -0
  45. glidepath/core/withdrawals.py +461 -0
  46. glidepath/core/wrappers.py +278 -0
  47. glidepath/gui/__init__.py +6 -0
  48. glidepath/gui/assets/icon_128.png +0 -0
  49. glidepath/gui/assets/icon_16.png +0 -0
  50. glidepath/gui/assets/icon_24.png +0 -0
  51. glidepath/gui/assets/icon_256.png +0 -0
  52. glidepath/gui/assets/icon_32.png +0 -0
  53. glidepath/gui/assets/icon_48.png +0 -0
  54. glidepath/gui/assets/icon_64.png +0 -0
  55. glidepath/gui/assets/wordmark.png +0 -0
  56. glidepath/gui/charts.py +829 -0
  57. glidepath/gui/forms.py +359 -0
  58. glidepath/gui/inspector.py +186 -0
  59. glidepath/gui/main.py +51 -0
  60. glidepath/gui/scenarios.py +402 -0
  61. glidepath/gui/style.py +376 -0
  62. glidepath/gui/tableview.py +67 -0
  63. glidepath/gui/widgets.py +989 -0
  64. glidepath/persistence/__init__.py +48 -0
  65. glidepath/persistence/assumptions.py +112 -0
  66. glidepath/persistence/decode.py +747 -0
  67. glidepath/persistence/document.py +101 -0
  68. glidepath/persistence/encode.py +433 -0
  69. glidepath/persistence/migrations.py +158 -0
  70. glidepath/persistence/values.py +298 -0
  71. glidepath/py.typed +0 -0
  72. glidepath/regions/__init__.py +7 -0
  73. glidepath/regions/uk/__init__.py +189 -0
  74. glidepath/regions/uk/ages.py +156 -0
  75. glidepath/regions/uk/contributions.py +717 -0
  76. glidepath/regions/uk/data/age_rules.toml +78 -0
  77. glidepath/regions/uk/data/assumptions_default.toml +170 -0
  78. glidepath/regions/uk/data/returns_history.toml +150 -0
  79. glidepath/regions/uk/data/tax_year_2026_27.toml +98 -0
  80. glidepath/regions/uk/extension.py +479 -0
  81. glidepath/regions/uk/loader.py +704 -0
  82. glidepath/regions/uk/region.py +160 -0
  83. glidepath/regions/uk/schema.py +563 -0
  84. glidepath/regions/uk/state_pension.py +129 -0
  85. glidepath/regions/uk/tax.py +466 -0
  86. glidepath/regions/uk/wrappers.py +283 -0
  87. glidepath/regions/uk/years.py +92 -0
  88. glidepath-0.2.0.dist-info/METADATA +189 -0
  89. glidepath-0.2.0.dist-info/RECORD +93 -0
  90. glidepath-0.2.0.dist-info/WHEEL +4 -0
  91. glidepath-0.2.0.dist-info/entry_points.txt +3 -0
  92. glidepath-0.2.0.dist-info/licenses/LICENSE +21 -0
  93. glidepath-0.2.0.dist-info/licenses/LICENSE-DATA +28 -0
@@ -0,0 +1,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)
@@ -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
+ )