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,278 @@
|
|
|
1
|
+
"""Scenario comparison report (roadmap 6.3; planning §4.3).
|
|
2
|
+
|
|
3
|
+
Comparison is a per-period metrics report across scenarios, computed
|
|
4
|
+
from each scenario's resolved run: :func:`run_scenarios` resolves every
|
|
5
|
+
scenario against the base plan (raising on orphans) and projects each
|
|
6
|
+
through the engine; :func:`compare_scenario_results` aligns the runs
|
|
7
|
+
period by period — household-level totals via the reporting layer, in
|
|
8
|
+
real (today's money) or nominal basis — and diffs every non-base run
|
|
9
|
+
against the base.
|
|
10
|
+
|
|
11
|
+
Rows cover the union of the runs' periods (a scenario overriding the
|
|
12
|
+
planning-age assumption projects a different horizon); a run simply has
|
|
13
|
+
no entry in a period it never modelled, and a delta appears only where
|
|
14
|
+
the base modelled the period too — over the *same* interval: a
|
|
15
|
+
horizon-clipped partial period (``year_fraction`` < 1) never diffs
|
|
16
|
+
against a whole one. Metric amounts are presentation values (quantized,
|
|
17
|
+
planning §4.6); deltas are exact differences of those.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from dataclasses import dataclass, fields
|
|
21
|
+
from operator import add, sub
|
|
22
|
+
from typing import TYPE_CHECKING
|
|
23
|
+
|
|
24
|
+
from glidepath.core.engine import run
|
|
25
|
+
from glidepath.core.reporting import ReportBasis, build_report
|
|
26
|
+
from glidepath.core.scenarios import ScenarioError, resolve_scenario
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from collections.abc import Callable, Sequence
|
|
30
|
+
from decimal import Decimal
|
|
31
|
+
|
|
32
|
+
from glidepath.core.config import RunConfig
|
|
33
|
+
from glidepath.core.entities import Household
|
|
34
|
+
from glidepath.core.money import Money
|
|
35
|
+
from glidepath.core.periods import Period
|
|
36
|
+
from glidepath.core.provenance import AssumptionSet
|
|
37
|
+
from glidepath.core.region import Region
|
|
38
|
+
from glidepath.core.reporting import PeriodReportRow
|
|
39
|
+
from glidepath.core.results import ProjectionResult
|
|
40
|
+
from glidepath.core.scenarios import Scenario
|
|
41
|
+
|
|
42
|
+
BASE_RUN_NAME = "base"
|
|
43
|
+
"""The reserved name of the unmodified base plan's run."""
|
|
44
|
+
|
|
45
|
+
_MIN_RUNS = 2
|
|
46
|
+
"""A comparison needs a baseline plus at least one other run."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True, slots=True)
|
|
50
|
+
class PeriodMetrics:
|
|
51
|
+
"""One run's household-level totals for one period (roadmap 6.3).
|
|
52
|
+
|
|
53
|
+
``income_total`` is income in payment (employment, DB, state
|
|
54
|
+
pension, annuity); ``lump_sums`` the one-off tax-advantaged cash
|
|
55
|
+
(DB commutation, up-front pension lump sums, annuity-purchase
|
|
56
|
+
tax-free cash). The pension and annuity elements of ``lump_sums``
|
|
57
|
+
are column views of tax-free cash the wrappers already carry in
|
|
58
|
+
``withdrawals_gross`` — only the DB commutation arrives from
|
|
59
|
+
outside the wrappers — so the two metrics overlap and must never
|
|
60
|
+
be summed. The rest carry the reporting layer's meanings.
|
|
61
|
+
Amounts follow the report's basis; a delta's amounts may be
|
|
62
|
+
negative.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
closing_balance: Money
|
|
66
|
+
income_total: Money
|
|
67
|
+
lump_sums: Money
|
|
68
|
+
tax_due: Money
|
|
69
|
+
contributions: Money
|
|
70
|
+
withdrawals_gross: Money
|
|
71
|
+
net_withdrawn: Money
|
|
72
|
+
spending_need: Money
|
|
73
|
+
planned_outflows: Money
|
|
74
|
+
shortfall: Money
|
|
75
|
+
|
|
76
|
+
def _combined(
|
|
77
|
+
self, other: PeriodMetrics, combine: Callable[[Money, Money], Money]
|
|
78
|
+
) -> PeriodMetrics:
|
|
79
|
+
"""Apply ``combine`` field-wise, producing new metrics."""
|
|
80
|
+
combined = {
|
|
81
|
+
field.name: combine(getattr(self, field.name), getattr(other, field.name))
|
|
82
|
+
for field in fields(self)
|
|
83
|
+
}
|
|
84
|
+
return PeriodMetrics(**combined)
|
|
85
|
+
|
|
86
|
+
def __add__(self, other: PeriodMetrics) -> PeriodMetrics:
|
|
87
|
+
"""Field-wise sum — totalling persons within a period."""
|
|
88
|
+
return self._combined(other, add)
|
|
89
|
+
|
|
90
|
+
def __sub__(self, other: PeriodMetrics) -> PeriodMetrics:
|
|
91
|
+
"""Field-wise difference — a scenario's delta vs the base."""
|
|
92
|
+
return self._combined(other, sub)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True, slots=True)
|
|
96
|
+
class ScenarioPeriodEntry:
|
|
97
|
+
"""One run's metrics in one period, with its delta vs the base.
|
|
98
|
+
|
|
99
|
+
``year_fraction`` is the whole-month fraction of the period this
|
|
100
|
+
run actually modelled (planning §5.2) — less than 1 when ``today``
|
|
101
|
+
or the run's horizon fell mid-period. ``delta_vs_base`` is ``None``
|
|
102
|
+
on the base run's own entries, on a period the base run never
|
|
103
|
+
modelled, and on a period the two runs modelled over *different*
|
|
104
|
+
intervals (unequal year fractions): flows over half a year minus
|
|
105
|
+
flows over a whole year is not a meaningful delta.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
run_name: str
|
|
109
|
+
metrics: PeriodMetrics
|
|
110
|
+
year_fraction: Decimal
|
|
111
|
+
delta_vs_base: PeriodMetrics | None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass(frozen=True, slots=True)
|
|
115
|
+
class ComparisonRow:
|
|
116
|
+
"""One period's metrics across the runs that modelled it.
|
|
117
|
+
|
|
118
|
+
Entries appear in run order (base first); a run without this
|
|
119
|
+
period contributes no entry.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
period: Period
|
|
123
|
+
entries: tuple[ScenarioPeriodEntry, ...]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass(frozen=True, slots=True)
|
|
127
|
+
class ScenarioComparison:
|
|
128
|
+
"""The per-period metrics report across scenarios (planning §4.3)."""
|
|
129
|
+
|
|
130
|
+
basis: ReportBasis
|
|
131
|
+
run_names: tuple[str, ...]
|
|
132
|
+
rows: tuple[ComparisonRow, ...]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def run_scenarios(
|
|
136
|
+
household: Household,
|
|
137
|
+
assumptions: AssumptionSet,
|
|
138
|
+
scenarios: Sequence[Scenario],
|
|
139
|
+
region_for: Callable[[AssumptionSet], Region],
|
|
140
|
+
config: RunConfig,
|
|
141
|
+
) -> tuple[tuple[str, ProjectionResult], ...]:
|
|
142
|
+
"""Project the base plan and every scenario's resolved inputs.
|
|
143
|
+
|
|
144
|
+
Returns named runs — the base first under :data:`BASE_RUN_NAME`,
|
|
145
|
+
then one per scenario in the given order — ready for
|
|
146
|
+
:func:`compare_scenario_results`.
|
|
147
|
+
|
|
148
|
+
``region_for`` builds the region bundle for one run from that
|
|
149
|
+
run's *effective* assumption set. Regions derive data from
|
|
150
|
+
assumptions at build time (e.g. the UK future-years tax extension
|
|
151
|
+
and state pension uprating read ``policy.tax.future_years``,
|
|
152
|
+
``inflation.cpi``, and ``policy.state_pension.uprating``), so a
|
|
153
|
+
prebuilt region shared across runs would silently pin every
|
|
154
|
+
scenario to the base policy and misreport the region data version;
|
|
155
|
+
the factory rebuilds it per resolved set instead. All runs share
|
|
156
|
+
``config``: run settings are not scenario-overridable in v1.
|
|
157
|
+
|
|
158
|
+
Raises:
|
|
159
|
+
ScenarioError: If a scenario has orphaned overrides, a
|
|
160
|
+
mistyped override value, a duplicate name, or the
|
|
161
|
+
reserved base name.
|
|
162
|
+
EngineError: If any resolved plan is not projectable.
|
|
163
|
+
"""
|
|
164
|
+
names = [scenario.name for scenario in scenarios]
|
|
165
|
+
if BASE_RUN_NAME in names or len(set(names)) != len(names):
|
|
166
|
+
msg = f"scenario names must be unique and none may be {BASE_RUN_NAME!r}"
|
|
167
|
+
raise ScenarioError(msg)
|
|
168
|
+
base_region = region_for(assumptions)
|
|
169
|
+
runs = [(BASE_RUN_NAME, run(household, assumptions, base_region, config))]
|
|
170
|
+
for scenario in scenarios:
|
|
171
|
+
resolution = resolve_scenario(household, assumptions, scenario)
|
|
172
|
+
runs.append(
|
|
173
|
+
(
|
|
174
|
+
scenario.name,
|
|
175
|
+
run(
|
|
176
|
+
resolution.household,
|
|
177
|
+
resolution.assumptions,
|
|
178
|
+
region_for(resolution.assumptions),
|
|
179
|
+
config,
|
|
180
|
+
),
|
|
181
|
+
)
|
|
182
|
+
)
|
|
183
|
+
return tuple(runs)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def compare_scenario_results(
|
|
187
|
+
runs: Sequence[tuple[str, ProjectionResult]],
|
|
188
|
+
basis: ReportBasis = ReportBasis.REAL,
|
|
189
|
+
) -> ScenarioComparison:
|
|
190
|
+
"""Diff named runs per period — the first run is the baseline.
|
|
191
|
+
|
|
192
|
+
Raises:
|
|
193
|
+
ValueError: If fewer than two runs are given or names repeat.
|
|
194
|
+
"""
|
|
195
|
+
if len(runs) < _MIN_RUNS:
|
|
196
|
+
msg = "a scenario comparison needs at least two runs"
|
|
197
|
+
raise ValueError(msg)
|
|
198
|
+
names = [name for name, _ in runs]
|
|
199
|
+
if len(set(names)) != len(names):
|
|
200
|
+
msg = "scenario comparison run names must be unique"
|
|
201
|
+
raise ValueError(msg)
|
|
202
|
+
per_run = [(name, _totals_by_period(result, basis)) for name, result in runs]
|
|
203
|
+
base_totals = per_run[0][1]
|
|
204
|
+
periods = sorted({period for _, totals in per_run for period in totals})
|
|
205
|
+
rows = []
|
|
206
|
+
for period in periods:
|
|
207
|
+
entries = []
|
|
208
|
+
base_total = base_totals.get(period)
|
|
209
|
+
for index, (name, totals) in enumerate(per_run):
|
|
210
|
+
total = totals.get(period)
|
|
211
|
+
if total is None:
|
|
212
|
+
continue
|
|
213
|
+
delta = None
|
|
214
|
+
if (
|
|
215
|
+
index > 0
|
|
216
|
+
and base_total is not None
|
|
217
|
+
and total.year_fraction == base_total.year_fraction
|
|
218
|
+
):
|
|
219
|
+
delta = total.metrics - base_total.metrics
|
|
220
|
+
entries.append(
|
|
221
|
+
ScenarioPeriodEntry(
|
|
222
|
+
run_name=name,
|
|
223
|
+
metrics=total.metrics,
|
|
224
|
+
year_fraction=total.year_fraction,
|
|
225
|
+
delta_vs_base=delta,
|
|
226
|
+
)
|
|
227
|
+
)
|
|
228
|
+
rows.append(ComparisonRow(period=period, entries=tuple(entries)))
|
|
229
|
+
return ScenarioComparison(basis=basis, run_names=tuple(names), rows=tuple(rows))
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@dataclass(frozen=True, slots=True)
|
|
233
|
+
class _PeriodTotals:
|
|
234
|
+
"""One run's household totals for one period, with its interval."""
|
|
235
|
+
|
|
236
|
+
metrics: PeriodMetrics
|
|
237
|
+
year_fraction: Decimal
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _totals_by_period(
|
|
241
|
+
result: ProjectionResult, basis: ReportBasis
|
|
242
|
+
) -> dict[Period, _PeriodTotals]:
|
|
243
|
+
"""Household-level period totals of one run, in the report's basis.
|
|
244
|
+
|
|
245
|
+
Persons within a period sum; the period's ``year_fraction`` is the
|
|
246
|
+
snapshot's own (one snapshot per period, so persons share it).
|
|
247
|
+
"""
|
|
248
|
+
totals: dict[Period, _PeriodTotals] = {}
|
|
249
|
+
for row in build_report(result, basis).rows:
|
|
250
|
+
metrics = _row_metrics(row)
|
|
251
|
+
existing = totals.get(row.period)
|
|
252
|
+
if existing is not None:
|
|
253
|
+
metrics = existing.metrics + metrics
|
|
254
|
+
totals[row.period] = _PeriodTotals(
|
|
255
|
+
metrics=metrics, year_fraction=row.year_fraction
|
|
256
|
+
)
|
|
257
|
+
return totals
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _row_metrics(row: PeriodReportRow) -> PeriodMetrics:
|
|
261
|
+
"""One person's report row reduced to the comparison metrics."""
|
|
262
|
+
return PeriodMetrics(
|
|
263
|
+
closing_balance=row.closing_balance,
|
|
264
|
+
income_total=(
|
|
265
|
+
row.employment_income
|
|
266
|
+
+ row.db_income
|
|
267
|
+
+ row.state_pension_income
|
|
268
|
+
+ row.annuity_income
|
|
269
|
+
),
|
|
270
|
+
lump_sums=row.db_lump_sum + row.pension_lump_sum + row.annuity_lump_sum,
|
|
271
|
+
tax_due=row.tax_due,
|
|
272
|
+
contributions=row.contributions,
|
|
273
|
+
withdrawals_gross=row.withdrawals_gross,
|
|
274
|
+
net_withdrawn=row.net_withdrawn,
|
|
275
|
+
spending_need=row.spending_need,
|
|
276
|
+
planned_outflows=row.planned_outflows,
|
|
277
|
+
shortfall=row.shortfall,
|
|
278
|
+
)
|
glidepath/core/config.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Run configuration for the projection engine (roadmap 4.1; planning §5.2).
|
|
2
|
+
|
|
3
|
+
Kept apart from the engine so the result types can carry the run's
|
|
4
|
+
configuration (part of the §4.6 run manifest) without an import cycle.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from enum import Enum, auto
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
from glidepath.core.withdrawals import (
|
|
12
|
+
FixedRealWithdrawalStrategy,
|
|
13
|
+
TaxFreeCashStrategy,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from datetime import date
|
|
18
|
+
|
|
19
|
+
from glidepath.core.withdrawals import WithdrawalStrategy
|
|
20
|
+
|
|
21
|
+
_DEFAULT_STRATEGY = FixedRealWithdrawalStrategy()
|
|
22
|
+
"""The v1 default withdrawal decision (planning §5.2): fixed real.
|
|
23
|
+
|
|
24
|
+
A frozen, stateless instance, so sharing one default across configs is
|
|
25
|
+
safe.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class EngineError(ValueError):
|
|
30
|
+
"""A projection request the engine cannot honour."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class RunMode(Enum):
|
|
34
|
+
"""Projection mode (planning §5.2).
|
|
35
|
+
|
|
36
|
+
The same step function runs under both modes; only the return model
|
|
37
|
+
differs (planning §5.2). ``MONTE_CARLO`` draws stochastic returns
|
|
38
|
+
from the run's seed — one substream per path (roadmap 7.3).
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
DETERMINISTIC = auto()
|
|
42
|
+
MONTE_CARLO = auto()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True, slots=True)
|
|
46
|
+
class RunConfig:
|
|
47
|
+
"""One run's configuration (planning §5.2, §4.6).
|
|
48
|
+
|
|
49
|
+
``today`` anchors the first period and defines "today's money" for
|
|
50
|
+
the reporting layer. ``horizon_end`` defaults to the date the (v1
|
|
51
|
+
single) person attains the ``horizon.planning_age`` assumption.
|
|
52
|
+
``seed`` is recorded in provenance and seeds the stochastic return
|
|
53
|
+
model's substreams (roadmap 7.1/7.2); a ``MONTE_CARLO`` run
|
|
54
|
+
requires it, and ``path`` names the substream the run draws from —
|
|
55
|
+
the path runner (roadmap 7.3) projects path *i* under
|
|
56
|
+
``replace(config, path=i)``, so any single path is re-runnable
|
|
57
|
+
from the seed and index alone (planning §4.6; a deterministic
|
|
58
|
+
model ignores the index). ``withdrawal_strategy`` is
|
|
59
|
+
the decumulation withdrawal decision (planning §5.2; roadmap 5.1),
|
|
60
|
+
defaulting to fixed real spending — it governs decumulation
|
|
61
|
+
periods only; planned outflows falling earlier are funded
|
|
62
|
+
net-defined in the default tax-aware order. ``tax_free_cash`` is
|
|
63
|
+
the orthogonal tax-free cash decision (roadmap 5.2), defaulting to
|
|
64
|
+
the split-each-payment mode.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
today: date
|
|
68
|
+
horizon_end: date | None = None
|
|
69
|
+
mode: RunMode = RunMode.DETERMINISTIC
|
|
70
|
+
seed: int | None = None
|
|
71
|
+
path: int = 0
|
|
72
|
+
withdrawal_strategy: WithdrawalStrategy = _DEFAULT_STRATEGY
|
|
73
|
+
tax_free_cash: TaxFreeCashStrategy = TaxFreeCashStrategy.SPLIT_EACH_PAYMENT
|
|
74
|
+
|
|
75
|
+
def __post_init__(self) -> None:
|
|
76
|
+
"""Reject a backwards horizon or a negative path index."""
|
|
77
|
+
if self.horizon_end is not None and self.horizon_end < self.today:
|
|
78
|
+
msg = f"horizon_end {self.horizon_end} precedes today {self.today}"
|
|
79
|
+
raise EngineError(msg)
|
|
80
|
+
if self.path < 0:
|
|
81
|
+
msg = f"path must be non-negative, got {self.path}"
|
|
82
|
+
raise EngineError(msg)
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
"""Contribution schedules and the relief-mechanics boundary (roadmap 3.2).
|
|
2
|
+
|
|
3
|
+
A :class:`ContributionSchedule` records what a person has *chosen* to pay
|
|
4
|
+
into one wrapper each year — the employee amount is a
|
|
5
|
+
:class:`~glidepath.core.provenance.Decision` (a scenario-overridable
|
|
6
|
+
choice, planning §4.3), the employer amount a
|
|
7
|
+
:class:`~glidepath.core.provenance.Fact` (employment terms). How tax
|
|
8
|
+
relief is delivered is the region's concern: the core defines the
|
|
9
|
+
mechanics vocabulary (:class:`~glidepath.core.wrappers.ReliefMechanic`)
|
|
10
|
+
and the outcome shape (:class:`MemberContributionOutcome`); a region's
|
|
11
|
+
:class:`ContributionRuleset` turns a gross contribution into cash flows
|
|
12
|
+
under its own relief rules and limits (planning §5.1).
|
|
13
|
+
|
|
14
|
+
Amounts are *gross* annual contributions — the amount intended to land
|
|
15
|
+
in the wrapper — so schedules are comparable across relief mechanics:
|
|
16
|
+
under relief at source the member pays less cash and the provider tops
|
|
17
|
+
the pot up to the gross amount, while under net pay the member's pay is
|
|
18
|
+
reduced by the full gross amount before tax.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from decimal import Decimal
|
|
23
|
+
from typing import TYPE_CHECKING, Protocol
|
|
24
|
+
|
|
25
|
+
from glidepath.core.money import Money
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from datetime import date
|
|
29
|
+
|
|
30
|
+
from glidepath.core.entities import EntityId
|
|
31
|
+
from glidepath.core.periods import Period
|
|
32
|
+
from glidepath.core.provenance import AssumptionKey, Decision, Fact
|
|
33
|
+
from glidepath.core.wrappers import ReliefMechanic
|
|
34
|
+
|
|
35
|
+
_ZERO = Money(Decimal(0))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True, slots=True)
|
|
39
|
+
class ContributionSchedule:
|
|
40
|
+
"""One wrapper's planned annual contributions (planning §5.1).
|
|
41
|
+
|
|
42
|
+
``employee_amount`` is the chosen *gross* annual contribution;
|
|
43
|
+
``employer_amount`` is the employer's annual contribution under the
|
|
44
|
+
employment terms (pension wrappers only). ``relief_mechanic`` is
|
|
45
|
+
``None`` for wrapper kinds whose contributions attract no relief
|
|
46
|
+
(the region's permitted-mechanics set is empty, e.g. an ISA).
|
|
47
|
+
``escalation`` names the assumption the engine grows the amounts by
|
|
48
|
+
(e.g. the earnings-growth assumption); applying it is the engine
|
|
49
|
+
step's job (roadmap 4.1).
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
employee_amount: Decision[Money]
|
|
53
|
+
employer_amount: Fact[Money] | None = None
|
|
54
|
+
relief_mechanic: ReliefMechanic | None = None
|
|
55
|
+
escalation: AssumptionKey | None = None
|
|
56
|
+
|
|
57
|
+
def __post_init__(self) -> None:
|
|
58
|
+
"""Reject negative contribution amounts."""
|
|
59
|
+
if self.employee_amount.value < _ZERO:
|
|
60
|
+
msg = "ContributionSchedule.employee_amount must be non-negative"
|
|
61
|
+
raise ValueError(msg)
|
|
62
|
+
if self.employer_amount is not None and self.employer_amount.value < _ZERO:
|
|
63
|
+
msg = "ContributionSchedule.employer_amount must be non-negative"
|
|
64
|
+
raise ValueError(msg)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True, slots=True)
|
|
68
|
+
class MemberContributionRequest:
|
|
69
|
+
"""One member contribution to resolve through a region's relief rules.
|
|
70
|
+
|
|
71
|
+
``gross`` is the intended gross contribution to one wrapper;
|
|
72
|
+
``relevant_earnings`` is the period's earned income the region's
|
|
73
|
+
relief limit measures against; ``date_of_birth`` lets the region
|
|
74
|
+
apply any relief age limits. Relief limits are per *person* per
|
|
75
|
+
period, shared across every wrapper and mechanic — so
|
|
76
|
+
``already_relieved_gross`` must carry the gross member
|
|
77
|
+
contributions relief has already been granted on this period
|
|
78
|
+
(across all the person's wrappers); the region grants relief only
|
|
79
|
+
on the remaining headroom. ``mechanic`` is ``None`` when the
|
|
80
|
+
wrapper kind attracts no relief.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
gross: Money
|
|
84
|
+
relevant_earnings: Money
|
|
85
|
+
date_of_birth: date
|
|
86
|
+
mechanic: ReliefMechanic | None = None
|
|
87
|
+
already_relieved_gross: Money = _ZERO
|
|
88
|
+
|
|
89
|
+
def __post_init__(self) -> None:
|
|
90
|
+
"""Reject negative monetary inputs."""
|
|
91
|
+
amounts = (self.gross, self.relevant_earnings, self.already_relieved_gross)
|
|
92
|
+
if any(amount < _ZERO for amount in amounts):
|
|
93
|
+
msg = "MemberContributionRequest amounts must be non-negative"
|
|
94
|
+
raise ValueError(msg)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(frozen=True, slots=True)
|
|
98
|
+
class MemberContributionOutcome:
|
|
99
|
+
"""One member contribution resolved through a region's relief rules.
|
|
100
|
+
|
|
101
|
+
``gross_to_pot`` is what lands in the wrapper;
|
|
102
|
+
``member_cash_cost`` is the cash the member pays (from taxed income
|
|
103
|
+
under relief at source, from gross pay under net pay);
|
|
104
|
+
``provider_relief`` is the top-up the provider reclaims at source;
|
|
105
|
+
``taxable_pay_deduction`` is the pre-tax pay reduction (net pay);
|
|
106
|
+
``assessment_relief_gross`` is the gross amount the tax assessment
|
|
107
|
+
must grant further relief on (feeds
|
|
108
|
+
:attr:`~glidepath.core.tax.TaxInput.relief_at_source_contributions`);
|
|
109
|
+
``unrelieved_excess`` is intended gross beyond the region's relief
|
|
110
|
+
limit — clipped and reported, never contributed or rerouted: a
|
|
111
|
+
schedule states intent for one wrapper, and a member's forgone
|
|
112
|
+
cash under a relief mechanic is not the gross amount (someone who
|
|
113
|
+
wants taxable saving schedules a GIA contribution directly).
|
|
114
|
+
|
|
115
|
+
The identity ``gross_to_pot == member_cash_cost + provider_relief``
|
|
116
|
+
is enforced, so any outcome that exists is internally consistent.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
gross_to_pot: Money
|
|
120
|
+
member_cash_cost: Money
|
|
121
|
+
provider_relief: Money
|
|
122
|
+
taxable_pay_deduction: Money
|
|
123
|
+
assessment_relief_gross: Money
|
|
124
|
+
unrelieved_excess: Money
|
|
125
|
+
|
|
126
|
+
def __post_init__(self) -> None:
|
|
127
|
+
"""Require non-negative amounts and the pot-cash-relief identity."""
|
|
128
|
+
amounts = (
|
|
129
|
+
self.gross_to_pot,
|
|
130
|
+
self.member_cash_cost,
|
|
131
|
+
self.provider_relief,
|
|
132
|
+
self.taxable_pay_deduction,
|
|
133
|
+
self.assessment_relief_gross,
|
|
134
|
+
self.unrelieved_excess,
|
|
135
|
+
)
|
|
136
|
+
if any(amount < _ZERO for amount in amounts):
|
|
137
|
+
msg = "MemberContributionOutcome amounts must be non-negative"
|
|
138
|
+
raise ValueError(msg)
|
|
139
|
+
if self.gross_to_pot != self.member_cash_cost + self.provider_relief:
|
|
140
|
+
msg = (
|
|
141
|
+
"MemberContributionOutcome.gross_to_pot must equal"
|
|
142
|
+
" member_cash_cost + provider_relief"
|
|
143
|
+
)
|
|
144
|
+
raise ValueError(msg)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@dataclass(frozen=True, slots=True)
|
|
148
|
+
class DbArrangementInput:
|
|
149
|
+
"""One DB arrangement's annual entitlement over a measured year.
|
|
150
|
+
|
|
151
|
+
``opening_annual`` is the accrued annual pension at the period's
|
|
152
|
+
open (before the year's accrual credit); ``closing_annual`` is the
|
|
153
|
+
entitlement at the period's close, the year's accrual and
|
|
154
|
+
revaluation included. How the pair values into a pension input
|
|
155
|
+
amount — valuation factor, inflation uplift, flooring — is wholly
|
|
156
|
+
the region's concern (planning §4.2).
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
opening_annual: Money
|
|
160
|
+
closing_annual: Money
|
|
161
|
+
|
|
162
|
+
def __post_init__(self) -> None:
|
|
163
|
+
"""Reject negative entitlements."""
|
|
164
|
+
if self.opening_annual < _ZERO or self.closing_annual < _ZERO:
|
|
165
|
+
msg = "DbArrangementInput amounts must be non-negative"
|
|
166
|
+
raise ValueError(msg)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@dataclass(frozen=True, slots=True)
|
|
170
|
+
class AnnualAllowanceMeasurement:
|
|
171
|
+
"""One person's pension inputs and income for one period (§5.2 step 5).
|
|
172
|
+
|
|
173
|
+
The engine's region-agnostic record of everything a region needs
|
|
174
|
+
to measure a year's pension savings against its cross-pension
|
|
175
|
+
allowances (roadmap 3.3): ``member_money_purchase`` is the gross
|
|
176
|
+
member contribution landed in pension wrappers (provider relief
|
|
177
|
+
included), ``employer_money_purchase`` the employer contributions
|
|
178
|
+
alongside them, and ``db_arrangements`` the DB entitlements the
|
|
179
|
+
region values into pension input amounts. ``total_income`` is the
|
|
180
|
+
period's taxable income *before* any member pension deduction;
|
|
181
|
+
``net_pay_contributions`` and ``relief_at_source_gross`` are the
|
|
182
|
+
member amounts each mechanic relieved, for the region's income
|
|
183
|
+
measures. ``cpi`` is the period's inflation rate (a region may
|
|
184
|
+
uprate DB opening values by it). ``mpaa_triggered_on`` is the
|
|
185
|
+
flexible-access trigger date *as it stood when the period's
|
|
186
|
+
contributions were made* — inputs paid before an in-period trigger
|
|
187
|
+
are measured pre-trigger (planning §5.2). ``scheme_member`` marks
|
|
188
|
+
membership of at least one pension arrangement this year, and
|
|
189
|
+
``carry_forward`` is the unused-allowance pool prior years left,
|
|
190
|
+
earliest first.
|
|
191
|
+
"""
|
|
192
|
+
|
|
193
|
+
member_money_purchase: Money
|
|
194
|
+
employer_money_purchase: Money
|
|
195
|
+
db_arrangements: tuple[DbArrangementInput, ...]
|
|
196
|
+
total_income: Money
|
|
197
|
+
net_pay_contributions: Money
|
|
198
|
+
relief_at_source_gross: Money
|
|
199
|
+
cpi: Decimal
|
|
200
|
+
mpaa_triggered_on: date | None
|
|
201
|
+
scheme_member: bool
|
|
202
|
+
carry_forward: tuple[Money, ...]
|
|
203
|
+
|
|
204
|
+
def __post_init__(self) -> None:
|
|
205
|
+
"""Reject negative monetary inputs."""
|
|
206
|
+
amounts = (
|
|
207
|
+
self.member_money_purchase,
|
|
208
|
+
self.employer_money_purchase,
|
|
209
|
+
self.total_income,
|
|
210
|
+
self.net_pay_contributions,
|
|
211
|
+
self.relief_at_source_gross,
|
|
212
|
+
*self.carry_forward,
|
|
213
|
+
)
|
|
214
|
+
if any(amount < _ZERO for amount in amounts):
|
|
215
|
+
msg = "AnnualAllowanceMeasurement amounts must be non-negative"
|
|
216
|
+
raise ValueError(msg)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@dataclass(frozen=True, slots=True)
|
|
220
|
+
class AnnualAllowanceOutcome:
|
|
221
|
+
"""A region's annual-allowance answer for one period (roadmap 3.3).
|
|
222
|
+
|
|
223
|
+
``chargeable_excess`` is the pension input beyond every allowance
|
|
224
|
+
the region operates — taper, money-purchase cap and carry-forward
|
|
225
|
+
already applied — for the tax assessment to charge at the region's
|
|
226
|
+
rates (:meth:`~glidepath.core.tax.TaxSystem.annual_allowance_charge`);
|
|
227
|
+
``carry_forward`` is the pool rolled forward one year for the next
|
|
228
|
+
period's measurement. A region without such machinery returns a
|
|
229
|
+
zero excess and an empty pool.
|
|
230
|
+
"""
|
|
231
|
+
|
|
232
|
+
chargeable_excess: Money
|
|
233
|
+
carry_forward: tuple[Money, ...]
|
|
234
|
+
|
|
235
|
+
def __post_init__(self) -> None:
|
|
236
|
+
"""Reject negative amounts."""
|
|
237
|
+
if self.chargeable_excess < _ZERO or any(
|
|
238
|
+
amount < _ZERO for amount in self.carry_forward
|
|
239
|
+
):
|
|
240
|
+
msg = "AnnualAllowanceOutcome amounts must be non-negative"
|
|
241
|
+
raise ValueError(msg)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@dataclass(frozen=True, slots=True)
|
|
245
|
+
class SchemeInput:
|
|
246
|
+
"""One pension wrapper's own input amount, for the funding split (#124).
|
|
247
|
+
|
|
248
|
+
``input_amount`` is the wrapper's own money-purchase pension input
|
|
249
|
+
for the period — member gross (provider relief included) plus
|
|
250
|
+
employer. DB streams are not schemes here: they have no modelled
|
|
251
|
+
pot to debit, so a charge their input generates always takes the
|
|
252
|
+
cash route (planning §5.2).
|
|
253
|
+
"""
|
|
254
|
+
|
|
255
|
+
wrapper_id: EntityId
|
|
256
|
+
input_amount: Money
|
|
257
|
+
|
|
258
|
+
def __post_init__(self) -> None:
|
|
259
|
+
"""Reject a negative input amount."""
|
|
260
|
+
if self.input_amount < _ZERO:
|
|
261
|
+
msg = "SchemeInput.input_amount must be non-negative"
|
|
262
|
+
raise ValueError(msg)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
@dataclass(frozen=True, slots=True)
|
|
266
|
+
class SchemePayment:
|
|
267
|
+
"""One scheme-funded debit of a period's priced AA charge (#124)."""
|
|
268
|
+
|
|
269
|
+
wrapper_id: EntityId
|
|
270
|
+
amount: Money
|
|
271
|
+
|
|
272
|
+
def __post_init__(self) -> None:
|
|
273
|
+
"""Reject a negative payment."""
|
|
274
|
+
if self.amount < _ZERO:
|
|
275
|
+
msg = "SchemePayment.amount must be non-negative"
|
|
276
|
+
raise ValueError(msg)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
@dataclass(frozen=True, slots=True)
|
|
280
|
+
class AnnualAllowanceFunding:
|
|
281
|
+
"""How a period's priced AA charge is funded (planning §5.2, #124).
|
|
282
|
+
|
|
283
|
+
``scheme_payments`` are debits against pension wrappers (the UK's
|
|
284
|
+
Scheme Pays — a scheme-administrator payment, not a member
|
|
285
|
+
withdrawal); ``cash`` falls to the person's bare taxable accounts
|
|
286
|
+
at period close, alongside the portfolio-income tax charge. The
|
|
287
|
+
split covers the whole charge; what a drained wrapper cannot fund
|
|
288
|
+
joins the person's shortfall (planning §5.2).
|
|
289
|
+
"""
|
|
290
|
+
|
|
291
|
+
scheme_payments: tuple[SchemePayment, ...]
|
|
292
|
+
cash: Money
|
|
293
|
+
|
|
294
|
+
def __post_init__(self) -> None:
|
|
295
|
+
"""Reject a negative cash share."""
|
|
296
|
+
if self.cash < _ZERO:
|
|
297
|
+
msg = "AnnualAllowanceFunding.cash must be non-negative"
|
|
298
|
+
raise ValueError(msg)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
class ContributionRuleset(Protocol):
|
|
302
|
+
"""Region-supplied contribution relief mechanics (planning §4.2).
|
|
303
|
+
|
|
304
|
+
Resolves one member contribution for one period under the region's
|
|
305
|
+
relief rules — gross-up at source, pre-tax deduction, and the
|
|
306
|
+
region's member relief limits. Which mechanics a wrapper kind may
|
|
307
|
+
operate is the wrapper ruleset's call
|
|
308
|
+
(:meth:`~glidepath.core.wrappers.WrapperRuleset.permitted_relief_mechanics`).
|
|
309
|
+
Cross-wrapper contribution measures — the pension annual allowance,
|
|
310
|
+
its taper, and any money-purchase cap — are the per-period
|
|
311
|
+
:meth:`annual_allowance` measurement (roadmap 3.3).
|
|
312
|
+
"""
|
|
313
|
+
|
|
314
|
+
def member_contribution(
|
|
315
|
+
self, request: MemberContributionRequest, period: Period
|
|
316
|
+
) -> MemberContributionOutcome:
|
|
317
|
+
"""Resolve one gross member contribution for ``period``."""
|
|
318
|
+
...
|
|
319
|
+
|
|
320
|
+
def annual_allowance(
|
|
321
|
+
self, measurement: AnnualAllowanceMeasurement, period: Period
|
|
322
|
+
) -> AnnualAllowanceOutcome:
|
|
323
|
+
"""Measure a period's pension inputs against the region's allowances."""
|
|
324
|
+
...
|
|
325
|
+
|
|
326
|
+
def annual_allowance_funding(
|
|
327
|
+
self, charge: Money, schemes: tuple[SchemeInput, ...], period: Period
|
|
328
|
+
) -> AnnualAllowanceFunding:
|
|
329
|
+
"""Split a period's priced AA charge between scheme pays and cash.
|
|
330
|
+
|
|
331
|
+
``charge`` is the priced charge the tax system appended to the
|
|
332
|
+
period's assessment; ``schemes`` are the person's pension
|
|
333
|
+
wrappers with their own input amounts. A region without
|
|
334
|
+
scheme-funded payment returns the whole charge as cash
|
|
335
|
+
(planning §5.2, #124).
|
|
336
|
+
"""
|
|
337
|
+
...
|