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
+ """State pension records and the region boundary (roadmap 4.3; planning §5.1).
2
+
3
+ The core holds the user's :class:`StatePensionRecord` — the official
4
+ DWP forecast is the fact and the only route to an amount (planning
5
+ §5.1); the model never re-derives what DWP has already computed — and
6
+ the :class:`StatePensionScheme` protocol (planning §4.2) a region
7
+ implements over its data files. The region answers with a
8
+ :class:`StatePensionEntitlement`: an exact start date (state pension
9
+ age plus any deferral, an income entitlement per §4.1) and annual
10
+ amounts split by uprating treatment, in the rates the forecast states.
11
+
12
+ Uprating is the engine's concern, governed by the
13
+ ``policy.state_pension.uprating`` assumption (planning §7) parsed here
14
+ as :class:`StatePensionUprating` — both the §4.8 roll-forward of a
15
+ stale forecast from its ``as_of`` to the run start and the in-run
16
+ advance from there. The split matters because the two slices uprate
17
+ differently (planning §5.1, §6): the main entitlement follows the
18
+ policy rule (triple-lock proxy by default), while protected payments
19
+ and deferral increments uprate by CPI only.
20
+ """
21
+
22
+ from collections.abc import Mapping
23
+ from dataclasses import dataclass
24
+ from decimal import Decimal
25
+ from enum import Enum, auto
26
+ from typing import TYPE_CHECKING, Protocol
27
+
28
+ from glidepath.core.config import EngineError
29
+ from glidepath.core.money import Money
30
+
31
+ if TYPE_CHECKING:
32
+ from datetime import date
33
+
34
+ from glidepath.core.provenance import Decision, Fact
35
+
36
+ _ZERO = Decimal(0)
37
+ _ZERO_MONEY = Money(_ZERO)
38
+ _MONTHS_PER_YEAR = 12
39
+ _UPRATING_CONTEXT = "policy.state_pension.uprating"
40
+
41
+
42
+ @dataclass(frozen=True, slots=True)
43
+ class StatePensionRecord:
44
+ """One person's state pension position (planning §5.1).
45
+
46
+ The official DWP forecast is the fact and the only route to an
47
+ amount — free and instant from gov.uk/check-state-pension — so the
48
+ model never re-derives what DWP has already computed;
49
+ ``protected_payment`` is the slice of that forecast that uprates by
50
+ CPI only (a pre-2016 transitional amount), so it may not appear
51
+ without one. ``forecast_weekly_amount`` is optional only so plans
52
+ saved before the forecast became mandatory still load; a region
53
+ refuses to answer for a record without one. ``deferral_years``
54
+ shifts the start past state pension age in whole months and earns
55
+ the region's deferral increments.
56
+ """
57
+
58
+ forecast_weekly_amount: Fact[Money] | None
59
+ protected_payment: Fact[Money] | None
60
+ deferral_years: Decision[Decimal]
61
+
62
+ def __post_init__(self) -> None:
63
+ """Reject internally inconsistent records loudly."""
64
+ forecast = self.forecast_weekly_amount
65
+ protected = self.protected_payment
66
+ if forecast is not None and forecast.value < _ZERO_MONEY:
67
+ msg = "StatePensionRecord.forecast_weekly_amount must be non-negative"
68
+ raise ValueError(msg)
69
+ if protected is not None:
70
+ if forecast is None:
71
+ msg = (
72
+ "StatePensionRecord.protected_payment is a slice of the"
73
+ " official forecast and requires one (planning §5.1)"
74
+ )
75
+ raise ValueError(msg)
76
+ if not _ZERO_MONEY <= protected.value <= forecast.value:
77
+ msg = (
78
+ "StatePensionRecord.protected_payment must lie between"
79
+ " zero and the forecast weekly amount"
80
+ )
81
+ raise ValueError(msg)
82
+ deferral = self.deferral_years.value
83
+ if deferral < _ZERO:
84
+ msg = "StatePensionRecord.deferral_years must be non-negative"
85
+ raise ValueError(msg)
86
+ if (deferral * _MONTHS_PER_YEAR) % 1 != 0:
87
+ msg = (
88
+ "StatePensionRecord.deferral_years must be a whole number of"
89
+ " months (the §4.1 whole-month convention)"
90
+ )
91
+ raise ValueError(msg)
92
+
93
+
94
+ def deferral_months(record: StatePensionRecord) -> int:
95
+ """The record's deferral as whole months (validated at construction)."""
96
+ return int(record.deferral_years.value * _MONTHS_PER_YEAR)
97
+
98
+
99
+ @dataclass(frozen=True, slots=True)
100
+ class StatePensionEntitlement:
101
+ """A region's answer for one record, in the rates the forecast states.
102
+
103
+ ``annual_amount`` uprates by the ``policy.state_pension.uprating``
104
+ assumption; ``cpi_uprated_annual_amount`` (any protected payment)
105
+ uprates by CPI only (planning §5.1, §6). Both begin on
106
+ ``start_date``. ``deferral_uplift`` is the deferral increment as a
107
+ *fraction*: the increase applies to the rate payable **at claim**
108
+ — which includes upratings earned during deferment — so the engine
109
+ computes the increment amount in the starting period and uprates
110
+ it by CPI only from then on (planning §5.1, §6).
111
+ """
112
+
113
+ start_date: date
114
+ annual_amount: Money
115
+ cpi_uprated_annual_amount: Money
116
+ deferral_uplift: Decimal = _ZERO
117
+
118
+ def __post_init__(self) -> None:
119
+ """Reject negative entitlements."""
120
+ if self.annual_amount < _ZERO_MONEY:
121
+ msg = "StatePensionEntitlement.annual_amount must be non-negative"
122
+ raise ValueError(msg)
123
+ if self.cpi_uprated_annual_amount < _ZERO_MONEY:
124
+ msg = (
125
+ "StatePensionEntitlement.cpi_uprated_annual_amount must be non-negative"
126
+ )
127
+ raise ValueError(msg)
128
+ if self.deferral_uplift < _ZERO:
129
+ msg = "StatePensionEntitlement.deferral_uplift must be non-negative"
130
+ raise ValueError(msg)
131
+
132
+
133
+ class StatePensionScheme(Protocol):
134
+ """Region-supplied state pension rules (planning §4.2).
135
+
136
+ Turns a record into an entitlement using the region's data: the
137
+ state pension age timetable and deferral increments from the age
138
+ rules. The amount itself is the record's stated DWP forecast — the
139
+ region computes no rates of its own (planning §5.1).
140
+ """
141
+
142
+ def entitlement(
143
+ self, record: StatePensionRecord, date_of_birth: date
144
+ ) -> StatePensionEntitlement:
145
+ """The record's entitlement in the rates the forecast states."""
146
+ ...
147
+
148
+
149
+ class UpratingRule(Enum):
150
+ """How the main state pension amount grows each year (planning §7)."""
151
+
152
+ CPI = auto()
153
+ """CPI-only uprating (the alternative scenario)."""
154
+ TRIPLE_LOCK = auto()
155
+ """Triple-lock proxy: ``max(CPI + earnings margin, floor)``."""
156
+
157
+
158
+ @dataclass(frozen=True, slots=True)
159
+ class StatePensionUprating:
160
+ """The parsed ``policy.state_pension.uprating`` assumption value.
161
+
162
+ The triple lock — ``max(earnings, CPI, floor)`` — is proxied as
163
+ ``max(CPI + deterministic_cpi_margin, floor)``, the margin
164
+ standing in for the long-run earnings premium (planning §7). The
165
+ proxy applies in *every* run mode: CPI is deterministic across
166
+ Monte Carlo paths by design and no earnings series is modelled,
167
+ so each path uprates the state pension identically (issue #112).
168
+ """
169
+
170
+ rule: UpratingRule
171
+ floor: Decimal | None = None
172
+ cpi_margin: Decimal | None = None
173
+
174
+ def __post_init__(self) -> None:
175
+ """Require the triple-lock parameters exactly when they apply."""
176
+ needs_parameters = self.rule is UpratingRule.TRIPLE_LOCK
177
+ if needs_parameters:
178
+ consistent = self.floor is not None and self.cpi_margin is not None
179
+ else:
180
+ consistent = self.floor is None and self.cpi_margin is None
181
+ if not consistent:
182
+ msg = (
183
+ f"{_UPRATING_CONTEXT}: floor and deterministic_cpi_margin are"
184
+ " required exactly for the triple_lock rule"
185
+ )
186
+ raise EngineError(msg)
187
+
188
+ @classmethod
189
+ def from_assumption_value(cls, value: object) -> StatePensionUprating:
190
+ """Parse the assumption's value: a rule tag or a parameter table.
191
+
192
+ Accepts the bare tag ``"cpi"`` (the alternative scenario of
193
+ planning §7) or a table with a ``rule`` key plus the
194
+ triple-lock parameters.
195
+
196
+ Raises:
197
+ EngineError: If the value has any other shape.
198
+ """
199
+ if isinstance(value, str):
200
+ return cls(rule=_parse_rule(value))
201
+ if not isinstance(value, Mapping):
202
+ msg = (
203
+ f"{_UPRATING_CONTEXT}: expected a rule tag or table,"
204
+ f" got {type(value).__name__}"
205
+ )
206
+ raise EngineError(msg)
207
+ entries = dict(value)
208
+ rule = _parse_rule(_take_string(entries, "rule"))
209
+ floor = _take_decimal(entries, "floor")
210
+ margin = _take_decimal(entries, "deterministic_cpi_margin")
211
+ if entries:
212
+ unknown = ", ".join(sorted(entries))
213
+ msg = f"{_UPRATING_CONTEXT}: unknown keys: {unknown}"
214
+ raise EngineError(msg)
215
+ return cls(rule=rule, floor=floor, cpi_margin=margin)
216
+
217
+ def annual_rate(self, cpi: Decimal) -> Decimal:
218
+ """The main amount's uprating rate in a year whose CPI is ``cpi``.
219
+
220
+ Never negative: statutory uprating leaves rates unchanged when
221
+ the relevant index falls (planning §5.1), so a deflationary CPI
222
+ assumption freezes the pension rather than cutting it.
223
+ """
224
+ if (
225
+ self.rule is UpratingRule.TRIPLE_LOCK
226
+ and self.floor is not None
227
+ and self.cpi_margin is not None
228
+ ):
229
+ return max(cpi + self.cpi_margin, self.floor, _ZERO)
230
+ return max(cpi, _ZERO)
231
+
232
+
233
+ def _parse_rule(raw: str) -> UpratingRule:
234
+ """Parse a rule tag (``"cpi"`` or ``"triple_lock"``)."""
235
+ by_tag: Mapping[str, UpratingRule] = {
236
+ "cpi": UpratingRule.CPI,
237
+ "triple_lock": UpratingRule.TRIPLE_LOCK,
238
+ }
239
+ rule = by_tag.get(raw)
240
+ if rule is None:
241
+ known = ", ".join(sorted(by_tag))
242
+ msg = f"{_UPRATING_CONTEXT}: unknown rule {raw!r} (one of: {known})"
243
+ raise EngineError(msg)
244
+ return rule
245
+
246
+
247
+ def _take_string(entries: dict[str, object], key: str) -> str:
248
+ """Pop a required string entry from the assumption table."""
249
+ raw = entries.pop(key, None)
250
+ if not isinstance(raw, str):
251
+ msg = f"{_UPRATING_CONTEXT}.{key}: expected a string tag"
252
+ raise EngineError(msg)
253
+ return raw
254
+
255
+
256
+ def _take_decimal(entries: dict[str, object], key: str) -> Decimal | None:
257
+ """Pop an optional Decimal entry from the assumption table."""
258
+ raw = entries.pop(key, None)
259
+ if raw is None:
260
+ return None
261
+ if not isinstance(raw, Decimal):
262
+ msg = f"{_UPRATING_CONTEXT}.{key}: expected a Decimal value"
263
+ raise EngineError(msg)
264
+ return raw
glidepath/core/tax.py ADDED
@@ -0,0 +1,139 @@
1
+ """Generic tax-assessment shapes crossing the core/region boundary.
2
+
3
+ Implements the planning §4.2 boundary: the core never knows band names or
4
+ policy figures. A region's :class:`TaxSystem` assesses a
5
+ :class:`TaxInput` — categorised gross income plus an opaque residency
6
+ id — for one period and returns a :class:`TaxResult` whose band labels
7
+ are region-supplied strings.
8
+ """
9
+
10
+ from dataclasses import dataclass
11
+ from decimal import Decimal
12
+ from typing import TYPE_CHECKING, Protocol
13
+
14
+ from glidepath.core.money import Money
15
+
16
+ if TYPE_CHECKING:
17
+ from glidepath.core.entities import TaxResidencyId
18
+ from glidepath.core.money import Rate
19
+ from glidepath.core.periods import Period
20
+
21
+ _ZERO = Money(Decimal(0))
22
+
23
+
24
+ @dataclass(frozen=True, slots=True)
25
+ class TaxInput:
26
+ """One person's categorised gross income for a single period.
27
+
28
+ ``non_savings_income`` is the employment/pension/property ladder;
29
+ ``savings_income`` (interest) and ``dividend_income`` arise from
30
+ taxable-growth wrappers (roadmap 9.2). How the categories stack —
31
+ ordering, nil rates, which schedule each uses — is wholly the
32
+ region's concern (planning §4.2).
33
+
34
+ ``relief_at_source_contributions`` is the period's gross member
35
+ pension contributions paid under a relief-at-source mechanic
36
+ (roadmap 3.2): basic-rate relief already arrived at source, and the
37
+ region's assessment grants the higher rates — e.g. by extending its
38
+ band thresholds — and deducts the gross amount from any
39
+ allowance-taper income measure. Net-pay contributions never appear
40
+ here: they leave pay before tax, so the caller excludes them from
41
+ ``non_savings_income``.
42
+ """
43
+
44
+ residency: TaxResidencyId
45
+ non_savings_income: Money
46
+ savings_income: Money = _ZERO
47
+ dividend_income: Money = _ZERO
48
+ relief_at_source_contributions: Money = _ZERO
49
+
50
+ def __post_init__(self) -> None:
51
+ """Reject negative amounts."""
52
+ amounts = (
53
+ ("non_savings_income", self.non_savings_income),
54
+ ("savings_income", self.savings_income),
55
+ ("dividend_income", self.dividend_income),
56
+ (
57
+ "relief_at_source_contributions",
58
+ self.relief_at_source_contributions,
59
+ ),
60
+ )
61
+ for name, amount in amounts:
62
+ if amount < _ZERO:
63
+ msg = f"TaxInput.{name} must be non-negative"
64
+ raise ValueError(msg)
65
+
66
+
67
+ @dataclass(frozen=True, slots=True)
68
+ class TaxLine:
69
+ """Tax charged within one region-defined band of an assessment."""
70
+
71
+ band: str
72
+ rate: Rate
73
+ taxed: Money
74
+ tax: Money
75
+
76
+ def __post_init__(self) -> None:
77
+ """Reject unnamed bands and negative amounts."""
78
+ if not self.band:
79
+ msg = "TaxLine.band must not be empty"
80
+ raise ValueError(msg)
81
+ if self.taxed < _ZERO or self.tax < _ZERO:
82
+ msg = "TaxLine amounts must be non-negative"
83
+ raise ValueError(msg)
84
+
85
+
86
+ @dataclass(frozen=True, slots=True)
87
+ class TaxResult:
88
+ """The outcome of one period's assessment for one person.
89
+
90
+ ``lines`` is the band-by-band breakdown. ``tax_due`` must equal the
91
+ sum of the line taxes (enforced), so any result that exists is
92
+ self-consistent.
93
+ """
94
+
95
+ tax_due: Money
96
+ taxable_income: Money
97
+ tax_free_allowance: Money
98
+ lines: tuple[TaxLine, ...]
99
+
100
+ def __post_init__(self) -> None:
101
+ """Require non-negative amounts and a consistent breakdown."""
102
+ if self.taxable_income < _ZERO or self.tax_free_allowance < _ZERO:
103
+ msg = "TaxResult amounts must be non-negative"
104
+ raise ValueError(msg)
105
+ total = sum((line.tax for line in self.lines), start=_ZERO)
106
+ if self.tax_due != total:
107
+ msg = "TaxResult.tax_due must equal the sum of its lines"
108
+ raise ValueError(msg)
109
+
110
+
111
+ class TaxSystem(Protocol):
112
+ """Region-supplied tax assessment (planning §4.2).
113
+
114
+ The same function serves both the withdrawal gross-up iteration and
115
+ the final period assessment (planning §5.2 steps 4-5), so the two are
116
+ consistent by construction.
117
+ """
118
+
119
+ def assess(self, period: Period, tax_input: TaxInput) -> TaxResult:
120
+ """Assess ``tax_input`` for ``period`` under this region's rules."""
121
+ ...
122
+
123
+ def annual_allowance_charge(
124
+ self, period: Period, tax_input: TaxInput, excess: Money
125
+ ) -> tuple[TaxLine, ...]:
126
+ """Price the tax on a pension-input excess for ``period``.
127
+
128
+ ``excess`` is the chargeable excess a region's annual-allowance
129
+ measurement produced
130
+ (:meth:`~glidepath.core.contributions.ContributionRuleset.annual_allowance`);
131
+ ``tax_input`` is the same full income picture the period's
132
+ final assessment sees, fixing the excess's position in the
133
+ region's rate structure. The excess is a charge, not income —
134
+ it must never feed back into ``assess`` (it would distort
135
+ income-measured allowances) — so it prices here as separate
136
+ lines the engine appends to the final result. A region without
137
+ such a charge returns no lines.
138
+ """
139
+ ...