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,240 @@
|
|
|
1
|
+
"""Annuity purchases and pricing (roadmap 5.5; planning §5.1, §7).
|
|
2
|
+
|
|
3
|
+
An :class:`AnnuityPurchase` is wholly a decision record (planning §5.1):
|
|
4
|
+
at a chosen age, a chosen fraction of the pension pot converts into
|
|
5
|
+
lifetime income of a chosen type and basis. Pricing is
|
|
6
|
+
assumption-driven — the §7 single-life-at-65 base rates
|
|
7
|
+
(``annuity.level.single.65`` and friends) shaped by the
|
|
8
|
+
``annuity.age_adjustment`` table (:class:`AnnuityRateTable`): per-age
|
|
9
|
+
multipliers with linear interpolation between whole-year knots, a
|
|
10
|
+
joint-life factor, and the escalating product's fixed annual increase.
|
|
11
|
+
Nothing here is region-specific: the rates are economic estimates in
|
|
12
|
+
the assumption catalogue, not policy figures (planning §4.2).
|
|
13
|
+
|
|
14
|
+
The engine executes the purchase (roadmap 5.5): capital leaves the
|
|
15
|
+
pension wrappers at the purchase date, tax-free cash arrives alongside
|
|
16
|
+
per the region's rules, and the resulting income joins the §5.2 income
|
|
17
|
+
step pro-rated from its exact start date.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from collections.abc import Mapping
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from decimal import Decimal
|
|
23
|
+
from enum import Enum, auto
|
|
24
|
+
from typing import TYPE_CHECKING
|
|
25
|
+
|
|
26
|
+
from glidepath.core.config import EngineError
|
|
27
|
+
from glidepath.core.money import Rate
|
|
28
|
+
from glidepath.core.periods import date_age_attained
|
|
29
|
+
from glidepath.core.provenance import AssumptionKey
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
from datetime import date
|
|
33
|
+
|
|
34
|
+
from glidepath.core.entities import EntityId
|
|
35
|
+
from glidepath.core.provenance import Decision
|
|
36
|
+
|
|
37
|
+
_ZERO = Decimal(0)
|
|
38
|
+
_ONE = Decimal(1)
|
|
39
|
+
_TABLE_CONTEXT = "annuity.age_adjustment assumption"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class AnnuityType(Enum):
|
|
43
|
+
"""The income shape an annuity pays (planning §5.1)."""
|
|
44
|
+
|
|
45
|
+
LEVEL = auto()
|
|
46
|
+
"""Constant nominal income for life."""
|
|
47
|
+
ESCALATING = auto()
|
|
48
|
+
"""Income rising by the table's fixed escalation rate each year."""
|
|
49
|
+
INFLATION_LINKED = auto()
|
|
50
|
+
"""Income tracking the run's CPI path (uncapped, unfloored)."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class AnnuityBasis(Enum):
|
|
54
|
+
"""Whose lives the annuity covers (planning §5.1)."""
|
|
55
|
+
|
|
56
|
+
SINGLE = auto()
|
|
57
|
+
JOINT = auto()
|
|
58
|
+
"""Continues (at the priced survivor fraction) to a partner."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True, slots=True)
|
|
62
|
+
class AnnuityPurchase:
|
|
63
|
+
"""One planned annuity purchase — wholly a decision (planning §5.1).
|
|
64
|
+
|
|
65
|
+
At the period containing the date the person attains ``at_age``,
|
|
66
|
+
``fraction_of_pot`` of each pension wrapper's balance (both
|
|
67
|
+
sub-balances, as they stand at that date) converts into lifetime
|
|
68
|
+
income priced from the annuity-rate assumptions (roadmap 5.5).
|
|
69
|
+
Partial annuitisation mid-drawdown is the ``fraction_of_pot < 1``
|
|
70
|
+
case; several purchases at different ages annuitise in stages.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
id: EntityId
|
|
74
|
+
at_age: Decision[int]
|
|
75
|
+
fraction_of_pot: Decision[Decimal]
|
|
76
|
+
annuity_type: AnnuityType = AnnuityType.LEVEL
|
|
77
|
+
basis: AnnuityBasis = AnnuityBasis.SINGLE
|
|
78
|
+
|
|
79
|
+
def __post_init__(self) -> None:
|
|
80
|
+
"""Reject a non-positive age or a fraction outside (0, 1]."""
|
|
81
|
+
if self.at_age.value <= 0:
|
|
82
|
+
msg = "AnnuityPurchase.at_age must be positive"
|
|
83
|
+
raise ValueError(msg)
|
|
84
|
+
if not _ZERO < self.fraction_of_pot.value <= _ONE:
|
|
85
|
+
msg = "AnnuityPurchase.fraction_of_pot must lie in (0, 1]"
|
|
86
|
+
raise ValueError(msg)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def annuity_start_date(purchase: AnnuityPurchase, date_of_birth: date) -> date:
|
|
90
|
+
"""The exact date the purchase fires and income starts (§4.1)."""
|
|
91
|
+
return date_age_attained(date_of_birth, purchase.at_age.value)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def annuity_base_rate_key(annuity_type: AnnuityType) -> AssumptionKey:
|
|
95
|
+
"""The §7 single-life-at-65 base rate key for ``annuity_type``."""
|
|
96
|
+
match annuity_type:
|
|
97
|
+
case AnnuityType.LEVEL:
|
|
98
|
+
return AssumptionKey.ANNUITY_LEVEL_SINGLE_65
|
|
99
|
+
case AnnuityType.ESCALATING:
|
|
100
|
+
return AssumptionKey.ANNUITY_ESCALATING3_SINGLE_65
|
|
101
|
+
case AnnuityType.INFLATION_LINKED:
|
|
102
|
+
return AssumptionKey.ANNUITY_INFLATION_LINKED_SINGLE_65
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
_TYPE_TABLE_KEYS: tuple[tuple[str, AnnuityType], ...] = (
|
|
106
|
+
("level", AnnuityType.LEVEL),
|
|
107
|
+
("escalating3", AnnuityType.ESCALATING),
|
|
108
|
+
("inflation_linked", AnnuityType.INFLATION_LINKED),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass(frozen=True, slots=True)
|
|
113
|
+
class AnnuityRateTable:
|
|
114
|
+
"""The parsed ``annuity.age_adjustment`` assumption (planning §7).
|
|
115
|
+
|
|
116
|
+
``multipliers`` maps each annuity type to whole-year age knots and
|
|
117
|
+
the multiplier each applies to that type's single-life-at-65 base
|
|
118
|
+
rate; ``joint_factor`` scales any type down to its joint-life
|
|
119
|
+
price; ``escalation`` is the escalating product's fixed annual
|
|
120
|
+
income increase (the base key names it: ``escalating3`` is the
|
|
121
|
+
3%/yr product).
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
escalation: Rate
|
|
125
|
+
joint_factor: Decimal
|
|
126
|
+
multipliers: Mapping[AnnuityType, Mapping[int, Decimal]]
|
|
127
|
+
|
|
128
|
+
def age_multiplier(self, annuity_type: AnnuityType, age: int) -> Decimal:
|
|
129
|
+
"""The multiplier on the base rate for a purchase at ``age``.
|
|
130
|
+
|
|
131
|
+
Linear interpolation between whole-year knots — exact
|
|
132
|
+
``Decimal`` arithmetic per planning §4.6. An age outside the
|
|
133
|
+
table's span is an error, never an extrapolation: shipped data
|
|
134
|
+
drives results, the model does not guess (planning §5.3).
|
|
135
|
+
|
|
136
|
+
Raises:
|
|
137
|
+
EngineError: If ``age`` lies outside the table's knots.
|
|
138
|
+
"""
|
|
139
|
+
knots = self.multipliers[annuity_type]
|
|
140
|
+
ages = sorted(knots)
|
|
141
|
+
if age < ages[0] or age > ages[-1]:
|
|
142
|
+
msg = (
|
|
143
|
+
f"{_TABLE_CONTEXT}: no multiplier for a purchase at age {age};"
|
|
144
|
+
f" the table covers ages {ages[0]}-{ages[-1]}"
|
|
145
|
+
)
|
|
146
|
+
raise EngineError(msg)
|
|
147
|
+
exact = knots.get(age)
|
|
148
|
+
if exact is not None:
|
|
149
|
+
return exact
|
|
150
|
+
below = max(knot for knot in ages if knot < age)
|
|
151
|
+
above = min(knot for knot in ages if knot > age)
|
|
152
|
+
share = Decimal(age - below) / Decimal(above - below)
|
|
153
|
+
return knots[below] + (knots[above] - knots[below]) * share
|
|
154
|
+
|
|
155
|
+
def basis_factor(self, basis: AnnuityBasis) -> Decimal:
|
|
156
|
+
"""The price factor for ``basis``: 1 single, ``joint_factor`` joint."""
|
|
157
|
+
if basis is AnnuityBasis.JOINT:
|
|
158
|
+
return self.joint_factor
|
|
159
|
+
return _ONE
|
|
160
|
+
|
|
161
|
+
@classmethod
|
|
162
|
+
def from_assumption_value(cls, value: object) -> AnnuityRateTable:
|
|
163
|
+
"""Parse the assumption's structured value, strictly.
|
|
164
|
+
|
|
165
|
+
Expects ``escalation`` and ``joint_factor`` figures plus one
|
|
166
|
+
age table per product tag (``level``, ``escalating3``,
|
|
167
|
+
``inflation_linked``); anything else — missing keys, unknown
|
|
168
|
+
keys, non-``Decimal`` figures — fails loudly.
|
|
169
|
+
|
|
170
|
+
Raises:
|
|
171
|
+
EngineError: If the value has any other shape.
|
|
172
|
+
"""
|
|
173
|
+
entries = _table_entries(value, _TABLE_CONTEXT)
|
|
174
|
+
escalation = Rate(_take_decimal(entries, "escalation"))
|
|
175
|
+
joint_factor = _take_decimal(entries, "joint_factor")
|
|
176
|
+
multipliers = {
|
|
177
|
+
annuity_type: _age_knots(entries, tag)
|
|
178
|
+
for tag, annuity_type in _TYPE_TABLE_KEYS
|
|
179
|
+
}
|
|
180
|
+
if entries:
|
|
181
|
+
unknown = ", ".join(sorted(entries))
|
|
182
|
+
msg = f"{_TABLE_CONTEXT}: unknown keys: {unknown}"
|
|
183
|
+
raise EngineError(msg)
|
|
184
|
+
if not _ZERO <= escalation.value <= _ONE:
|
|
185
|
+
msg = f"{_TABLE_CONTEXT}: escalation must lie between 0 and 1"
|
|
186
|
+
raise EngineError(msg)
|
|
187
|
+
if joint_factor <= _ZERO:
|
|
188
|
+
msg = f"{_TABLE_CONTEXT}: joint_factor must be positive"
|
|
189
|
+
raise EngineError(msg)
|
|
190
|
+
return cls(
|
|
191
|
+
escalation=escalation, joint_factor=joint_factor, multipliers=multipliers
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _table_entries(value: object, context: str) -> dict[str, object]:
|
|
196
|
+
"""The assumption value as a consumable key/value dictionary."""
|
|
197
|
+
if not isinstance(value, Mapping):
|
|
198
|
+
msg = f"{context}: expected a table value, got {type(value).__name__}"
|
|
199
|
+
raise EngineError(msg)
|
|
200
|
+
return {str(key): entry for key, entry in value.items()}
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _take_decimal(entries: dict[str, object], key: str) -> Decimal:
|
|
204
|
+
"""Pop a required ``Decimal`` figure from the table."""
|
|
205
|
+
raw = entries.pop(key, None)
|
|
206
|
+
if not isinstance(raw, Decimal):
|
|
207
|
+
found = "missing" if raw is None else type(raw).__name__
|
|
208
|
+
msg = f"{_TABLE_CONTEXT}: {key!r} must be a decimal figure ({found})"
|
|
209
|
+
raise EngineError(msg)
|
|
210
|
+
return raw
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _age_knots(entries: dict[str, object], tag: str) -> dict[int, Decimal]:
|
|
214
|
+
"""Pop one product's age table: whole-year ages to positive factors."""
|
|
215
|
+
raw = entries.pop(tag, None)
|
|
216
|
+
if raw is None:
|
|
217
|
+
msg = f"{_TABLE_CONTEXT}: missing required key {tag!r}"
|
|
218
|
+
raise EngineError(msg)
|
|
219
|
+
table = _table_entries(raw, f"{_TABLE_CONTEXT}.{tag}")
|
|
220
|
+
if not table:
|
|
221
|
+
msg = f"{_TABLE_CONTEXT}.{tag}: age table must not be empty"
|
|
222
|
+
raise EngineError(msg)
|
|
223
|
+
knots: dict[int, Decimal] = {}
|
|
224
|
+
for age_text, factor in table.items():
|
|
225
|
+
try:
|
|
226
|
+
age = int(age_text)
|
|
227
|
+
except ValueError:
|
|
228
|
+
msg = f"{_TABLE_CONTEXT}.{tag}: ages must be whole years, got {age_text!r}"
|
|
229
|
+
raise EngineError(msg) from None
|
|
230
|
+
if age <= 0:
|
|
231
|
+
msg = f"{_TABLE_CONTEXT}.{tag}: ages must be positive"
|
|
232
|
+
raise EngineError(msg)
|
|
233
|
+
if not isinstance(factor, Decimal) or factor <= _ZERO:
|
|
234
|
+
msg = (
|
|
235
|
+
f"{_TABLE_CONTEXT}.{tag}[{age}]: multipliers must be"
|
|
236
|
+
" positive decimal figures"
|
|
237
|
+
)
|
|
238
|
+
raise EngineError(msg)
|
|
239
|
+
knots[age] = factor
|
|
240
|
+
return knots
|