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,504 @@
|
|
|
1
|
+
"""Projection results, snapshots, and run provenance (roadmap 4.1; planning §5.2).
|
|
2
|
+
|
|
3
|
+
The engine emits one :class:`PeriodSnapshot` per period — balances,
|
|
4
|
+
flows by category, tax breakdown, ages, stage, and allocation per
|
|
5
|
+
person and wrapper (planning §5.2 step 8) — and returns them in a
|
|
6
|
+
:class:`ProjectionResult` whose :class:`RunProvenance` lists the facts
|
|
7
|
+
used, the assumptions actually read (default vs overridden), the
|
|
8
|
+
decision variables in effect, the region data version, and the seed:
|
|
9
|
+
exactly the payload the UI's "stated vs assumed" inspector renders
|
|
10
|
+
(planning §5.1).
|
|
11
|
+
|
|
12
|
+
All monetary snapshot fields are quantized ledger writes (planning
|
|
13
|
+
§4.6): the engine rounds at period close, so consumers never see
|
|
14
|
+
sub-penny amounts.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from decimal import Decimal
|
|
19
|
+
from typing import TYPE_CHECKING, Any
|
|
20
|
+
|
|
21
|
+
from glidepath.core.money import Money
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from collections.abc import Callable
|
|
25
|
+
from datetime import date
|
|
26
|
+
|
|
27
|
+
from glidepath.core.config import RunConfig
|
|
28
|
+
from glidepath.core.entities import EntityId, Household
|
|
29
|
+
from glidepath.core.glide import LifeStage
|
|
30
|
+
from glidepath.core.investments import AssetAllocation
|
|
31
|
+
from glidepath.core.pensions import DBPension
|
|
32
|
+
from glidepath.core.periods import Period
|
|
33
|
+
from glidepath.core.provenance import Assumption, Decision, Fact
|
|
34
|
+
from glidepath.core.returns import PeriodReturns
|
|
35
|
+
from glidepath.core.tax import TaxResult
|
|
36
|
+
from glidepath.core.wrappers import WrapperKindId
|
|
37
|
+
|
|
38
|
+
_ZERO = Money(Decimal(0))
|
|
39
|
+
_ZERO_FACTOR = Decimal(0)
|
|
40
|
+
_ONE_FACTOR = Decimal(1)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True, slots=True)
|
|
44
|
+
class WrapperPeriodResult:
|
|
45
|
+
"""One wrapper's balances and flows through one period (§5.2 step 8).
|
|
46
|
+
|
|
47
|
+
Pension sub-balances are tracked separately: ``uncrystallised``
|
|
48
|
+
funds have not been accessed; ``crystallised`` funds are already in
|
|
49
|
+
drawdown (planning §5.1). Non-pension kinds keep the crystallised
|
|
50
|
+
fields at zero. ``employee_contribution`` is the gross amount that
|
|
51
|
+
landed in the pot (of which ``provider_relief`` arrived from the
|
|
52
|
+
provider's at-source reclaim); ``contribution_bonus`` is a
|
|
53
|
+
government bonus credited on top of it (the UK LISA's 25%, roadmap
|
|
54
|
+
9.2); ``contribution_shortfall`` is the intended amount that could
|
|
55
|
+
not be contributed (per-kind caps or the region's relief limits).
|
|
56
|
+
``annuity_purchase`` is capital that left the wrapper to buy
|
|
57
|
+
annuity income this period (roadmap 5.5) — the purchase's tax-free
|
|
58
|
+
cash element is paid out through ``withdrawal_tax_free`` instead,
|
|
59
|
+
exactly like an up-front lump sum.
|
|
60
|
+
|
|
61
|
+
Taxable-growth wrappers (roadmap 9.2): ``taxable_interest`` and
|
|
62
|
+
``taxable_dividends`` are the period's portfolio income entering
|
|
63
|
+
the tax assessment's savings and dividend layers; ``growth_tax``
|
|
64
|
+
is the tax attributable to that income actually charged to the
|
|
65
|
+
wrapper at period close — capped at what the account then holds,
|
|
66
|
+
with any unfunded remainder joining the person's shortfall
|
|
67
|
+
(planning §5.2). ``aa_charge`` is the slice of the period's priced
|
|
68
|
+
annual-allowance charge this wrapper actually funded at close
|
|
69
|
+
(#124) — a scheme-pays debit on a pension wrapper, or the cash
|
|
70
|
+
route on a bare taxable one — under the same close-settlement
|
|
71
|
+
convention as ``growth_tax``; it is not a withdrawal and appears
|
|
72
|
+
in no ``withdrawal_*`` flow. ``banked_in`` is decumulation surplus
|
|
73
|
+
swept into this wrapper — income or gross draws beyond the
|
|
74
|
+
period's need (planning §5.2). ``growth`` may be negative (a down
|
|
75
|
+
period); every other flow is non-negative.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
wrapper_id: EntityId
|
|
79
|
+
kind: WrapperKindId
|
|
80
|
+
allocation: AssetAllocation
|
|
81
|
+
opening_uncrystallised: Money
|
|
82
|
+
opening_crystallised: Money
|
|
83
|
+
employee_contribution: Money
|
|
84
|
+
employer_contribution: Money
|
|
85
|
+
provider_relief: Money
|
|
86
|
+
contribution_shortfall: Money
|
|
87
|
+
withdrawal_tax_free: Money
|
|
88
|
+
withdrawal_taxable: Money
|
|
89
|
+
fee: Money
|
|
90
|
+
growth: Money
|
|
91
|
+
closing_uncrystallised: Money
|
|
92
|
+
closing_crystallised: Money
|
|
93
|
+
annuity_purchase: Money = _ZERO
|
|
94
|
+
contribution_bonus: Money = _ZERO
|
|
95
|
+
taxable_interest: Money = _ZERO
|
|
96
|
+
taxable_dividends: Money = _ZERO
|
|
97
|
+
growth_tax: Money = _ZERO
|
|
98
|
+
aa_charge: Money = _ZERO
|
|
99
|
+
banked_in: Money = _ZERO
|
|
100
|
+
|
|
101
|
+
def __post_init__(self) -> None:
|
|
102
|
+
"""Reject negative amounts in the non-negative fields."""
|
|
103
|
+
non_negative = (
|
|
104
|
+
self.opening_uncrystallised,
|
|
105
|
+
self.opening_crystallised,
|
|
106
|
+
self.employee_contribution,
|
|
107
|
+
self.employer_contribution,
|
|
108
|
+
self.provider_relief,
|
|
109
|
+
self.contribution_shortfall,
|
|
110
|
+
self.withdrawal_tax_free,
|
|
111
|
+
self.withdrawal_taxable,
|
|
112
|
+
self.fee,
|
|
113
|
+
self.closing_uncrystallised,
|
|
114
|
+
self.closing_crystallised,
|
|
115
|
+
self.annuity_purchase,
|
|
116
|
+
self.contribution_bonus,
|
|
117
|
+
self.taxable_interest,
|
|
118
|
+
self.taxable_dividends,
|
|
119
|
+
self.growth_tax,
|
|
120
|
+
self.aa_charge,
|
|
121
|
+
self.banked_in,
|
|
122
|
+
)
|
|
123
|
+
if any(amount < _ZERO for amount in non_negative):
|
|
124
|
+
msg = "WrapperPeriodResult amounts (except growth) must be non-negative"
|
|
125
|
+
raise ValueError(msg)
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def opening_balance(self) -> Money:
|
|
129
|
+
"""Both sub-balances at period open."""
|
|
130
|
+
return self.opening_uncrystallised + self.opening_crystallised
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def closing_balance(self) -> Money:
|
|
134
|
+
"""Both sub-balances at period close."""
|
|
135
|
+
return self.closing_uncrystallised + self.closing_crystallised
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def withdrawal_gross(self) -> Money:
|
|
139
|
+
"""The period's total gross withdrawal from this wrapper."""
|
|
140
|
+
return self.withdrawal_tax_free + self.withdrawal_taxable
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@dataclass(frozen=True, slots=True)
|
|
144
|
+
class PersonPeriodResult:
|
|
145
|
+
"""One person's position through one period (§5.2 step 8).
|
|
146
|
+
|
|
147
|
+
``spending_need`` is the period's net (after-tax) spending target in
|
|
148
|
+
nominal money — zero before decumulation; ``net_withdrawn`` is the
|
|
149
|
+
net cash the withdrawal step delivered toward it; ``shortfall`` is
|
|
150
|
+
the need left unmet after the configured withdrawal strategy's plan
|
|
151
|
+
executed, plus any portfolio-income tax a drained taxable wrapper
|
|
152
|
+
could not fund (roadmap 9.2) and any slice of the annual-allowance
|
|
153
|
+
charge no wrapper could fund (#124) — the ruin signal the success
|
|
154
|
+
metrics of roadmap 7.3 read.
|
|
155
|
+
Under the default net-defined strategy a shortfall means every
|
|
156
|
+
accessible wrapper was exhausted; a gross-defined strategy (e.g.
|
|
157
|
+
fixed-%) may report one with balances still standing, because its
|
|
158
|
+
draw follows the pot, not the need (planning §5.2).
|
|
159
|
+
|
|
160
|
+
``db_income`` and ``state_pension_income`` are the period's DB and
|
|
161
|
+
state pension income actually in payment (revalued/uprated,
|
|
162
|
+
pro-rated from their exact start dates, §4.1); ``db_lump_sum`` is
|
|
163
|
+
the gross commutation cash received when a DB pension starts this
|
|
164
|
+
period (roadmap 4.2/4.3) — tax-free up to the remaining lump-sum
|
|
165
|
+
allowance, the excess taxed as income (roadmap 5.2).
|
|
166
|
+
``annuity_income`` is purchased annuity income in payment
|
|
167
|
+
(escalated per its type, pro-rated from its exact start date), and
|
|
168
|
+
``annuity_lump_sum`` is the tax-free cash delivered alongside an
|
|
169
|
+
annuity purchase's crystallisation of uncrystallised funds this
|
|
170
|
+
period, capped by the remaining lump-sum allowance headroom
|
|
171
|
+
(roadmap 5.5).
|
|
172
|
+
``planned_outflows`` is the nominal total
|
|
173
|
+
of the household's dated one-offs landing this period (roadmap
|
|
174
|
+
5.4) — a net need on top of ``spending_need``, so the shortfall
|
|
175
|
+
accounting covers both.
|
|
176
|
+
|
|
177
|
+
Tax-free cash tracking (roadmap 5.2): ``pension_lump_sum`` is
|
|
178
|
+
up-front tax-free cash delivered by a whole-pot crystallisation
|
|
179
|
+
this period (the ``UP_FRONT_LUMP_SUM`` event). Like
|
|
180
|
+
``annuity_lump_sum``, it is a column view of cash the paying
|
|
181
|
+
wrapper also records in ``withdrawal_tax_free`` — and so inside
|
|
182
|
+
its gross withdrawals — never an addition to them: summing either
|
|
183
|
+
with the wrapper withdrawals double-counts the tax-free cash.
|
|
184
|
+
Phased (as-needed or split-payment) tax-free elements appear only
|
|
185
|
+
in ``withdrawal_tax_free``.
|
|
186
|
+
``lsa_used`` is the person's cumulative tax-free cash at period
|
|
187
|
+
end, including the pre-plan ``lsa_used`` fact;
|
|
188
|
+
``mpaa_triggered_on`` is the flexible-access trigger date in
|
|
189
|
+
effect at period end — the pre-plan fact or the in-run first
|
|
190
|
+
taxable pension draw — or ``None`` if never triggered.
|
|
191
|
+
|
|
192
|
+
``banked`` is the period's surplus swept into a taxable wrapper
|
|
193
|
+
(roadmap 9.2): in decumulation, income and gross draws beyond the
|
|
194
|
+
net need; before retirement, non-employment income already in
|
|
195
|
+
payment (an early DB start, a purchased annuity, the state
|
|
196
|
+
pension alongside work) net of its marginal tax and beyond the
|
|
197
|
+
period's planned outflows. The sweep lands in the first uncapped
|
|
198
|
+
taxable account rather than being spent — zero when the person
|
|
199
|
+
holds none (planning §5.2).
|
|
200
|
+
"""
|
|
201
|
+
|
|
202
|
+
person_id: EntityId
|
|
203
|
+
age_at_period_start: int
|
|
204
|
+
years_to_retirement: int
|
|
205
|
+
stage: LifeStage
|
|
206
|
+
employment_income: Money
|
|
207
|
+
tax: TaxResult
|
|
208
|
+
spending_need: Money
|
|
209
|
+
net_withdrawn: Money
|
|
210
|
+
shortfall: Money
|
|
211
|
+
wrappers: tuple[WrapperPeriodResult, ...]
|
|
212
|
+
db_income: Money = _ZERO
|
|
213
|
+
db_lump_sum: Money = _ZERO
|
|
214
|
+
state_pension_income: Money = _ZERO
|
|
215
|
+
annuity_income: Money = _ZERO
|
|
216
|
+
annuity_lump_sum: Money = _ZERO
|
|
217
|
+
planned_outflows: Money = _ZERO
|
|
218
|
+
pension_lump_sum: Money = _ZERO
|
|
219
|
+
lsa_used: Money = _ZERO
|
|
220
|
+
mpaa_triggered_on: date | None = None
|
|
221
|
+
banked: Money = _ZERO
|
|
222
|
+
|
|
223
|
+
def __post_init__(self) -> None:
|
|
224
|
+
"""Reject negative flows."""
|
|
225
|
+
amounts = (
|
|
226
|
+
self.employment_income,
|
|
227
|
+
self.spending_need,
|
|
228
|
+
self.net_withdrawn,
|
|
229
|
+
self.shortfall,
|
|
230
|
+
self.db_income,
|
|
231
|
+
self.db_lump_sum,
|
|
232
|
+
self.state_pension_income,
|
|
233
|
+
self.annuity_income,
|
|
234
|
+
self.annuity_lump_sum,
|
|
235
|
+
self.planned_outflows,
|
|
236
|
+
self.pension_lump_sum,
|
|
237
|
+
self.lsa_used,
|
|
238
|
+
self.banked,
|
|
239
|
+
)
|
|
240
|
+
if any(amount < _ZERO for amount in amounts):
|
|
241
|
+
msg = "PersonPeriodResult amounts must be non-negative"
|
|
242
|
+
raise ValueError(msg)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@dataclass(frozen=True, slots=True)
|
|
246
|
+
class PeriodSnapshot:
|
|
247
|
+
"""The full ledger record of one projected period (§5.2 step 8).
|
|
248
|
+
|
|
249
|
+
``inflation_factor`` is the cumulative factor from the run's first
|
|
250
|
+
period to this one — the CPI path the engine inflated nominal
|
|
251
|
+
figures by, which the reporting layer deflates by (roadmap 4.4:
|
|
252
|
+
one inflation truth per run). The first period's factor is 1.
|
|
253
|
+
|
|
254
|
+
``year_fraction`` is the whole-month fraction of the period inside
|
|
255
|
+
the run window (roadmap 4.6, planning §5.2): 1 for a whole period;
|
|
256
|
+
less when ``today`` or the horizon end falls mid-period, in which
|
|
257
|
+
case the period's flows, fees, and growth were scaled by it.
|
|
258
|
+
"""
|
|
259
|
+
|
|
260
|
+
period: Period
|
|
261
|
+
returns: PeriodReturns
|
|
262
|
+
inflation_factor: Decimal
|
|
263
|
+
persons: tuple[PersonPeriodResult, ...]
|
|
264
|
+
year_fraction: Decimal = _ONE_FACTOR
|
|
265
|
+
|
|
266
|
+
def __post_init__(self) -> None:
|
|
267
|
+
"""Require a positive inflation factor and a fraction in [0, 1]."""
|
|
268
|
+
if self.inflation_factor <= _ZERO_FACTOR:
|
|
269
|
+
msg = "PeriodSnapshot.inflation_factor must be positive"
|
|
270
|
+
raise ValueError(msg)
|
|
271
|
+
if not _ZERO_FACTOR <= self.year_fraction <= _ONE_FACTOR:
|
|
272
|
+
msg = "PeriodSnapshot.year_fraction must lie between 0 and 1"
|
|
273
|
+
raise ValueError(msg)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
@dataclass(frozen=True, slots=True)
|
|
277
|
+
class LabelledFact:
|
|
278
|
+
"""One user-stated fact the run used, at a stable plan path."""
|
|
279
|
+
|
|
280
|
+
label: str
|
|
281
|
+
fact: Fact[Any]
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
@dataclass(frozen=True, slots=True)
|
|
285
|
+
class BalanceRollForward:
|
|
286
|
+
"""One stale stated amount rolled forward to the run start (§4.8).
|
|
287
|
+
|
|
288
|
+
A statement-dated fact — a wrapper balance, or the DWP forecast's
|
|
289
|
+
weekly rates — is dated ``as_of``; the engine opens the run with
|
|
290
|
+
``stated`` compounded over the ``months`` whole months from
|
|
291
|
+
``as_of`` to the run's ``today`` at the rate that governs it (the
|
|
292
|
+
wrapper's expected nominal return; the state pension uprating
|
|
293
|
+
rule, CPI-only for the protected slice) — ``factor`` is the exact
|
|
294
|
+
multiplier applied and ``opening`` the quantized result. The stated
|
|
295
|
+
fact itself is never altered: this record is how the estimate
|
|
296
|
+
layered on it stays visible rather than silent (planning §4.8).
|
|
297
|
+
``label`` addresses the fact at its stable plan path, exactly like
|
|
298
|
+
:class:`LabelledFact`.
|
|
299
|
+
"""
|
|
300
|
+
|
|
301
|
+
label: str
|
|
302
|
+
stated: Money
|
|
303
|
+
as_of: date
|
|
304
|
+
months: int
|
|
305
|
+
factor: Decimal
|
|
306
|
+
opening: Money
|
|
307
|
+
|
|
308
|
+
def __post_init__(self) -> None:
|
|
309
|
+
"""Reject records that could not describe a §4.8 roll-forward."""
|
|
310
|
+
if self.months <= 0:
|
|
311
|
+
msg = "BalanceRollForward.months must be positive"
|
|
312
|
+
raise ValueError(msg)
|
|
313
|
+
if self.factor <= _ZERO_FACTOR:
|
|
314
|
+
msg = "BalanceRollForward.factor must be positive"
|
|
315
|
+
raise ValueError(msg)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
@dataclass(frozen=True, slots=True)
|
|
319
|
+
class LabelledDecision:
|
|
320
|
+
"""One user choice in effect during the run, at a stable plan path."""
|
|
321
|
+
|
|
322
|
+
label: str
|
|
323
|
+
decision: Decision[Any]
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
@dataclass(frozen=True, slots=True)
|
|
327
|
+
class RunProvenance:
|
|
328
|
+
"""What a run's numbers rest on (planning §5.1, §4.6).
|
|
329
|
+
|
|
330
|
+
``assumptions`` lists every assumption actually read, in first-read
|
|
331
|
+
order, each carrying its own default-vs-overridden provenance —
|
|
332
|
+
the engine-side read tracking makes this exhaustive with no UI
|
|
333
|
+
bookkeeping (planning §5.1).
|
|
334
|
+
|
|
335
|
+
``balance_roll_forwards`` lists every statement-dated fact the run
|
|
336
|
+
rolled forward from its ``as_of`` to ``today`` (planning §4.8) —
|
|
337
|
+
wrapper balances and the state pension forecast's weekly rates —
|
|
338
|
+
empty when every such fact was stated within a whole month of the
|
|
339
|
+
run start.
|
|
340
|
+
"""
|
|
341
|
+
|
|
342
|
+
facts: tuple[LabelledFact, ...]
|
|
343
|
+
decisions: tuple[LabelledDecision, ...]
|
|
344
|
+
assumptions: tuple[Assumption[Any], ...]
|
|
345
|
+
region_data_version: str
|
|
346
|
+
seed: int | None
|
|
347
|
+
balance_roll_forwards: tuple[BalanceRollForward, ...] = ()
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
@dataclass(frozen=True, slots=True)
|
|
351
|
+
class ProjectionResult:
|
|
352
|
+
"""One deterministic projection: the period ledger plus provenance.
|
|
353
|
+
|
|
354
|
+
``config`` is the exact :class:`~glidepath.core.RunConfig` the run
|
|
355
|
+
received — today, horizon, mode, seed — so a result carries the
|
|
356
|
+
configuration part of its §4.6 manifest (the full persisted
|
|
357
|
+
manifest is Phase 6 work).
|
|
358
|
+
"""
|
|
359
|
+
|
|
360
|
+
snapshots: tuple[PeriodSnapshot, ...]
|
|
361
|
+
provenance: RunProvenance
|
|
362
|
+
config: RunConfig
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _note_db_pension_facts(
|
|
366
|
+
pension: DBPension, note: Callable[[str, Fact[Any] | None], None]
|
|
367
|
+
) -> None:
|
|
368
|
+
"""One DB pension's facts under its stable entity-id prefix (§5.1)."""
|
|
369
|
+
prefix = f"db_pension[{pension.id}]"
|
|
370
|
+
note(f"{prefix}.accrued_annual_pension", pension.accrued_annual_pension)
|
|
371
|
+
note(f"{prefix}.normal_pension_age", pension.normal_pension_age)
|
|
372
|
+
note(f"{prefix}.commutation_factor", pension.commutation_factor)
|
|
373
|
+
membership = pension.active_membership
|
|
374
|
+
if membership is not None:
|
|
375
|
+
note(f"{prefix}.active_membership.accrual_rate", membership.accrual_rate)
|
|
376
|
+
note(
|
|
377
|
+
f"{prefix}.active_membership.pensionable_salary",
|
|
378
|
+
membership.pensionable_salary,
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def collect_plan_facts(household: Household) -> tuple[LabelledFact, ...]:
|
|
383
|
+
"""Every user-stated fact in the plan, at stable entity-id paths.
|
|
384
|
+
|
|
385
|
+
The engine's inputs are total — a projection reads the whole plan —
|
|
386
|
+
so the facts used are the facts present (planning §5.1).
|
|
387
|
+
"""
|
|
388
|
+
facts: list[LabelledFact] = []
|
|
389
|
+
|
|
390
|
+
def note(label: str, fact: Fact[Any] | None) -> None:
|
|
391
|
+
"""Record ``fact`` under ``label`` when present."""
|
|
392
|
+
if fact is not None:
|
|
393
|
+
facts.append(LabelledFact(label=label, fact=fact))
|
|
394
|
+
|
|
395
|
+
if household.spending is not None:
|
|
396
|
+
note(
|
|
397
|
+
"household.spending.annual_spending_real",
|
|
398
|
+
household.spending.annual_spending_real,
|
|
399
|
+
)
|
|
400
|
+
for person in household.persons:
|
|
401
|
+
prefix = f"person[{person.id}]"
|
|
402
|
+
note(f"{prefix}.date_of_birth", person.date_of_birth)
|
|
403
|
+
note(f"{prefix}.sex_for_longevity", person.sex_for_longevity)
|
|
404
|
+
note(f"{prefix}.employment_income", person.employment_income)
|
|
405
|
+
note(f"{prefix}.mpaa_triggered_on", person.mpaa_triggered_on)
|
|
406
|
+
note(f"{prefix}.lsa_used", person.lsa_used)
|
|
407
|
+
for pension in person.db_pensions:
|
|
408
|
+
_note_db_pension_facts(pension, note)
|
|
409
|
+
if person.state_pension is not None:
|
|
410
|
+
record = person.state_pension
|
|
411
|
+
record_prefix = f"{prefix}.state_pension"
|
|
412
|
+
note(
|
|
413
|
+
f"{record_prefix}.forecast_weekly_amount",
|
|
414
|
+
record.forecast_weekly_amount,
|
|
415
|
+
)
|
|
416
|
+
note(f"{record_prefix}.protected_payment", record.protected_payment)
|
|
417
|
+
for wrapper in person.wrappers:
|
|
418
|
+
wrapper_prefix = f"wrapper[{wrapper.id}]"
|
|
419
|
+
note(f"{wrapper_prefix}.balance", wrapper.balance)
|
|
420
|
+
note(f"{wrapper_prefix}.crystallised_balance", wrapper.crystallised_balance)
|
|
421
|
+
if wrapper.contributions is not None:
|
|
422
|
+
note(
|
|
423
|
+
f"{wrapper_prefix}.contributions.employer_amount",
|
|
424
|
+
wrapper.contributions.employer_amount,
|
|
425
|
+
)
|
|
426
|
+
return tuple(facts)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def collect_plan_decisions(household: Household) -> tuple[LabelledDecision, ...]:
|
|
430
|
+
"""Every decision variable in effect, at stable entity-id paths.
|
|
431
|
+
|
|
432
|
+
Decisions are exactly the scenario what-if whitelist (planning
|
|
433
|
+
§4.3): retirement ages and contribution choices today; withdrawal
|
|
434
|
+
and annuity choices as later phases add them.
|
|
435
|
+
"""
|
|
436
|
+
decisions: list[LabelledDecision] = []
|
|
437
|
+
decisions.extend(
|
|
438
|
+
LabelledDecision(
|
|
439
|
+
label=f"planned_outflow[{outflow.id}].amount_real",
|
|
440
|
+
decision=outflow.amount_real,
|
|
441
|
+
)
|
|
442
|
+
for outflow in household.planned_outflows
|
|
443
|
+
)
|
|
444
|
+
for person in household.persons:
|
|
445
|
+
decisions.append(
|
|
446
|
+
LabelledDecision(
|
|
447
|
+
label=f"person[{person.id}].target_retirement_age",
|
|
448
|
+
decision=person.target_retirement_age,
|
|
449
|
+
)
|
|
450
|
+
)
|
|
451
|
+
for pension in person.db_pensions:
|
|
452
|
+
pension_prefix = f"db_pension[{pension.id}]"
|
|
453
|
+
if pension.taken_at_age is not None:
|
|
454
|
+
decisions.append(
|
|
455
|
+
LabelledDecision(
|
|
456
|
+
label=f"{pension_prefix}.taken_at_age",
|
|
457
|
+
decision=pension.taken_at_age,
|
|
458
|
+
)
|
|
459
|
+
)
|
|
460
|
+
decisions.append(
|
|
461
|
+
LabelledDecision(
|
|
462
|
+
label=f"{pension_prefix}.commuted_fraction",
|
|
463
|
+
decision=pension.commuted_fraction,
|
|
464
|
+
)
|
|
465
|
+
)
|
|
466
|
+
if (
|
|
467
|
+
pension.active_membership is not None
|
|
468
|
+
and pension.active_membership.active_until_age is not None
|
|
469
|
+
):
|
|
470
|
+
decisions.append(
|
|
471
|
+
LabelledDecision(
|
|
472
|
+
label=f"{pension_prefix}.active_membership.active_until_age",
|
|
473
|
+
decision=pension.active_membership.active_until_age,
|
|
474
|
+
)
|
|
475
|
+
)
|
|
476
|
+
for purchase in person.annuity_purchases:
|
|
477
|
+
purchase_prefix = f"annuity_purchase[{purchase.id}]"
|
|
478
|
+
decisions.append(
|
|
479
|
+
LabelledDecision(
|
|
480
|
+
label=f"{purchase_prefix}.at_age", decision=purchase.at_age
|
|
481
|
+
)
|
|
482
|
+
)
|
|
483
|
+
decisions.append(
|
|
484
|
+
LabelledDecision(
|
|
485
|
+
label=f"{purchase_prefix}.fraction_of_pot",
|
|
486
|
+
decision=purchase.fraction_of_pot,
|
|
487
|
+
)
|
|
488
|
+
)
|
|
489
|
+
if person.state_pension is not None:
|
|
490
|
+
decisions.append(
|
|
491
|
+
LabelledDecision(
|
|
492
|
+
label=f"person[{person.id}].state_pension.deferral_years",
|
|
493
|
+
decision=person.state_pension.deferral_years,
|
|
494
|
+
)
|
|
495
|
+
)
|
|
496
|
+
decisions.extend(
|
|
497
|
+
LabelledDecision(
|
|
498
|
+
label=f"wrapper[{wrapper.id}].contributions.employee_amount",
|
|
499
|
+
decision=wrapper.contributions.employee_amount,
|
|
500
|
+
)
|
|
501
|
+
for wrapper in person.wrappers
|
|
502
|
+
if wrapper.contributions is not None
|
|
503
|
+
)
|
|
504
|
+
return tuple(decisions)
|