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