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,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