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,298 @@
|
|
|
1
|
+
"""Tagged JSON encoding for polymorphic values (roadmap 6.2; planning §4.5).
|
|
2
|
+
|
|
3
|
+
Entity fields decode by field context — the schema knows a balance is
|
|
4
|
+
``Money`` — but assumption values and scenario override values are
|
|
5
|
+
polymorphic: an override may hold an ``int`` retirement age, a
|
|
6
|
+
``Decimal`` fraction, a ``Money`` amount, a rule tag, a structured
|
|
7
|
+
table, or an annuity product enum. A closed tag vocabulary keeps the
|
|
8
|
+
exact runtime type through the JSON round trip: ``Decimal`` and
|
|
9
|
+
``Money`` travel as strings (never JSON floats — money is ``Decimal``,
|
|
10
|
+
planning §4.6), tables as tagged objects, and enums as their stable
|
|
11
|
+
tokens.
|
|
12
|
+
|
|
13
|
+
This module also holds the primitive parsers and the enum token tables
|
|
14
|
+
the entity codecs share.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from collections.abc import Callable, Mapping
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from datetime import date, datetime
|
|
20
|
+
from decimal import Decimal, InvalidOperation
|
|
21
|
+
from enum import Enum
|
|
22
|
+
from types import MappingProxyType
|
|
23
|
+
|
|
24
|
+
from glidepath.core import (
|
|
25
|
+
AnnuityBasis,
|
|
26
|
+
AnnuityType,
|
|
27
|
+
LifeStage,
|
|
28
|
+
Money,
|
|
29
|
+
ReliefMechanic,
|
|
30
|
+
RevaluationReference,
|
|
31
|
+
Sex,
|
|
32
|
+
)
|
|
33
|
+
from glidepath.persistence.document import PersistenceError
|
|
34
|
+
|
|
35
|
+
_KIND = "kind"
|
|
36
|
+
_VALUE = "value"
|
|
37
|
+
|
|
38
|
+
_KIND_INT = "int"
|
|
39
|
+
_KIND_TEXT = "text"
|
|
40
|
+
_KIND_DECIMAL = "decimal"
|
|
41
|
+
_KIND_MONEY = "money"
|
|
42
|
+
_KIND_TABLE = "table"
|
|
43
|
+
_KIND_ANNUITY_TYPE = "annuity_type"
|
|
44
|
+
_KIND_ANNUITY_BASIS = "annuity_basis"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True, slots=True)
|
|
48
|
+
class EnumTokens[E: Enum]:
|
|
49
|
+
"""A bidirectional stable-token map for one enum (planning §4.5).
|
|
50
|
+
|
|
51
|
+
Tokens are persisted, so they must never change meaning; retire a
|
|
52
|
+
token by adding a new one, exactly like assumption keys.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
by_member: Mapping[E, str]
|
|
56
|
+
|
|
57
|
+
def token(self, member: E) -> str:
|
|
58
|
+
"""The stable token written for ``member``."""
|
|
59
|
+
return self.by_member[member]
|
|
60
|
+
|
|
61
|
+
def member(self, raw: object, path: str) -> E:
|
|
62
|
+
"""The member a stored token names.
|
|
63
|
+
|
|
64
|
+
Raises:
|
|
65
|
+
PersistenceError: If ``raw`` is not one of the tokens.
|
|
66
|
+
"""
|
|
67
|
+
for member, token in self.by_member.items():
|
|
68
|
+
if token == raw:
|
|
69
|
+
return member
|
|
70
|
+
known = ", ".join(sorted(self.by_member.values()))
|
|
71
|
+
msg = f"{path}: unknown token {raw!r} (one of: {known})"
|
|
72
|
+
raise PersistenceError(msg)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
SEX_TOKENS = EnumTokens({Sex.FEMALE: "female", Sex.MALE: "male"})
|
|
76
|
+
RELIEF_MECHANIC_TOKENS = EnumTokens(
|
|
77
|
+
{
|
|
78
|
+
ReliefMechanic.RELIEF_AT_SOURCE: "relief_at_source",
|
|
79
|
+
ReliefMechanic.NET_PAY: "net_pay",
|
|
80
|
+
}
|
|
81
|
+
)
|
|
82
|
+
REVALUATION_REFERENCE_TOKENS = EnumTokens(
|
|
83
|
+
{
|
|
84
|
+
RevaluationReference.CPI: "cpi",
|
|
85
|
+
RevaluationReference.FIXED: "fixed",
|
|
86
|
+
RevaluationReference.NONE: "none",
|
|
87
|
+
}
|
|
88
|
+
)
|
|
89
|
+
ANNUITY_TYPE_TOKENS = EnumTokens(
|
|
90
|
+
{
|
|
91
|
+
AnnuityType.LEVEL: "level",
|
|
92
|
+
AnnuityType.ESCALATING: "escalating",
|
|
93
|
+
AnnuityType.INFLATION_LINKED: "inflation_linked",
|
|
94
|
+
}
|
|
95
|
+
)
|
|
96
|
+
ANNUITY_BASIS_TOKENS = EnumTokens(
|
|
97
|
+
{AnnuityBasis.SINGLE: "single", AnnuityBasis.JOINT: "joint"}
|
|
98
|
+
)
|
|
99
|
+
LIFE_STAGE_TOKENS = EnumTokens(
|
|
100
|
+
{
|
|
101
|
+
LifeStage.EARLY_ACCUMULATION: "early_accumulation",
|
|
102
|
+
LifeStage.MID_ACCUMULATION: "mid_accumulation",
|
|
103
|
+
LifeStage.PRE_RETIREMENT: "pre_retirement",
|
|
104
|
+
LifeStage.DECUMULATION: "decumulation",
|
|
105
|
+
LifeStage.GO_GO: "go_go",
|
|
106
|
+
LifeStage.SLOW_GO: "slow_go",
|
|
107
|
+
LifeStage.NO_GO: "no_go",
|
|
108
|
+
}
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def parse_decimal(raw: object, path: str) -> Decimal:
|
|
113
|
+
"""Parse a stored ``Decimal`` string, exactly and finitely.
|
|
114
|
+
|
|
115
|
+
Raises:
|
|
116
|
+
PersistenceError: If ``raw`` is not a string holding a finite
|
|
117
|
+
decimal number.
|
|
118
|
+
"""
|
|
119
|
+
if not isinstance(raw, str):
|
|
120
|
+
msg = f"{path}: expected a decimal string, got {type(raw).__name__}"
|
|
121
|
+
raise PersistenceError(msg)
|
|
122
|
+
try:
|
|
123
|
+
value = Decimal(raw)
|
|
124
|
+
except InvalidOperation:
|
|
125
|
+
msg = f"{path}: not a decimal number: {raw!r}"
|
|
126
|
+
raise PersistenceError(msg) from None
|
|
127
|
+
if not value.is_finite():
|
|
128
|
+
msg = f"{path}: decimal values must be finite, got {raw!r}"
|
|
129
|
+
raise PersistenceError(msg)
|
|
130
|
+
return value
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def parse_int(raw: object, path: str) -> int:
|
|
134
|
+
"""Parse a stored whole number (``bool`` rejected, as ever).
|
|
135
|
+
|
|
136
|
+
Raises:
|
|
137
|
+
PersistenceError: If ``raw`` is not an integer.
|
|
138
|
+
"""
|
|
139
|
+
if isinstance(raw, bool) or not isinstance(raw, int):
|
|
140
|
+
msg = f"{path}: expected a whole number, got {type(raw).__name__}"
|
|
141
|
+
raise PersistenceError(msg)
|
|
142
|
+
return raw
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def parse_str(raw: object, path: str) -> str:
|
|
146
|
+
"""Parse a stored string.
|
|
147
|
+
|
|
148
|
+
Raises:
|
|
149
|
+
PersistenceError: If ``raw`` is not a string.
|
|
150
|
+
"""
|
|
151
|
+
if not isinstance(raw, str):
|
|
152
|
+
msg = f"{path}: expected a string, got {type(raw).__name__}"
|
|
153
|
+
raise PersistenceError(msg)
|
|
154
|
+
return raw
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def parse_date(raw: object, path: str) -> date:
|
|
158
|
+
"""Parse a stored ISO-8601 calendar date.
|
|
159
|
+
|
|
160
|
+
Raises:
|
|
161
|
+
PersistenceError: If ``raw`` is not an ISO-8601 date string.
|
|
162
|
+
"""
|
|
163
|
+
text = parse_str(raw, path)
|
|
164
|
+
try:
|
|
165
|
+
return date.fromisoformat(text)
|
|
166
|
+
except ValueError:
|
|
167
|
+
msg = f"{path}: not an ISO-8601 date: {text!r}"
|
|
168
|
+
raise PersistenceError(msg) from None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def parse_datetime(raw: object, path: str) -> datetime:
|
|
172
|
+
"""Parse a stored ISO-8601 timezone-aware datetime.
|
|
173
|
+
|
|
174
|
+
Raises:
|
|
175
|
+
PersistenceError: If ``raw`` is not an ISO-8601 datetime string
|
|
176
|
+
carrying a UTC offset.
|
|
177
|
+
"""
|
|
178
|
+
text = parse_str(raw, path)
|
|
179
|
+
try:
|
|
180
|
+
moment = datetime.fromisoformat(text)
|
|
181
|
+
except ValueError:
|
|
182
|
+
msg = f"{path}: not an ISO-8601 datetime: {text!r}"
|
|
183
|
+
raise PersistenceError(msg) from None
|
|
184
|
+
if moment.tzinfo is None or moment.tzinfo.utcoffset(moment) is None:
|
|
185
|
+
msg = f"{path}: datetimes must be timezone-aware, got {text!r}"
|
|
186
|
+
raise PersistenceError(msg)
|
|
187
|
+
return moment
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def parse_money(raw: object, path: str) -> Money:
|
|
191
|
+
"""Parse a stored monetary amount (a ``Decimal`` string).
|
|
192
|
+
|
|
193
|
+
Raises:
|
|
194
|
+
PersistenceError: If ``raw`` is not a finite decimal string.
|
|
195
|
+
"""
|
|
196
|
+
return Money(parse_decimal(raw, path))
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def encode_value(value: object, path: str) -> dict[str, object]:
|
|
200
|
+
"""Encode one polymorphic value as a tagged JSON object.
|
|
201
|
+
|
|
202
|
+
Raises:
|
|
203
|
+
PersistenceError: If the value's type is outside the closed
|
|
204
|
+
vocabulary, or a table key is not a string.
|
|
205
|
+
"""
|
|
206
|
+
kind, payload = _tagged(value, path)
|
|
207
|
+
return {_KIND: kind, _VALUE: payload}
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _tagged(value: object, path: str) -> tuple[str, object]:
|
|
211
|
+
"""The tag and JSON payload for one value."""
|
|
212
|
+
if isinstance(value, bool):
|
|
213
|
+
msg = f"{path}: booleans are not a persisted value type"
|
|
214
|
+
raise PersistenceError(msg)
|
|
215
|
+
scalar = _tagged_scalar(value, path)
|
|
216
|
+
if scalar is not None:
|
|
217
|
+
return scalar
|
|
218
|
+
if isinstance(value, AnnuityType):
|
|
219
|
+
return (_KIND_ANNUITY_TYPE, ANNUITY_TYPE_TOKENS.token(value))
|
|
220
|
+
if isinstance(value, AnnuityBasis):
|
|
221
|
+
return (_KIND_ANNUITY_BASIS, ANNUITY_BASIS_TOKENS.token(value))
|
|
222
|
+
if isinstance(value, Mapping):
|
|
223
|
+
return (_KIND_TABLE, _encode_table(value, path))
|
|
224
|
+
msg = f"{path}: cannot persist a value of type {type(value).__name__}"
|
|
225
|
+
raise PersistenceError(msg)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _tagged_scalar(value: object, path: str) -> tuple[str, object] | None:
|
|
229
|
+
"""The tag and payload for a scalar value; ``None`` for non-scalars.
|
|
230
|
+
|
|
231
|
+
A non-finite ``Decimal`` is rejected here so the writer never
|
|
232
|
+
produces a file :func:`parse_decimal` would refuse on reload
|
|
233
|
+
(``Money`` enforces finiteness at construction already).
|
|
234
|
+
|
|
235
|
+
Raises:
|
|
236
|
+
PersistenceError: If a ``Decimal`` value is not finite.
|
|
237
|
+
"""
|
|
238
|
+
if isinstance(value, int):
|
|
239
|
+
return (_KIND_INT, value)
|
|
240
|
+
if isinstance(value, str):
|
|
241
|
+
return (_KIND_TEXT, value)
|
|
242
|
+
if isinstance(value, Decimal):
|
|
243
|
+
if not value.is_finite():
|
|
244
|
+
msg = f"{path}: decimal values must be finite, got {value!r}"
|
|
245
|
+
raise PersistenceError(msg)
|
|
246
|
+
return (_KIND_DECIMAL, str(value))
|
|
247
|
+
if isinstance(value, Money):
|
|
248
|
+
return (_KIND_MONEY, str(value.amount))
|
|
249
|
+
return None
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _encode_table(value: Mapping[object, object], path: str) -> dict[str, object]:
|
|
253
|
+
"""Encode a structured table value, entry by entry."""
|
|
254
|
+
table: dict[str, object] = {}
|
|
255
|
+
for key, entry in value.items():
|
|
256
|
+
if not isinstance(key, str):
|
|
257
|
+
msg = f"{path}: table keys must be strings, got {type(key).__name__}"
|
|
258
|
+
raise PersistenceError(msg)
|
|
259
|
+
table[key] = encode_value(entry, f"{path}.{key}")
|
|
260
|
+
return table
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _decode_table(raw: object, path: str) -> dict[str, object]:
|
|
264
|
+
"""Decode a tagged table's entries back to a plain dictionary."""
|
|
265
|
+
if not isinstance(raw, dict):
|
|
266
|
+
msg = f"{path}: expected a table object, got {type(raw).__name__}"
|
|
267
|
+
raise PersistenceError(msg)
|
|
268
|
+
return {key: decode_value(entry, f"{path}.{key}") for key, entry in raw.items()}
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
_DECODERS: Mapping[str, Callable[[object, str], object]] = MappingProxyType(
|
|
272
|
+
{
|
|
273
|
+
_KIND_INT: parse_int,
|
|
274
|
+
_KIND_TEXT: parse_str,
|
|
275
|
+
_KIND_DECIMAL: parse_decimal,
|
|
276
|
+
_KIND_MONEY: parse_money,
|
|
277
|
+
_KIND_ANNUITY_TYPE: ANNUITY_TYPE_TOKENS.member,
|
|
278
|
+
_KIND_ANNUITY_BASIS: ANNUITY_BASIS_TOKENS.member,
|
|
279
|
+
_KIND_TABLE: _decode_table,
|
|
280
|
+
}
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def decode_value(raw: object, path: str) -> object:
|
|
285
|
+
"""Decode one tagged JSON object back to its exact runtime type.
|
|
286
|
+
|
|
287
|
+
Raises:
|
|
288
|
+
PersistenceError: If the tag shape or kind is not recognised.
|
|
289
|
+
"""
|
|
290
|
+
if not isinstance(raw, dict) or set(raw) != {_KIND, _VALUE}:
|
|
291
|
+
msg = f"{path}: expected a tagged value object with keys 'kind' and 'value'"
|
|
292
|
+
raise PersistenceError(msg)
|
|
293
|
+
kind = raw[_KIND]
|
|
294
|
+
decoder = _DECODERS.get(kind) if isinstance(kind, str) else None
|
|
295
|
+
if decoder is None:
|
|
296
|
+
msg = f"{path}: unknown value kind {kind!r}"
|
|
297
|
+
raise PersistenceError(msg)
|
|
298
|
+
return decoder(raw[_VALUE], path)
|
glidepath/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Region packages implementing the core boundary protocols (planning §4.2).
|
|
2
|
+
|
|
3
|
+
Everything country-specific — tax rules, wrappers, state pension, age
|
|
4
|
+
rules — lives under this package. The dependency direction is region →
|
|
5
|
+
core only: the core never imports region code (guard-tested), and no
|
|
6
|
+
policy figure appears outside a region's ``data/`` TOML files.
|
|
7
|
+
"""
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""UK region package (planning §4.2, §5.3).
|
|
2
|
+
|
|
3
|
+
Implements the core boundary protocols for the UK. Every UK policy
|
|
4
|
+
figure — tax bands, allowances, state pension rates, age rules — is
|
|
5
|
+
loaded from the TOML data files under ``data/`` (each carrying
|
|
6
|
+
``verified_on`` + ``sources``), never hardcoded; a guard test enforces
|
|
7
|
+
this. Shipped default assumptions mirror planning §7 and are kept in
|
|
8
|
+
sync by a doc-sync test.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from glidepath.regions.uk.ages import UkAgeError, UkAgeRules
|
|
12
|
+
from glidepath.regions.uk.contributions import (
|
|
13
|
+
AnnualAllowanceAssessment,
|
|
14
|
+
CarryForwardOutcome,
|
|
15
|
+
UkContributionError,
|
|
16
|
+
UkContributionRuleset,
|
|
17
|
+
adjusted_income,
|
|
18
|
+
apply_carry_forward,
|
|
19
|
+
assess_annual_allowance,
|
|
20
|
+
carry_forward_generated,
|
|
21
|
+
db_pension_input_amount,
|
|
22
|
+
is_mpaa_active,
|
|
23
|
+
roll_carry_forward,
|
|
24
|
+
tapered_annual_allowance,
|
|
25
|
+
threshold_income,
|
|
26
|
+
)
|
|
27
|
+
from glidepath.regions.uk.extension import (
|
|
28
|
+
FutureYearsExtension,
|
|
29
|
+
FutureYearsMode,
|
|
30
|
+
FutureYearsPolicy,
|
|
31
|
+
ScottishBandsPolicy,
|
|
32
|
+
extend_tax_year,
|
|
33
|
+
)
|
|
34
|
+
from glidepath.regions.uk.loader import (
|
|
35
|
+
AGE_RULES_FILENAME,
|
|
36
|
+
ASSUMPTIONS_FILENAME,
|
|
37
|
+
RETURNS_HISTORY_FILENAME,
|
|
38
|
+
available_tax_years,
|
|
39
|
+
data_file_digest,
|
|
40
|
+
load_age_rules,
|
|
41
|
+
load_default_assumptions,
|
|
42
|
+
load_returns_history,
|
|
43
|
+
load_tax_year,
|
|
44
|
+
parse_age_rules,
|
|
45
|
+
parse_default_assumptions,
|
|
46
|
+
parse_returns_history,
|
|
47
|
+
parse_tax_year,
|
|
48
|
+
tax_year_filename,
|
|
49
|
+
)
|
|
50
|
+
from glidepath.regions.uk.region import (
|
|
51
|
+
default_assumption_set,
|
|
52
|
+
future_years_extension,
|
|
53
|
+
uk_region,
|
|
54
|
+
)
|
|
55
|
+
from glidepath.regions.uk.schema import (
|
|
56
|
+
SCHEMA_VERSION,
|
|
57
|
+
AgeRulesFile,
|
|
58
|
+
AssumptionDefault,
|
|
59
|
+
AssumptionsFile,
|
|
60
|
+
AssumptionValue,
|
|
61
|
+
DataFileError,
|
|
62
|
+
DividendRate,
|
|
63
|
+
DividendRules,
|
|
64
|
+
FileMeta,
|
|
65
|
+
IncomeTaxSchedule,
|
|
66
|
+
IsaRules,
|
|
67
|
+
LisaAges,
|
|
68
|
+
NmpaStep,
|
|
69
|
+
PensionRules,
|
|
70
|
+
ReturnsHistoryFile,
|
|
71
|
+
SavingsRules,
|
|
72
|
+
SpaAgeBand,
|
|
73
|
+
SpaBand,
|
|
74
|
+
SpaDateBand,
|
|
75
|
+
StatePensionDeferral,
|
|
76
|
+
TaxBand,
|
|
77
|
+
TaxYearFile,
|
|
78
|
+
TaxYearMeta,
|
|
79
|
+
)
|
|
80
|
+
from glidepath.regions.uk.state_pension import (
|
|
81
|
+
UkStatePensionError,
|
|
82
|
+
UkStatePensionScheme,
|
|
83
|
+
)
|
|
84
|
+
from glidepath.regions.uk.tax import (
|
|
85
|
+
DIVIDEND_NIL_RATE_BAND,
|
|
86
|
+
RUK_RESIDENCY,
|
|
87
|
+
SAVINGS_NIL_RATE_BAND,
|
|
88
|
+
SAVINGS_STARTING_RATE_BAND,
|
|
89
|
+
SCOTLAND_RESIDENCY,
|
|
90
|
+
UkTaxError,
|
|
91
|
+
UkTaxSystem,
|
|
92
|
+
)
|
|
93
|
+
from glidepath.regions.uk.wrappers import (
|
|
94
|
+
CASH_KIND,
|
|
95
|
+
GIA_KIND,
|
|
96
|
+
ISA_ALLOWANCE_GROUP,
|
|
97
|
+
ISA_KIND,
|
|
98
|
+
LISA_ALLOWANCE_GROUP,
|
|
99
|
+
LISA_KIND,
|
|
100
|
+
SIPP_KIND,
|
|
101
|
+
WORKPLACE_DC_KIND,
|
|
102
|
+
UkWrapperError,
|
|
103
|
+
UkWrapperRuleset,
|
|
104
|
+
)
|
|
105
|
+
from glidepath.regions.uk.years import TaxYearSeries, UkTaxYearError
|
|
106
|
+
|
|
107
|
+
__all__ = [
|
|
108
|
+
"AGE_RULES_FILENAME",
|
|
109
|
+
"ASSUMPTIONS_FILENAME",
|
|
110
|
+
"CASH_KIND",
|
|
111
|
+
"DIVIDEND_NIL_RATE_BAND",
|
|
112
|
+
"GIA_KIND",
|
|
113
|
+
"ISA_ALLOWANCE_GROUP",
|
|
114
|
+
"ISA_KIND",
|
|
115
|
+
"LISA_ALLOWANCE_GROUP",
|
|
116
|
+
"LISA_KIND",
|
|
117
|
+
"RETURNS_HISTORY_FILENAME",
|
|
118
|
+
"RUK_RESIDENCY",
|
|
119
|
+
"SAVINGS_NIL_RATE_BAND",
|
|
120
|
+
"SAVINGS_STARTING_RATE_BAND",
|
|
121
|
+
"SCHEMA_VERSION",
|
|
122
|
+
"SCOTLAND_RESIDENCY",
|
|
123
|
+
"SIPP_KIND",
|
|
124
|
+
"WORKPLACE_DC_KIND",
|
|
125
|
+
"AgeRulesFile",
|
|
126
|
+
"AnnualAllowanceAssessment",
|
|
127
|
+
"AssumptionDefault",
|
|
128
|
+
"AssumptionValue",
|
|
129
|
+
"AssumptionsFile",
|
|
130
|
+
"CarryForwardOutcome",
|
|
131
|
+
"DataFileError",
|
|
132
|
+
"DividendRate",
|
|
133
|
+
"DividendRules",
|
|
134
|
+
"FileMeta",
|
|
135
|
+
"FutureYearsExtension",
|
|
136
|
+
"FutureYearsMode",
|
|
137
|
+
"FutureYearsPolicy",
|
|
138
|
+
"IncomeTaxSchedule",
|
|
139
|
+
"IsaRules",
|
|
140
|
+
"LisaAges",
|
|
141
|
+
"NmpaStep",
|
|
142
|
+
"PensionRules",
|
|
143
|
+
"ReturnsHistoryFile",
|
|
144
|
+
"SavingsRules",
|
|
145
|
+
"ScottishBandsPolicy",
|
|
146
|
+
"SpaAgeBand",
|
|
147
|
+
"SpaBand",
|
|
148
|
+
"SpaDateBand",
|
|
149
|
+
"StatePensionDeferral",
|
|
150
|
+
"TaxBand",
|
|
151
|
+
"TaxYearFile",
|
|
152
|
+
"TaxYearMeta",
|
|
153
|
+
"TaxYearSeries",
|
|
154
|
+
"UkAgeError",
|
|
155
|
+
"UkAgeRules",
|
|
156
|
+
"UkContributionError",
|
|
157
|
+
"UkContributionRuleset",
|
|
158
|
+
"UkStatePensionError",
|
|
159
|
+
"UkStatePensionScheme",
|
|
160
|
+
"UkTaxError",
|
|
161
|
+
"UkTaxSystem",
|
|
162
|
+
"UkTaxYearError",
|
|
163
|
+
"UkWrapperError",
|
|
164
|
+
"UkWrapperRuleset",
|
|
165
|
+
"adjusted_income",
|
|
166
|
+
"apply_carry_forward",
|
|
167
|
+
"assess_annual_allowance",
|
|
168
|
+
"available_tax_years",
|
|
169
|
+
"carry_forward_generated",
|
|
170
|
+
"data_file_digest",
|
|
171
|
+
"db_pension_input_amount",
|
|
172
|
+
"default_assumption_set",
|
|
173
|
+
"extend_tax_year",
|
|
174
|
+
"future_years_extension",
|
|
175
|
+
"is_mpaa_active",
|
|
176
|
+
"load_age_rules",
|
|
177
|
+
"load_default_assumptions",
|
|
178
|
+
"load_returns_history",
|
|
179
|
+
"load_tax_year",
|
|
180
|
+
"parse_age_rules",
|
|
181
|
+
"parse_default_assumptions",
|
|
182
|
+
"parse_returns_history",
|
|
183
|
+
"parse_tax_year",
|
|
184
|
+
"roll_carry_forward",
|
|
185
|
+
"tapered_annual_allowance",
|
|
186
|
+
"tax_year_filename",
|
|
187
|
+
"threshold_income",
|
|
188
|
+
"uk_region",
|
|
189
|
+
]
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""UK age rules (roadmap 2.4; planning §4.1, §4.2, §6).
|
|
2
|
+
|
|
3
|
+
Implements the core :class:`~glidepath.core.AgeRules` protocol plus the
|
|
4
|
+
UK-only LISA age gates. Every figure — the SPA timetable, the NMPA
|
|
5
|
+
schedule, the LISA ages — comes from ``age_rules.toml`` (§5.3); nothing
|
|
6
|
+
is hardcoded here (guard-tested).
|
|
7
|
+
|
|
8
|
+
The three §4.1 conventions appear as three shapes here:
|
|
9
|
+
|
|
10
|
+
- **Access gates** (NMPA, LISA access at 60) are booleans per period,
|
|
11
|
+
open only if the age is attained on or before the period's first day.
|
|
12
|
+
- **Income entitlements** (SPA) are exact dates for the core to
|
|
13
|
+
pro-rate.
|
|
14
|
+
- **Eligibility windows** (LISA opening 18-39, contributions to 50) are
|
|
15
|
+
exact date spans — never rounded to periods in either direction; the
|
|
16
|
+
consumer (the wrapper ruleset, roadmap 3.1/9.2) intersects them with
|
|
17
|
+
the period and pro-rates any flow by whole months.
|
|
18
|
+
|
|
19
|
+
The NMPA schedule is effective-dated, and the age in force is read on
|
|
20
|
+
the period's first day. Around a legislated step-up this correctly
|
|
21
|
+
denies *new* access to the caught cohort — old enough under the
|
|
22
|
+
outgoing age but not the incoming one — until they attain the new age;
|
|
23
|
+
benefits already in payment are never re-gated (planning §5.1).
|
|
24
|
+
Protected pension ages are out of scope for v1 (§6).
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from datetime import timedelta
|
|
29
|
+
from typing import TYPE_CHECKING
|
|
30
|
+
|
|
31
|
+
from glidepath.core import (
|
|
32
|
+
Period,
|
|
33
|
+
add_months,
|
|
34
|
+
date_age_attained,
|
|
35
|
+
is_age_attained_by_period_start,
|
|
36
|
+
)
|
|
37
|
+
from glidepath.regions.uk.loader import load_age_rules
|
|
38
|
+
from glidepath.regions.uk.schema import SpaAgeBand
|
|
39
|
+
|
|
40
|
+
if TYPE_CHECKING:
|
|
41
|
+
from datetime import date
|
|
42
|
+
|
|
43
|
+
from glidepath.regions.uk.schema import AgeRulesFile, SpaBand
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class UkAgeError(ValueError):
|
|
47
|
+
"""An age query the shipped UK data cannot answer."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True, slots=True)
|
|
51
|
+
class UkAgeRules:
|
|
52
|
+
"""UK implementation of the core ``AgeRules`` protocol.
|
|
53
|
+
|
|
54
|
+
Holds one validated :class:`AgeRulesFile`; every answer is a pure
|
|
55
|
+
function of that file and the query.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
rules: AgeRulesFile
|
|
59
|
+
|
|
60
|
+
@classmethod
|
|
61
|
+
def from_shipped_data(cls) -> UkAgeRules:
|
|
62
|
+
"""Build the rules over the shipped ``age_rules.toml``."""
|
|
63
|
+
return cls(rules=load_age_rules())
|
|
64
|
+
|
|
65
|
+
def state_pension_date(self, date_of_birth: date) -> date:
|
|
66
|
+
"""The exact date this date of birth reaches state pension age.
|
|
67
|
+
|
|
68
|
+
Age-based timetable bands add whole years then whole months to
|
|
69
|
+
the (leap-day-deemed) birthday, clamping to the target month's
|
|
70
|
+
end; date-based bands reach SPA on their legislated date.
|
|
71
|
+
|
|
72
|
+
Raises:
|
|
73
|
+
UkAgeError: If the date of birth predates the timetable's
|
|
74
|
+
coverage (earlier cohorts had phased, pre-2016-system
|
|
75
|
+
SPAs this forward-looking planner does not model).
|
|
76
|
+
"""
|
|
77
|
+
band = self._spa_band_for(date_of_birth)
|
|
78
|
+
if isinstance(band, SpaAgeBand):
|
|
79
|
+
return add_months(date_age_attained(date_of_birth, band.years), band.months)
|
|
80
|
+
return band.reaches_on
|
|
81
|
+
|
|
82
|
+
def _spa_band_for(self, date_of_birth: date) -> SpaBand:
|
|
83
|
+
"""The SPA timetable band containing ``date_of_birth``."""
|
|
84
|
+
bands = self.rules.spa_bands
|
|
85
|
+
first_covered = bands[0].dob_from
|
|
86
|
+
if first_covered is not None and date_of_birth < first_covered:
|
|
87
|
+
msg = (
|
|
88
|
+
f"date of birth {date_of_birth} predates SPA timetable"
|
|
89
|
+
f" coverage (which starts {first_covered})"
|
|
90
|
+
)
|
|
91
|
+
raise UkAgeError(msg)
|
|
92
|
+
*bounded, open_ended = bands
|
|
93
|
+
for band in bounded:
|
|
94
|
+
if band.dob_to is not None and date_of_birth <= band.dob_to:
|
|
95
|
+
return band
|
|
96
|
+
return open_ended
|
|
97
|
+
|
|
98
|
+
def normal_minimum_pension_age(self, on: date) -> int:
|
|
99
|
+
"""The normal minimum pension age in force on ``on``."""
|
|
100
|
+
baseline, *dated = self.rules.nmpa
|
|
101
|
+
age = baseline.age
|
|
102
|
+
for step in dated:
|
|
103
|
+
if step.effective_from is not None and step.effective_from <= on:
|
|
104
|
+
age = step.age
|
|
105
|
+
return age
|
|
106
|
+
|
|
107
|
+
def is_pension_access_open(self, date_of_birth: date, period: Period) -> bool:
|
|
108
|
+
"""Whether new pension access is open for ``period`` (§4.1).
|
|
109
|
+
|
|
110
|
+
The NMPA in force on the period's first day must be attained on
|
|
111
|
+
or before that day. Only *new* crystallisations are gated here;
|
|
112
|
+
benefits already in payment continue regardless (planning §5.1).
|
|
113
|
+
"""
|
|
114
|
+
age = self.normal_minimum_pension_age(period.start)
|
|
115
|
+
return is_age_attained_by_period_start(date_of_birth, age, period)
|
|
116
|
+
|
|
117
|
+
def lisa_opening_window(self, date_of_birth: date) -> Period:
|
|
118
|
+
"""The exact dates a LISA may be opened (§4.1 eligibility window).
|
|
119
|
+
|
|
120
|
+
Runs from the opening birthday to the day before the birthday
|
|
121
|
+
after the last eligible age. Consumers intersect this with the
|
|
122
|
+
engine period; a mid-period opening birthday makes the rest of
|
|
123
|
+
that period eligible.
|
|
124
|
+
"""
|
|
125
|
+
lisa = self.rules.lisa
|
|
126
|
+
return _age_window(date_of_birth, lisa.open_age_min, lisa.open_age_max + 1)
|
|
127
|
+
|
|
128
|
+
def lisa_contribution_window(self, date_of_birth: date) -> Period:
|
|
129
|
+
"""The exact dates LISA contributions may be made (§4.1 window).
|
|
130
|
+
|
|
131
|
+
The age window only — holding an open LISA is the wrapper's
|
|
132
|
+
concern (roadmap 9.2). It runs from the opening birthday (a
|
|
133
|
+
contributor must be old enough to hold an account) to the eve
|
|
134
|
+
of the closing birthday; contribution flows crossing either
|
|
135
|
+
edge are pro-rated by the consumer like other partial years.
|
|
136
|
+
"""
|
|
137
|
+
lisa = self.rules.lisa
|
|
138
|
+
return _age_window(date_of_birth, lisa.open_age_min, lisa.contribute_until_age)
|
|
139
|
+
|
|
140
|
+
def is_lisa_access_open(self, date_of_birth: date, period: Period) -> bool:
|
|
141
|
+
"""Whether charge-free LISA access is open for ``period`` (§4.1 gate).
|
|
142
|
+
|
|
143
|
+
The age gate only; the other charge-free events (first home,
|
|
144
|
+
terminal illness, death) are out of scope for v1 (§6).
|
|
145
|
+
"""
|
|
146
|
+
return is_age_attained_by_period_start(
|
|
147
|
+
date_of_birth, self.rules.lisa.access_age, period
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _age_window(date_of_birth: date, opens_at: int, closes_at: int) -> Period:
|
|
152
|
+
"""The inclusive span from one birthday to the eve of a later one."""
|
|
153
|
+
return Period(
|
|
154
|
+
start=date_age_attained(date_of_birth, opens_at),
|
|
155
|
+
end=date_age_attained(date_of_birth, closes_at) - timedelta(days=1),
|
|
156
|
+
)
|