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