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