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
glidepath/core/engine.py
ADDED
|
@@ -0,0 +1,2811 @@
|
|
|
1
|
+
"""The projection engine (roadmap 4.1; planning §4.6, §5.2).
|
|
2
|
+
|
|
3
|
+
``run(plan, assumptions, region, config)`` is a pure function: no I/O,
|
|
4
|
+
no clock reads (``config.today`` is an input), no global state
|
|
5
|
+
(planning §4.6). The same step function runs under the deterministic
|
|
6
|
+
and Monte Carlo modes; only the return model — resolved from
|
|
7
|
+
``config.mode``, or injected — differs (planning §5.2). Within each
|
|
8
|
+
period the operation order is part of the spec (planning §5.2,
|
|
9
|
+
tested):
|
|
10
|
+
|
|
11
|
+
1. **Open** — resolve ages, stage, glide-path allocation (§4.1 gate
|
|
12
|
+
convention: retirement is attained only if reached by the period's
|
|
13
|
+
first day).
|
|
14
|
+
2. **Income** — employment income while accumulating, escalated by the
|
|
15
|
+
earnings-growth assumption; DB pension income (revalued in
|
|
16
|
+
deferment and increased in payment per the scheme basis, early/late
|
|
17
|
+
factors and commutation applied at start — roadmap 4.2); state
|
|
18
|
+
pension income (region entitlement, uprated per the
|
|
19
|
+
``policy.state_pension.uprating`` assumption with protected
|
|
20
|
+
payments and deferral increments uprating by CPI only — roadmap
|
|
21
|
+
4.3); and purchased annuity income (priced at purchase from the
|
|
22
|
+
annuity-rate assumptions, escalated per its product type — roadmap
|
|
23
|
+
5.5). Entitlements begin at their exact start dates and are
|
|
24
|
+
pro-rated by whole months within their starting period (§4.1).
|
|
25
|
+
3. **Contributions** — employee + employer per schedule, escalation,
|
|
26
|
+
per-kind caps, then the region's relief mechanics.
|
|
27
|
+
4. **Withdrawals** — in decumulation, the household's net (after-tax)
|
|
28
|
+
spending need is met from wrappers; net-defined draws gross up
|
|
29
|
+
against the region tax system by fixed-point iteration (capped,
|
|
30
|
+
residual settled at ledger precision).
|
|
31
|
+
5. **Tax** — one final assessment per person over the period's full
|
|
32
|
+
categorised income; the gross-up called the same function, so the
|
|
33
|
+
final assessment is consistent by construction.
|
|
34
|
+
6. **Fees** — platform + fund on average balances.
|
|
35
|
+
7. **Growth** — the period's returns on each wrapper's allocation
|
|
36
|
+
(fees before growth is enforced by ``apply_fees_and_growth``).
|
|
37
|
+
8. **Close** — quantize the ledger, emit the period snapshot.
|
|
38
|
+
|
|
39
|
+
v1 engine conventions, superseded as later phases land:
|
|
40
|
+
|
|
41
|
+
- Accumulation and decumulation switch together at the target
|
|
42
|
+
retirement age (§4.1 convention): employment income and
|
|
43
|
+
contributions run while years-to-retirement is positive; spending
|
|
44
|
+
withdrawals start once it is not.
|
|
45
|
+
- Withdrawals follow the configured strategy (roadmap 5.1): the
|
|
46
|
+
strategy plans net-defined or gross-defined draws over the drawable
|
|
47
|
+
sub-balances and the engine executes the plan, enforcing the
|
|
48
|
+
region's access gates. The default fixed-real strategy meets the
|
|
49
|
+
net need in the tax-aware order of planning §5.2 — taxable-growth
|
|
50
|
+
accounts (GIA/cash) first, then tax-free wrappers, then funds
|
|
51
|
+
already in drawdown (no fresh tax-free cash), then new pension
|
|
52
|
+
access — with every uncrystallised pot, tax-free kinds included,
|
|
53
|
+
subject to the region's access gate. In decumulation the net-of-tax
|
|
54
|
+
DB/state-pension income (and any commutation lump sum) received in
|
|
55
|
+
the period offsets the net spending need before wrappers are drawn;
|
|
56
|
+
income and gross draws beyond the need bank into the person's first
|
|
57
|
+
uncapped taxable wrapper (GIA/cash, roadmap 9.2) and are spent only
|
|
58
|
+
when they hold none.
|
|
59
|
+
- Planned outflows (roadmap 5.4) land whole in the period containing
|
|
60
|
+
the date their person attains the stated age — inside the run
|
|
61
|
+
window only — inflated from today's money by the period-start
|
|
62
|
+
price level. They join the period's net need: in decumulation the
|
|
63
|
+
configured strategy funds them after the income offset; before
|
|
64
|
+
decumulation they are funded net-defined in the default tax-aware
|
|
65
|
+
order, since the strategy is a decumulation decision. The income
|
|
66
|
+
offset applies in both phases: retirement income already in payment
|
|
67
|
+
before the target retirement age (an early DB start, a purchased
|
|
68
|
+
annuity, the state pension alongside work) meets the period's
|
|
69
|
+
outflows net of the marginal tax it adds on top of employment
|
|
70
|
+
income, and the remainder banks per roadmap 9.2. Employment income
|
|
71
|
+
itself never offsets or banks — net pay funds working-life
|
|
72
|
+
spending, which the model does not track.
|
|
73
|
+
- DB revaluation for the span before ``today`` — which the run never
|
|
74
|
+
models period-by-period — compounds the scheme basis over the whole
|
|
75
|
+
months from the statement date at the assumed CPI (planning §5.1);
|
|
76
|
+
within the run it advances with each period's CPI. A DB start date
|
|
77
|
+
before ``today`` means benefits are already in payment: income flows
|
|
78
|
+
from the run start and the commutation lump sum is treated as
|
|
79
|
+
already spent or banked in the user's stated balances.
|
|
80
|
+
- Tax-free cash (roadmap 5.2): pension draws resolve per the run's
|
|
81
|
+
``TaxFreeCashStrategy`` — split payments (the default), tax-free
|
|
82
|
+
cash first with the residue designated to drawdown, or the whole-pot
|
|
83
|
+
up-front crystallisation event whose lump sum joins the income
|
|
84
|
+
offset. Tax-free elements are capped by the region's lump-sum
|
|
85
|
+
allowance, tracked cumulatively from the ``lsa_used`` fact; an
|
|
86
|
+
in-run DB commutation lump sum consumes the same headroom in the
|
|
87
|
+
income step (ahead of wrapper draws) with its excess taxed as
|
|
88
|
+
income; the first taxable pension draw records the MPAA trigger
|
|
89
|
+
date unless the ``mpaa_triggered_on`` fact already set it.
|
|
90
|
+
Crystallised funds never yield fresh tax-free cash (planning §5.1).
|
|
91
|
+
- Annuity purchases (roadmap 5.5) fire in the period containing the
|
|
92
|
+
date their person attains the chosen age — inside the run window
|
|
93
|
+
only; a purchase age already attained is an engine error, since a
|
|
94
|
+
past purchase cannot be priced from a modelled pot. The chosen
|
|
95
|
+
fraction of every pension wrapper's sub-balances — the pot at the
|
|
96
|
+
period's open, before that period's contributions — converts into
|
|
97
|
+
income at the assumption-priced rate (base single-life-at-65 rate,
|
|
98
|
+
per-age multiplier, joint factor). Uncrystallised funds crystallise
|
|
99
|
+
on the way: the region's tax-free fraction is paid out (capped at
|
|
100
|
+
the remaining lump-sum-allowance headroom, the excess buying more
|
|
101
|
+
annuity) and joins the income offset; crystallised funds annuitise
|
|
102
|
+
whole. A lifetime annuity purchase never marks flexible access.
|
|
103
|
+
When an up-front crystallisation event lands in the same period,
|
|
104
|
+
the purchase — a step-2 income event — resolves first.
|
|
105
|
+
- Natural-yield pricing (roadmap 5.3): only for a withdrawal strategy
|
|
106
|
+
declaring ``uses_natural_yield`` does the engine price each drawable
|
|
107
|
+
source's period income from the per-asset ``yield.*`` assumptions,
|
|
108
|
+
so those keys enter the run's provenance exactly when a strategy
|
|
109
|
+
spends portfolio income.
|
|
110
|
+
- Wrapper balance facts are dated by their statement (planning §4.8):
|
|
111
|
+
each sub-balance rolls forward from its ``as_of`` to ``today`` over
|
|
112
|
+
whole months at the wrapper's expected nominal return net of its
|
|
113
|
+
fee drag — the deterministic composition, whatever the run mode,
|
|
114
|
+
with fees before growth as in every modelled period — with every
|
|
115
|
+
non-zero adjustment reported in the run's provenance; a balance
|
|
116
|
+
dated after ``today`` is an engine error (the DB statement-date
|
|
117
|
+
convention). The state pension forecast follows the same
|
|
118
|
+
convention: its weekly rates roll forward from ``forecast_as_of``
|
|
119
|
+
to ``today`` — the main slice at the uprating assumption's rate,
|
|
120
|
+
the protected slice by CPI only — and a future-dated forecast is an
|
|
121
|
+
engine error.
|
|
122
|
+
- Annual allowance (roadmap 3.3, 9.5): each period the year's pension
|
|
123
|
+
input amounts — member gross plus employer contributions into
|
|
124
|
+
pension wrappers, and each not-yet-in-payment DB stream's
|
|
125
|
+
opening/closing entitlement — are measured against the region's
|
|
126
|
+
allowances (taper, money-purchase cap, carry-forward), and any
|
|
127
|
+
chargeable excess is priced by the region as top-slice tax lines
|
|
128
|
+
appended to the period's final assessment. The measurement takes
|
|
129
|
+
the MPAA trigger standing when the contributions were made, so
|
|
130
|
+
inputs paid before an in-period step-4 trigger stay pre-trigger;
|
|
131
|
+
the carry-forward pool starts empty at the run start (pre-run
|
|
132
|
+
years' unused allowance is unknown — §4.1 conservative) and rolls
|
|
133
|
+
forward each period. Unlike employment tax (which settles outside
|
|
134
|
+
the model), the priced charge is funded from modelled balances
|
|
135
|
+
(#124): the region splits it between scheme pays — a debit against
|
|
136
|
+
the pension wrapper whose own input met the mandatory conditions —
|
|
137
|
+
and cash from the bare taxable wrappers, each settling at period
|
|
138
|
+
close after fees and growth exactly like the portfolio-income tax
|
|
139
|
+
charge; what no wrapper can fund joins the person's shortfall.
|
|
140
|
+
- Partial first and last periods (roadmap 4.6, planning §5.2): the run
|
|
141
|
+
models only the window from ``config.today`` through the horizon end.
|
|
142
|
+
A period partly outside that window has its flows (employment income,
|
|
143
|
+
contributions, spending need) pro-rated by whole months per §4.1, and
|
|
144
|
+
its annual fee rate and expected growth scaled linearly by the same
|
|
145
|
+
fraction — exact ``Decimal`` arithmetic, so §4.6 reproducibility
|
|
146
|
+
holds — while a stochastic return's deviation from the expectation
|
|
147
|
+
scales by the square root of the fraction (sigma times root-f, issue
|
|
148
|
+
#115; ``Decimal.sqrt`` is correctly rounded and deterministic). The
|
|
149
|
+
cumulative CPI and escalation factors likewise advance between
|
|
150
|
+
periods by the completed period's fraction, so later price and
|
|
151
|
+
earnings levels reflect the time actually modelled. Annual
|
|
152
|
+
caps, allowances, and tax bands stay whole-year: the months already
|
|
153
|
+
elapsed live in the balance facts, not the model, so the partial
|
|
154
|
+
year's pro-rated income meets full-year bands (accepted cost, §5.2).
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
from dataclasses import dataclass, field
|
|
158
|
+
from decimal import Decimal
|
|
159
|
+
from typing import TYPE_CHECKING
|
|
160
|
+
|
|
161
|
+
from glidepath.core.annuities import (
|
|
162
|
+
AnnuityRateTable,
|
|
163
|
+
AnnuityType,
|
|
164
|
+
annuity_base_rate_key,
|
|
165
|
+
annuity_start_date,
|
|
166
|
+
)
|
|
167
|
+
from glidepath.core.config import EngineError, RunConfig, RunMode
|
|
168
|
+
from glidepath.core.contributions import (
|
|
169
|
+
AnnualAllowanceMeasurement,
|
|
170
|
+
DbArrangementInput,
|
|
171
|
+
MemberContributionRequest,
|
|
172
|
+
SchemeInput,
|
|
173
|
+
)
|
|
174
|
+
from glidepath.core.entities import validate_household_v1
|
|
175
|
+
from glidepath.core.glide import (
|
|
176
|
+
LifeStage,
|
|
177
|
+
glide_path_from_shape,
|
|
178
|
+
years_to_target_retirement,
|
|
179
|
+
)
|
|
180
|
+
from glidepath.core.investments import AssetReturns, FeeSchedule, period_fee
|
|
181
|
+
from glidepath.core.money import Money, Rate
|
|
182
|
+
from glidepath.core.pensions import (
|
|
183
|
+
db_early_late_factor,
|
|
184
|
+
db_service_end_date,
|
|
185
|
+
db_start_date,
|
|
186
|
+
revaluation_factor_for_months,
|
|
187
|
+
)
|
|
188
|
+
from glidepath.core.periods import (
|
|
189
|
+
age_on,
|
|
190
|
+
date_age_attained,
|
|
191
|
+
entitlement_active_fraction,
|
|
192
|
+
period_active_fraction,
|
|
193
|
+
service_active_fraction,
|
|
194
|
+
whole_months_between,
|
|
195
|
+
)
|
|
196
|
+
from glidepath.core.provenance import (
|
|
197
|
+
AssumptionKey,
|
|
198
|
+
AssumptionReadRecorder,
|
|
199
|
+
TrackedAssumptions,
|
|
200
|
+
decimal_assumption_value,
|
|
201
|
+
int_assumption_value,
|
|
202
|
+
mapping_assumption_value,
|
|
203
|
+
)
|
|
204
|
+
from glidepath.core.results import (
|
|
205
|
+
BalanceRollForward,
|
|
206
|
+
PeriodSnapshot,
|
|
207
|
+
PersonPeriodResult,
|
|
208
|
+
ProjectionResult,
|
|
209
|
+
RunProvenance,
|
|
210
|
+
WrapperPeriodResult,
|
|
211
|
+
collect_plan_decisions,
|
|
212
|
+
collect_plan_facts,
|
|
213
|
+
)
|
|
214
|
+
from glidepath.core.returns import (
|
|
215
|
+
DeterministicReturnModel,
|
|
216
|
+
StochasticReturnModel,
|
|
217
|
+
nominal_rate,
|
|
218
|
+
)
|
|
219
|
+
from glidepath.core.state_pension import (
|
|
220
|
+
StatePensionEntitlement,
|
|
221
|
+
StatePensionUprating,
|
|
222
|
+
)
|
|
223
|
+
from glidepath.core.tax import TaxInput, TaxResult
|
|
224
|
+
from glidepath.core.withdrawals import (
|
|
225
|
+
FixedRealWithdrawalStrategy,
|
|
226
|
+
NetWithdrawalPlan,
|
|
227
|
+
TaxFreeCashStrategy,
|
|
228
|
+
WithdrawalSource,
|
|
229
|
+
WithdrawalSourceId,
|
|
230
|
+
WithdrawalState,
|
|
231
|
+
)
|
|
232
|
+
from glidepath.core.wrappers import (
|
|
233
|
+
ContributionTaxTreatment,
|
|
234
|
+
GrowthTaxTreatment,
|
|
235
|
+
WithdrawalTaxTreatment,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
if TYPE_CHECKING:
|
|
239
|
+
from collections.abc import Iterator
|
|
240
|
+
from datetime import date
|
|
241
|
+
|
|
242
|
+
from glidepath.core.annuities import AnnuityPurchase
|
|
243
|
+
from glidepath.core.contributions import (
|
|
244
|
+
AnnualAllowanceOutcome,
|
|
245
|
+
ContributionSchedule,
|
|
246
|
+
)
|
|
247
|
+
from glidepath.core.entities import Household, Person, SpendingPlan
|
|
248
|
+
from glidepath.core.glide import GlidePathConfig
|
|
249
|
+
from glidepath.core.investments import AssetAllocation
|
|
250
|
+
from glidepath.core.pensions import DBPension, RevaluationBasis
|
|
251
|
+
from glidepath.core.periods import Period
|
|
252
|
+
from glidepath.core.provenance import AssumptionSet, Fact
|
|
253
|
+
from glidepath.core.region import Region
|
|
254
|
+
from glidepath.core.returns import PeriodReturns, ReturnModel, ReturnModelFactory
|
|
255
|
+
from glidepath.core.state_pension import StatePensionRecord
|
|
256
|
+
from glidepath.core.withdrawals import GrossWithdrawalPlan, WithdrawalStrategy
|
|
257
|
+
from glidepath.core.wrappers import ContributionCap, Wrapper, WrapperTaxTreatment
|
|
258
|
+
|
|
259
|
+
_ZERO = Money(Decimal(0))
|
|
260
|
+
_ONE = Decimal(1)
|
|
261
|
+
_MINUS_ONE = Decimal(-1)
|
|
262
|
+
_MONTHS_PER_YEAR = Decimal(12)
|
|
263
|
+
_GROSS_UP_ITERATION_CAP = 48
|
|
264
|
+
"""Fixed-point iteration cap for the net-need gross-up (§5.2 step 4)."""
|
|
265
|
+
_NET_TOLERANCE = Money(Decimal("0.005"))
|
|
266
|
+
"""Half a penny: residuals below ledger precision are settled, not chased."""
|
|
267
|
+
_NO_FEES = FeeSchedule(platform=Rate(Decimal(0)), fund=Rate(Decimal(0)))
|
|
268
|
+
"""The schedule of a kind the region exempts from the default fees."""
|
|
269
|
+
_OUTFLOW_FUNDING = FixedRealWithdrawalStrategy()
|
|
270
|
+
"""Funds planned outflows falling before decumulation (roadmap 5.4).
|
|
271
|
+
|
|
272
|
+
The configured strategy is a *decumulation* decision; an outflow due
|
|
273
|
+
while still accumulating is simply a net cash need, met in the default
|
|
274
|
+
tax-aware order.
|
|
275
|
+
"""
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
@dataclass(slots=True)
|
|
279
|
+
class _WrapperLedger:
|
|
280
|
+
"""One wrapper's mutable working ledger for a single period.
|
|
281
|
+
|
|
282
|
+
``uncrystallised``/``crystallised`` are the running balances as the
|
|
283
|
+
period's flows apply; the ``opening_*`` fields keep the step-1
|
|
284
|
+
values for the snapshot and the average-balance fee base.
|
|
285
|
+
"""
|
|
286
|
+
|
|
287
|
+
wrapper: Wrapper
|
|
288
|
+
allocation: AssetAllocation
|
|
289
|
+
treatment: WrapperTaxTreatment
|
|
290
|
+
uncrystallised: Money
|
|
291
|
+
crystallised: Money
|
|
292
|
+
opening_uncrystallised: Money
|
|
293
|
+
opening_crystallised: Money
|
|
294
|
+
employee_in: Money = _ZERO
|
|
295
|
+
employer_in: Money = _ZERO
|
|
296
|
+
provider_relief: Money = _ZERO
|
|
297
|
+
bonus_in: Money = _ZERO
|
|
298
|
+
contribution_shortfall: Money = _ZERO
|
|
299
|
+
withdrawn_uncrystallised: Money = _ZERO
|
|
300
|
+
withdrawn_crystallised: Money = _ZERO
|
|
301
|
+
withdrawal_tax_free: Money = _ZERO
|
|
302
|
+
withdrawal_taxable: Money = _ZERO
|
|
303
|
+
annuity_purchase: Money = _ZERO
|
|
304
|
+
taxable_interest: Money = _ZERO
|
|
305
|
+
taxable_dividends: Money = _ZERO
|
|
306
|
+
growth_tax: Money = _ZERO
|
|
307
|
+
growth_tax_unfunded: Money = _ZERO
|
|
308
|
+
aa_charge: Money = _ZERO
|
|
309
|
+
aa_charge_unfunded: Money = _ZERO
|
|
310
|
+
banked_in: Money = _ZERO
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
@dataclass(slots=True)
|
|
314
|
+
class _WithdrawalSource:
|
|
315
|
+
"""One drawable sub-balance, with the tax-free fraction of a draw.
|
|
316
|
+
|
|
317
|
+
``access_open`` follows the §4.1 gate convention; a crystallised
|
|
318
|
+
sub-balance is always open (already accessed, never re-gated —
|
|
319
|
+
planning §5.1). ``pension`` marks a sub-balance of a
|
|
320
|
+
partially-tax-free (pension) wrapper kind: its tax-free cash
|
|
321
|
+
consumes the region's lump-sum allowance and its taxable draws
|
|
322
|
+
mark flexible access (roadmap 5.2).
|
|
323
|
+
"""
|
|
324
|
+
|
|
325
|
+
ledger: _WrapperLedger
|
|
326
|
+
crystallised: bool
|
|
327
|
+
tax_free_fraction: Decimal
|
|
328
|
+
access_open: bool = True
|
|
329
|
+
pension: bool = False
|
|
330
|
+
|
|
331
|
+
@property
|
|
332
|
+
def source_id(self) -> WithdrawalSourceId:
|
|
333
|
+
"""The stable key withdrawal plans reference this source by."""
|
|
334
|
+
return WithdrawalSourceId(
|
|
335
|
+
wrapper_id=self.ledger.wrapper.id, crystallised=self.crystallised
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
def view(self, natural_yield: Money = _ZERO) -> WithdrawalSource:
|
|
339
|
+
"""The frozen strategy-facing view of this sub-balance.
|
|
340
|
+
|
|
341
|
+
``natural_yield`` is the period income the engine priced for a
|
|
342
|
+
yield-aware strategy (roadmap 5.3); other strategies see zero.
|
|
343
|
+
"""
|
|
344
|
+
return WithdrawalSource(
|
|
345
|
+
id=self.source_id,
|
|
346
|
+
kind=self.ledger.wrapper.kind,
|
|
347
|
+
available=self.available,
|
|
348
|
+
tax_free_fraction=self.tax_free_fraction,
|
|
349
|
+
access_open=self.access_open,
|
|
350
|
+
natural_yield=natural_yield,
|
|
351
|
+
growth_taxable=(self.ledger.treatment.growth is GrowthTaxTreatment.TAXABLE),
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
@property
|
|
355
|
+
def available(self) -> Money:
|
|
356
|
+
"""What the sub-balance currently holds."""
|
|
357
|
+
if self.crystallised:
|
|
358
|
+
return self.ledger.crystallised
|
|
359
|
+
return self.ledger.uncrystallised
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
@dataclass(frozen=True, slots=True)
|
|
363
|
+
class _DrawTranche:
|
|
364
|
+
"""One linear slice of a draw on a sub-balance (roadmap 5.2).
|
|
365
|
+
|
|
366
|
+
Of every pound of ``gross``, ``free_share`` arrives as tax-free
|
|
367
|
+
cash and ``taxable_share`` as taxable income; the remainder — the
|
|
368
|
+
crystallised residue of a lump-sum-as-needed designation — moves
|
|
369
|
+
into the wrapper's crystallised sub-balance and stays invested.
|
|
370
|
+
``from_crystallised`` names the sub-balance the gross leaves (a
|
|
371
|
+
phased draw takes its income leg from the residue it just
|
|
372
|
+
designated). Within a tranche the shares are constant, so the
|
|
373
|
+
net-need fixed point of planning §5.2 step 4 converges exactly as
|
|
374
|
+
it does on a whole source; tranche caps are computed lazily at
|
|
375
|
+
draw time, so lump-sum-allowance headroom consumed by an earlier
|
|
376
|
+
draw is never double-counted.
|
|
377
|
+
"""
|
|
378
|
+
|
|
379
|
+
free_share: Decimal
|
|
380
|
+
taxable_share: Decimal
|
|
381
|
+
max_gross: Money
|
|
382
|
+
from_crystallised: bool
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
@dataclass(frozen=True, slots=True)
|
|
386
|
+
class _PeriodIncome:
|
|
387
|
+
"""One person's step-2 income amounts for one period (§5.2).
|
|
388
|
+
|
|
389
|
+
The gross entitlements the income step priced — employment,
|
|
390
|
+
DB/state-pension/annuity income in payment, and the period's
|
|
391
|
+
commutation and annuity-purchase lump sums — feeding the income
|
|
392
|
+
offset of steps 3-4 and the period result.
|
|
393
|
+
"""
|
|
394
|
+
|
|
395
|
+
employment: Money
|
|
396
|
+
db_income: Money
|
|
397
|
+
db_lump_sum: Money
|
|
398
|
+
annuity_income: Money
|
|
399
|
+
annuity_lump_sum: Money
|
|
400
|
+
state_pension: Money
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
class _NominalFactors:
|
|
404
|
+
"""Cumulative nominal escalation factors, one per assumption key.
|
|
405
|
+
|
|
406
|
+
Each registered key holds a *real* growth-rate assumption; after
|
|
407
|
+
each completed period its factor advances by the annual nominal
|
|
408
|
+
rate ``(1 + real)(1 + CPI) - 1`` scaled linearly by that period's
|
|
409
|
+
active fraction (planning §5.2, roadmap 4.6), so escalated amounts
|
|
410
|
+
stay nominal and a partial first period advances the level only by
|
|
411
|
+
the months actually modelled — never a whole year.
|
|
412
|
+
"""
|
|
413
|
+
|
|
414
|
+
__slots__ = ("_factors", "_real_rates")
|
|
415
|
+
|
|
416
|
+
def __init__(self, tracked: TrackedAssumptions, keys: set[AssumptionKey]) -> None:
|
|
417
|
+
"""Read each key's real rate through the tracked view.
|
|
418
|
+
|
|
419
|
+
Keys are read in sorted order so the run's recorded read order
|
|
420
|
+
— and therefore the serialized provenance — is identical across
|
|
421
|
+
processes (set iteration order is hash-salted; planning §4.6
|
|
422
|
+
demands byte-identical results from identical inputs).
|
|
423
|
+
"""
|
|
424
|
+
self._real_rates = {
|
|
425
|
+
key: decimal_assumption_value(tracked.get(key)) for key in sorted(keys)
|
|
426
|
+
}
|
|
427
|
+
self._factors = dict.fromkeys(self._real_rates, _ONE)
|
|
428
|
+
|
|
429
|
+
def advance(self, cpi: Decimal, fraction: Decimal) -> None:
|
|
430
|
+
"""Compound every factor by one completed period's nominal growth."""
|
|
431
|
+
for key, real in self._real_rates.items():
|
|
432
|
+
annual = (_ONE + real) * (_ONE + cpi) - _ONE
|
|
433
|
+
self._factors[key] *= _ONE + annual * fraction
|
|
434
|
+
|
|
435
|
+
def factor(self, key: AssumptionKey) -> Decimal:
|
|
436
|
+
"""The cumulative nominal factor for ``key`` (1 in period one)."""
|
|
437
|
+
return self._factors[key]
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
@dataclass(slots=True)
|
|
441
|
+
class _DbAccrual:
|
|
442
|
+
"""Active CARE-style accrual on a DB stream (roadmap 9.6).
|
|
443
|
+
|
|
444
|
+
``rate`` and ``salary`` are the scheme's accrual rate and stated
|
|
445
|
+
annual pensionable salary; the salary escalates with the
|
|
446
|
+
earnings-growth assumption at credit time like employment income.
|
|
447
|
+
``service_end`` is the exclusive date service stops (leave-and-defer
|
|
448
|
+
age, else the benefits start); the retirement gate may stop accrual
|
|
449
|
+
earlier (planning §5.1).
|
|
450
|
+
"""
|
|
451
|
+
|
|
452
|
+
rate: Decimal
|
|
453
|
+
salary: Money
|
|
454
|
+
service_end: date
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
@dataclass(slots=True)
|
|
458
|
+
class _DbStream:
|
|
459
|
+
"""One DB pension's income stream through the run (roadmap 4.2, 9.6).
|
|
460
|
+
|
|
461
|
+
``accrued_annual`` is the entitlement revalued to the period open —
|
|
462
|
+
statement date to ``today`` folded in at the assumed CPI pre-run —
|
|
463
|
+
*before* the early/late factor and commutation; ``advance`` carries
|
|
464
|
+
the within-run revaluation forward per period, both in deferment
|
|
465
|
+
and in payment (the single-basis convention, planning §5.1), and an
|
|
466
|
+
active stream's credits join it at each period's open. The payout
|
|
467
|
+
and lump-sum factors apply the early/late factor and the
|
|
468
|
+
commutation split when income or the one-shot lump sum is read.
|
|
469
|
+
"""
|
|
470
|
+
|
|
471
|
+
basis: RevaluationBasis
|
|
472
|
+
start: date
|
|
473
|
+
accrued_annual: Money
|
|
474
|
+
payout_factor: Decimal
|
|
475
|
+
lump_sum_factor: Decimal
|
|
476
|
+
accrual: _DbAccrual | None = None
|
|
477
|
+
|
|
478
|
+
def advance(self, cpi: Decimal, fraction: Decimal) -> None:
|
|
479
|
+
"""Compound one completed period's revaluation (§5.2 linear scaling)."""
|
|
480
|
+
self.accrued_annual = self.accrued_annual * (
|
|
481
|
+
_ONE + self.basis.annual_rate(cpi) * fraction
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
def credit(self, amount: Money) -> None:
|
|
485
|
+
"""Join one period's accrual at the period open (planning §5.1)."""
|
|
486
|
+
self.accrued_annual = self.accrued_annual + amount
|
|
487
|
+
|
|
488
|
+
def income_annual(self) -> Money:
|
|
489
|
+
"""The annual pension in payment, factors applied."""
|
|
490
|
+
return self.accrued_annual * self.payout_factor
|
|
491
|
+
|
|
492
|
+
def lump_sum(self) -> Money:
|
|
493
|
+
"""The commutation lump sum as of the benefits start."""
|
|
494
|
+
return self.accrued_annual * self.lump_sum_factor
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
@dataclass(slots=True)
|
|
498
|
+
class _StatePensionStream:
|
|
499
|
+
"""The person's state pension income stream (roadmap 4.3).
|
|
500
|
+
|
|
501
|
+
The entitlement's slices uprate separately (planning §5.1, §6):
|
|
502
|
+
the main amount by the ``policy.state_pension.uprating`` rule, the
|
|
503
|
+
protected payment by CPI only. State pension rates step by a full
|
|
504
|
+
year's uprating at each period boundary (upratings take effect
|
|
505
|
+
whole each April, which is exactly a UK period boundary), so —
|
|
506
|
+
unlike the continuous price/earnings levels — the advance is never
|
|
507
|
+
scaled by a partial period's active fraction (planning §5.1), and
|
|
508
|
+
the CPI-only step is floored at zero (statutory uprating never
|
|
509
|
+
cuts a rate).
|
|
510
|
+
|
|
511
|
+
The deferral ``increment`` is captured in the first paying period —
|
|
512
|
+
the uplift fraction applied to the rate then payable, upratings
|
|
513
|
+
earned through deferment included — and uprates by CPI only from
|
|
514
|
+
that point on (planning §5.1, §6).
|
|
515
|
+
"""
|
|
516
|
+
|
|
517
|
+
entitlement: StatePensionEntitlement
|
|
518
|
+
uprating: StatePensionUprating
|
|
519
|
+
policy_factor: Decimal = _ONE
|
|
520
|
+
cpi_factor: Decimal = _ONE
|
|
521
|
+
increment: Money | None = None
|
|
522
|
+
|
|
523
|
+
def advance(self, cpi: Decimal) -> None:
|
|
524
|
+
"""Step one period boundary's uprating, whole (class docstring)."""
|
|
525
|
+
self.policy_factor *= _ONE + self.uprating.annual_rate(cpi)
|
|
526
|
+
cpi_step = _ONE + max(cpi, Decimal(0))
|
|
527
|
+
self.cpi_factor *= cpi_step
|
|
528
|
+
if self.increment is not None:
|
|
529
|
+
self.increment = self.increment * cpi_step
|
|
530
|
+
|
|
531
|
+
def annual_amount(self) -> Money:
|
|
532
|
+
"""The paying period's full annual state pension, uprated to date."""
|
|
533
|
+
base = (
|
|
534
|
+
self.entitlement.annual_amount * self.policy_factor
|
|
535
|
+
+ self.entitlement.cpi_uprated_annual_amount * self.cpi_factor
|
|
536
|
+
)
|
|
537
|
+
if self.increment is None:
|
|
538
|
+
self.increment = base * self.entitlement.deferral_uplift
|
|
539
|
+
return base + self.increment
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
@dataclass(slots=True)
|
|
543
|
+
class _AnnuityStream:
|
|
544
|
+
"""One purchased annuity's income stream (roadmap 5.5).
|
|
545
|
+
|
|
546
|
+
``base_annual`` is the income the purchase bought — capital times
|
|
547
|
+
the priced rate, nominal at the purchase date. ``factor`` carries
|
|
548
|
+
the product's escalation forward per completed period under the
|
|
549
|
+
§5.2 linear scaling convention: nothing for a level annuity, the
|
|
550
|
+
table's fixed rate for an escalating one, the run's CPI for an
|
|
551
|
+
inflation-linked one (tracking the index exactly — annuity
|
|
552
|
+
contracts, unlike statutory upratings, are not floored at zero;
|
|
553
|
+
planning §5.1). A stream created mid-run first advances at the
|
|
554
|
+
period after its purchase, so its first period pays the purchased
|
|
555
|
+
rate pro-rated from the exact start date.
|
|
556
|
+
|
|
557
|
+
Escalation accrues from that exact start date, not the period
|
|
558
|
+
boundary: ``purchase_period_share`` is the entitlement's share of
|
|
559
|
+
the purchase period (the same share its first income pro-rates
|
|
560
|
+
by), and the first boundary advance consumes it in place of the
|
|
561
|
+
whole period's fraction — a mid-period purchase must not collect a
|
|
562
|
+
full period of escalation (§4.1 linear whole-month convention).
|
|
563
|
+
"""
|
|
564
|
+
|
|
565
|
+
annuity_type: AnnuityType
|
|
566
|
+
start: date
|
|
567
|
+
base_annual: Money
|
|
568
|
+
escalation: Rate
|
|
569
|
+
purchase_period_share: Decimal | None = None
|
|
570
|
+
factor: Decimal = _ONE
|
|
571
|
+
|
|
572
|
+
def advance(self, cpi: Decimal, fraction: Decimal) -> None:
|
|
573
|
+
"""Compound one completed period's escalation (§5.2 linear scaling)."""
|
|
574
|
+
share = fraction
|
|
575
|
+
if self.purchase_period_share is not None:
|
|
576
|
+
share = self.purchase_period_share
|
|
577
|
+
self.purchase_period_share = None
|
|
578
|
+
if self.annuity_type is AnnuityType.ESCALATING:
|
|
579
|
+
rate = self.escalation.value
|
|
580
|
+
elif self.annuity_type is AnnuityType.INFLATION_LINKED:
|
|
581
|
+
rate = cpi
|
|
582
|
+
else:
|
|
583
|
+
return
|
|
584
|
+
self.factor *= _ONE + rate * share
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def run(
|
|
588
|
+
plan: Household,
|
|
589
|
+
assumptions: AssumptionSet,
|
|
590
|
+
region: Region,
|
|
591
|
+
config: RunConfig,
|
|
592
|
+
*,
|
|
593
|
+
return_model_factory: ReturnModelFactory | None = None,
|
|
594
|
+
) -> ProjectionResult:
|
|
595
|
+
"""Project ``plan`` over the horizon (planning §5.2).
|
|
596
|
+
|
|
597
|
+
Pure and deterministic (planning §4.6): identical inputs produce an
|
|
598
|
+
identical result — under ``RunMode.MONTE_CARLO`` the randomness is
|
|
599
|
+
exactly determined by ``config.seed`` and ``config.path``, so any
|
|
600
|
+
single path is individually re-runnable. Every tunable number is
|
|
601
|
+
read through the assumption set and recorded; the result's
|
|
602
|
+
provenance lists the facts used, assumptions read, decisions in
|
|
603
|
+
effect, the region data version, and the seed.
|
|
604
|
+
|
|
605
|
+
The same step function runs under every mode; only the return
|
|
606
|
+
model differs (planning §5.2). ``config.mode`` selects it —
|
|
607
|
+
deterministic expected returns, or seeded stochastic draws where
|
|
608
|
+
``config.path`` names the substream (roadmap 7.3) — unless
|
|
609
|
+
``return_model_factory`` injects one built from the run's tracked
|
|
610
|
+
assumption view (a scripted sequence fixture, roadmap 7.4).
|
|
611
|
+
|
|
612
|
+
Raises:
|
|
613
|
+
EngineError: If the horizon is empty, the plan is not
|
|
614
|
+
projectable (v1: exactly one person), or a ``MONTE_CARLO``
|
|
615
|
+
config carries no seed.
|
|
616
|
+
"""
|
|
617
|
+
try:
|
|
618
|
+
validate_household_v1(plan)
|
|
619
|
+
except ValueError as exc:
|
|
620
|
+
raise EngineError(str(exc)) from exc
|
|
621
|
+
recorder = AssumptionReadRecorder()
|
|
622
|
+
tracked = TrackedAssumptions(assumptions=assumptions, recorder=recorder)
|
|
623
|
+
projection = _Projection(
|
|
624
|
+
plan=plan,
|
|
625
|
+
person=plan.persons[0],
|
|
626
|
+
region=region,
|
|
627
|
+
tracked=tracked,
|
|
628
|
+
config=config,
|
|
629
|
+
model=_return_model(config, tracked, return_model_factory),
|
|
630
|
+
)
|
|
631
|
+
snapshots = projection.execute()
|
|
632
|
+
provenance = RunProvenance(
|
|
633
|
+
facts=collect_plan_facts(plan),
|
|
634
|
+
decisions=collect_plan_decisions(plan),
|
|
635
|
+
assumptions=tuple(assumptions.get(key) for key in recorder.keys_read),
|
|
636
|
+
region_data_version=region.data_version,
|
|
637
|
+
seed=config.seed,
|
|
638
|
+
balance_roll_forwards=projection.roll_forwards,
|
|
639
|
+
)
|
|
640
|
+
return ProjectionResult(snapshots=snapshots, provenance=provenance, config=config)
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def _return_model(
|
|
644
|
+
config: RunConfig,
|
|
645
|
+
tracked: TrackedAssumptions,
|
|
646
|
+
factory: ReturnModelFactory | None,
|
|
647
|
+
) -> ReturnModel:
|
|
648
|
+
"""The run's return model — injected, or resolved from the mode.
|
|
649
|
+
|
|
650
|
+
The seed requirement binds before any injection: a ``MONTE_CARLO``
|
|
651
|
+
config labels its result a Monte Carlo run, and an unseeded one
|
|
652
|
+
could never be reproduced from its manifest, so it is rejected
|
|
653
|
+
rather than defaulted — whether or not a factory stands in for the
|
|
654
|
+
stochastic model (planning §4.6).
|
|
655
|
+
|
|
656
|
+
Raises:
|
|
657
|
+
EngineError: If a ``MONTE_CARLO`` config carries no seed.
|
|
658
|
+
"""
|
|
659
|
+
if config.mode is RunMode.MONTE_CARLO:
|
|
660
|
+
if config.seed is None:
|
|
661
|
+
msg = "RunMode.MONTE_CARLO requires RunConfig.seed (planning §4.6)"
|
|
662
|
+
raise EngineError(msg)
|
|
663
|
+
if factory is not None:
|
|
664
|
+
return factory(tracked)
|
|
665
|
+
return StochasticReturnModel(assumptions=tracked, seed=config.seed)
|
|
666
|
+
if factory is not None:
|
|
667
|
+
return factory(tracked)
|
|
668
|
+
return DeterministicReturnModel(assumptions=tracked)
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
@dataclass(slots=True)
|
|
672
|
+
class _Projection:
|
|
673
|
+
"""One run's working state: the loop of planning §5.2 over the horizon."""
|
|
674
|
+
|
|
675
|
+
plan: Household
|
|
676
|
+
person: Person
|
|
677
|
+
region: Region
|
|
678
|
+
tracked: TrackedAssumptions
|
|
679
|
+
config: RunConfig
|
|
680
|
+
model: ReturnModel
|
|
681
|
+
_balances: dict[str, tuple[Money, Money]] = field(default_factory=dict)
|
|
682
|
+
_taxable_income: Money = _ZERO
|
|
683
|
+
_savings_income: Money = _ZERO
|
|
684
|
+
_dividend_income: Money = _ZERO
|
|
685
|
+
_relief_at_source: Money = _ZERO
|
|
686
|
+
_net_pay_deductions: Money = _ZERO
|
|
687
|
+
_aa_carry_forward: tuple[Money, ...] = ()
|
|
688
|
+
_aa_charge_unallocated: Money = _ZERO
|
|
689
|
+
_db_openings: tuple[Money, ...] = ()
|
|
690
|
+
_mpaa_at_contributions: date | None = None
|
|
691
|
+
_expected_returns: AssetReturns | None = None
|
|
692
|
+
_db_streams: list[_DbStream] = field(default_factory=list)
|
|
693
|
+
_sp_stream: _StatePensionStream | None = None
|
|
694
|
+
_annuity_streams: list[_AnnuityStream] = field(default_factory=list)
|
|
695
|
+
_annuity_table: AnnuityRateTable | None = None
|
|
696
|
+
_lsa_used: Money = _ZERO
|
|
697
|
+
_mpaa_triggered_on: date | None = None
|
|
698
|
+
_roll_forwards: list[BalanceRollForward] = field(default_factory=list)
|
|
699
|
+
|
|
700
|
+
@property
|
|
701
|
+
def roll_forwards(self) -> tuple[BalanceRollForward, ...]:
|
|
702
|
+
"""The §4.8 balance adjustments recorded while seeding the ledger."""
|
|
703
|
+
return tuple(self._roll_forwards)
|
|
704
|
+
|
|
705
|
+
def execute(self) -> tuple[PeriodSnapshot, ...]:
|
|
706
|
+
"""Run the period loop and return the snapshots in order."""
|
|
707
|
+
if self.person.lsa_used is not None:
|
|
708
|
+
self._lsa_used = self.person.lsa_used.value
|
|
709
|
+
if self.person.mpaa_triggered_on is not None:
|
|
710
|
+
self._mpaa_triggered_on = self.person.mpaa_triggered_on.value
|
|
711
|
+
self._balances = self._opening_balances()
|
|
712
|
+
factors = _NominalFactors(self.tracked, self._escalation_keys())
|
|
713
|
+
self._build_income_streams()
|
|
714
|
+
inflation = _ONE
|
|
715
|
+
snapshots: list[PeriodSnapshot] = []
|
|
716
|
+
horizon_end = self._horizon_end()
|
|
717
|
+
previous_cpi: Decimal | None = None
|
|
718
|
+
previous_fraction = _ONE
|
|
719
|
+
for period in self.region.calendar.periods(self.config.today, horizon_end):
|
|
720
|
+
fraction = period_active_fraction(period, self.config.today, horizon_end)
|
|
721
|
+
returns = self.model.returns_for(period, self.config.path)
|
|
722
|
+
if previous_cpi is not None:
|
|
723
|
+
# Advance the price/earnings levels by the growth of the
|
|
724
|
+
# period just completed, scaled by its active fraction
|
|
725
|
+
# (§5.2, roadmap 4.6): a partial first period must not
|
|
726
|
+
# fast-forward a whole year of escalation.
|
|
727
|
+
inflation *= _ONE + previous_cpi * previous_fraction
|
|
728
|
+
factors.advance(previous_cpi, previous_fraction)
|
|
729
|
+
for stream in self._db_streams:
|
|
730
|
+
stream.advance(previous_cpi, previous_fraction)
|
|
731
|
+
for annuity in self._annuity_streams:
|
|
732
|
+
annuity.advance(previous_cpi, previous_fraction)
|
|
733
|
+
if self._sp_stream is not None:
|
|
734
|
+
self._sp_stream.advance(previous_cpi)
|
|
735
|
+
snapshots.append(
|
|
736
|
+
self._project_period(period, returns, inflation, factors, fraction)
|
|
737
|
+
)
|
|
738
|
+
previous_cpi = returns.cpi.value
|
|
739
|
+
previous_fraction = fraction
|
|
740
|
+
return tuple(snapshots)
|
|
741
|
+
|
|
742
|
+
def _opening_balances(self) -> dict[str, tuple[Money, Money]]:
|
|
743
|
+
"""Seed each wrapper's opening sub-balances at ``today`` (§4.8).
|
|
744
|
+
|
|
745
|
+
Every balance fact rolls forward from its statement ``as_of``
|
|
746
|
+
over whole months at the wrapper's expected nominal return net
|
|
747
|
+
of its fee drag, and each non-zero adjustment is recorded for
|
|
748
|
+
the run's provenance — an estimate layered on the stated fact
|
|
749
|
+
is never applied silently (planning §4.8).
|
|
750
|
+
"""
|
|
751
|
+
balances: dict[str, tuple[Money, Money]] = {}
|
|
752
|
+
for wrapper in self.person.wrappers:
|
|
753
|
+
prefix = f"wrapper[{wrapper.id}]"
|
|
754
|
+
uncrystallised = self._rolled_balance(
|
|
755
|
+
wrapper, wrapper.balance, f"{prefix}.balance"
|
|
756
|
+
)
|
|
757
|
+
crystallised = _ZERO
|
|
758
|
+
if wrapper.crystallised_balance is not None:
|
|
759
|
+
crystallised = self._rolled_balance(
|
|
760
|
+
wrapper,
|
|
761
|
+
wrapper.crystallised_balance,
|
|
762
|
+
f"{prefix}.crystallised_balance",
|
|
763
|
+
)
|
|
764
|
+
balances[wrapper.id] = (uncrystallised, crystallised)
|
|
765
|
+
return balances
|
|
766
|
+
|
|
767
|
+
def _rolled_balance(self, wrapper: Wrapper, fact: Fact[Money], label: str) -> Money:
|
|
768
|
+
"""One balance fact rolled forward from ``as_of`` to today (§4.8).
|
|
769
|
+
|
|
770
|
+
The whole-month convention makes a balance stated within one
|
|
771
|
+
month of ``today`` an exact no-op — no adjustment, no record.
|
|
772
|
+
The annual rate is the expected nominal return net of the
|
|
773
|
+
wrapper's fee drag — ``(1 + nominal)(1 - fees) - 1``, fees
|
|
774
|
+
before growth exactly as every modelled period charges them
|
|
775
|
+
(§5.2 step 6; issue #111) — and the factor compounds like the
|
|
776
|
+
DB statement-date convention: integer-exponent whole years,
|
|
777
|
+
linear remainder months, exact ``Decimal`` arithmetic
|
|
778
|
+
(planning §4.6). A fee-adjusted expected return of -100% per
|
|
779
|
+
year or worse is rejected — the same positive-gross invariant
|
|
780
|
+
the stochastic model enforces on its expectation — so the
|
|
781
|
+
factor is always strictly positive.
|
|
782
|
+
|
|
783
|
+
Raises:
|
|
784
|
+
EngineError: If the fact is dated after ``today``, or the
|
|
785
|
+
wrapper's fee-adjusted expected nominal gross return
|
|
786
|
+
is not positive.
|
|
787
|
+
"""
|
|
788
|
+
today = self.config.today
|
|
789
|
+
if fact.as_of > today:
|
|
790
|
+
msg = (
|
|
791
|
+
f"{label}: balance as_of {fact.as_of} is after today"
|
|
792
|
+
f" {today} (planning §4.8)"
|
|
793
|
+
)
|
|
794
|
+
raise EngineError(msg)
|
|
795
|
+
months = whole_months_between(fact.as_of, today)
|
|
796
|
+
if months == 0:
|
|
797
|
+
return fact.value
|
|
798
|
+
nominal = self._expected_nominal_rate(self._opening_allocation(wrapper))
|
|
799
|
+
kept_after_fees = _ONE - self._fees_for(wrapper).total_rate.value
|
|
800
|
+
rate = (_ONE + nominal) * kept_after_fees - _ONE
|
|
801
|
+
if rate <= _MINUS_ONE:
|
|
802
|
+
msg = (
|
|
803
|
+
f"{label}: the wrapper's fee-adjusted expected nominal return"
|
|
804
|
+
f" ({rate} per year) is -100% or worse; the roll-forward"
|
|
805
|
+
" needs a positive expected gross return (planning §4.8)"
|
|
806
|
+
)
|
|
807
|
+
raise EngineError(msg)
|
|
808
|
+
factor = revaluation_factor_for_months(rate, months)
|
|
809
|
+
opening = (fact.value * factor).quantized()
|
|
810
|
+
self._roll_forwards.append(
|
|
811
|
+
BalanceRollForward(
|
|
812
|
+
label=label,
|
|
813
|
+
stated=fact.value,
|
|
814
|
+
as_of=fact.as_of,
|
|
815
|
+
months=months,
|
|
816
|
+
factor=factor,
|
|
817
|
+
opening=opening,
|
|
818
|
+
)
|
|
819
|
+
)
|
|
820
|
+
return opening
|
|
821
|
+
|
|
822
|
+
def _expected_nominal_rate(self, allocation: AssetAllocation) -> Decimal:
|
|
823
|
+
"""The allocation-weighted expected nominal annual return (§4.8).
|
|
824
|
+
|
|
825
|
+
The deterministic composition of each class's real-return
|
|
826
|
+
assumption with CPI — the expectation both return models are
|
|
827
|
+
built from — whatever the run mode: the pre-``today`` span is
|
|
828
|
+
never path-modelled, exactly as CPI stays deterministic across
|
|
829
|
+
Monte Carlo paths (planning §4.8).
|
|
830
|
+
"""
|
|
831
|
+
cpi = decimal_assumption_value(self.tracked.get(AssumptionKey.INFLATION_CPI))
|
|
832
|
+
weighted = Decimal(0)
|
|
833
|
+
for weight, key in (
|
|
834
|
+
(allocation.equity, AssumptionKey.RETURNS_EQUITY_REAL),
|
|
835
|
+
(allocation.bonds, AssumptionKey.RETURNS_BONDS_REAL),
|
|
836
|
+
(allocation.cash, AssumptionKey.RETURNS_CASH_REAL),
|
|
837
|
+
):
|
|
838
|
+
real = decimal_assumption_value(self.tracked.get(key))
|
|
839
|
+
weighted += weight * nominal_rate(real, cpi).value
|
|
840
|
+
return weighted
|
|
841
|
+
|
|
842
|
+
def _expected_asset_returns(self) -> AssetReturns:
|
|
843
|
+
"""The return model's per-class expectation, read once on first use.
|
|
844
|
+
|
|
845
|
+
The Fisher composition of each class's real-return assumption
|
|
846
|
+
with CPI — exactly the rates ``DeterministicReturnModel``
|
|
847
|
+
returns and the mean the stochastic model's lognormal draws
|
|
848
|
+
are matched to — so a period return's deviation from these is
|
|
849
|
+
the pure stochastic shock (:meth:`_close_wrapper`, issue
|
|
850
|
+
#115), identically zero in a deterministic run. Both models
|
|
851
|
+
read the same keys, so no new assumption enters the run's
|
|
852
|
+
provenance here.
|
|
853
|
+
"""
|
|
854
|
+
if self._expected_returns is None:
|
|
855
|
+
cpi = decimal_assumption_value(
|
|
856
|
+
self.tracked.get(AssumptionKey.INFLATION_CPI)
|
|
857
|
+
)
|
|
858
|
+
equity, bonds, cash = (
|
|
859
|
+
nominal_rate(decimal_assumption_value(self.tracked.get(key)), cpi)
|
|
860
|
+
for key in (
|
|
861
|
+
AssumptionKey.RETURNS_EQUITY_REAL,
|
|
862
|
+
AssumptionKey.RETURNS_BONDS_REAL,
|
|
863
|
+
AssumptionKey.RETURNS_CASH_REAL,
|
|
864
|
+
)
|
|
865
|
+
)
|
|
866
|
+
self._expected_returns = AssetReturns(equity=equity, bonds=bonds, cash=cash)
|
|
867
|
+
return self._expected_returns
|
|
868
|
+
|
|
869
|
+
def _opening_allocation(self, wrapper: Wrapper) -> AssetAllocation:
|
|
870
|
+
"""The allocation the wrapper opens the first period with (§4.8).
|
|
871
|
+
|
|
872
|
+
The wrapper's own stated split, else the glide path at the
|
|
873
|
+
run-start years-to-retirement — the same resolution step 1 of
|
|
874
|
+
the first period applies, standing in for the whole pre-run
|
|
875
|
+
span (an accepted §4.8 cost).
|
|
876
|
+
"""
|
|
877
|
+
if wrapper.allocation is not None:
|
|
878
|
+
return wrapper.allocation
|
|
879
|
+
first_period = next(
|
|
880
|
+
iter(self.region.calendar.periods(self.config.today, self._horizon_end()))
|
|
881
|
+
)
|
|
882
|
+
ytr = years_to_target_retirement(
|
|
883
|
+
self.person.date_of_birth.value,
|
|
884
|
+
self.person.target_retirement_age.value,
|
|
885
|
+
first_period,
|
|
886
|
+
)
|
|
887
|
+
return self._glide().allocation_at(ytr)
|
|
888
|
+
|
|
889
|
+
def _build_income_streams(self) -> None:
|
|
890
|
+
"""Resolve the person's DB and state pension income streams.
|
|
891
|
+
|
|
892
|
+
DB amounts are revalued from the statement date to ``today``
|
|
893
|
+
over whole months at the assumed CPI (the run never models
|
|
894
|
+
time before ``today``; module docstring), with the early/late
|
|
895
|
+
factor and the commutation split applied. The state pension
|
|
896
|
+
entitlement comes from the region's scheme in the rates its
|
|
897
|
+
forecast states, then rolls forward from the forecast date to
|
|
898
|
+
``today`` (:meth:`_rolled_entitlement`); its uprating rule is
|
|
899
|
+
read (and recorded) only when a non-zero record is present.
|
|
900
|
+
|
|
901
|
+
Annuity purchases create their streams mid-run — pricing needs
|
|
902
|
+
the pot as it stands at the purchase date — so only their
|
|
903
|
+
dates are validated here: a purchase age already attained
|
|
904
|
+
cannot be priced from a modelled pot and is rejected rather
|
|
905
|
+
than guessed (roadmap 5.5); an annuity already in payment
|
|
906
|
+
belongs in the plan's stated income, not the model.
|
|
907
|
+
|
|
908
|
+
Raises:
|
|
909
|
+
EngineError: If a DB statement date lies in the future, or
|
|
910
|
+
an annuity purchase age was attained before ``today``.
|
|
911
|
+
"""
|
|
912
|
+
person = self.person
|
|
913
|
+
today = self.config.today
|
|
914
|
+
for purchase in person.annuity_purchases:
|
|
915
|
+
if annuity_start_date(purchase, person.date_of_birth.value) < today:
|
|
916
|
+
msg = (
|
|
917
|
+
f"annuity purchase {purchase.id}: age"
|
|
918
|
+
f" {purchase.at_age.value} was attained before today"
|
|
919
|
+
f" {today}, so the purchase cannot be priced from the"
|
|
920
|
+
" modelled pot (roadmap 5.5)"
|
|
921
|
+
)
|
|
922
|
+
raise EngineError(msg)
|
|
923
|
+
cpi = decimal_assumption_value(self.tracked.get(AssumptionKey.INFLATION_CPI))
|
|
924
|
+
for pension in person.db_pensions:
|
|
925
|
+
if pension.statement_date > today:
|
|
926
|
+
msg = (
|
|
927
|
+
f"DB pension {pension.id}: statement date"
|
|
928
|
+
f" {pension.statement_date} is after today {today}"
|
|
929
|
+
)
|
|
930
|
+
raise EngineError(msg)
|
|
931
|
+
self._db_streams.append(self._db_stream(pension, cpi))
|
|
932
|
+
if person.state_pension is None:
|
|
933
|
+
return
|
|
934
|
+
record = person.state_pension
|
|
935
|
+
entitlement = self.region.state_pension.entitlement(
|
|
936
|
+
record, person.date_of_birth.value
|
|
937
|
+
)
|
|
938
|
+
if (
|
|
939
|
+
entitlement.annual_amount <= _ZERO
|
|
940
|
+
and entitlement.cpi_uprated_annual_amount <= _ZERO
|
|
941
|
+
):
|
|
942
|
+
return
|
|
943
|
+
uprating = StatePensionUprating.from_assumption_value(
|
|
944
|
+
self.tracked.get(AssumptionKey.POLICY_STATE_PENSION_UPRATING).value
|
|
945
|
+
)
|
|
946
|
+
entitlement = self._rolled_entitlement(record, entitlement, uprating, cpi)
|
|
947
|
+
self._sp_stream = _StatePensionStream(
|
|
948
|
+
entitlement=entitlement, uprating=uprating
|
|
949
|
+
)
|
|
950
|
+
|
|
951
|
+
def _db_stream(self, pension: DBPension, cpi: Decimal) -> _DbStream:
|
|
952
|
+
"""Seed one DB pension's stream at ``today`` (planning §5.1).
|
|
953
|
+
|
|
954
|
+
The accrued entitlement revalues from the statement date to
|
|
955
|
+
``today`` (whole-month convention, §4.6). An active membership
|
|
956
|
+
additionally credits the statement→today span's service at the
|
|
957
|
+
stated salary, un-revalued (§5.1), clamped at the earliest of
|
|
958
|
+
the service end and the exact target-retirement date — the
|
|
959
|
+
pre-run counterpart of the in-run retirement gate; service
|
|
960
|
+
still to run carries the accrual parameters into the period
|
|
961
|
+
loop. Service already over just leaves the pension deferred —
|
|
962
|
+
tolerant, like a benefits start already past.
|
|
963
|
+
"""
|
|
964
|
+
today = self.config.today
|
|
965
|
+
person = self.person
|
|
966
|
+
months = whole_months_between(pension.statement_date, today)
|
|
967
|
+
annual_rate = pension.revaluation_basis.annual_rate(cpi)
|
|
968
|
+
accrued = pension.accrued_annual_pension.value * revaluation_factor_for_months(
|
|
969
|
+
annual_rate, months
|
|
970
|
+
)
|
|
971
|
+
accrual = None
|
|
972
|
+
membership = pension.active_membership
|
|
973
|
+
if membership is not None:
|
|
974
|
+
retirement = date_age_attained(
|
|
975
|
+
person.date_of_birth.value, person.target_retirement_age.value
|
|
976
|
+
)
|
|
977
|
+
service_end = db_service_end_date(pension, person.date_of_birth.value)
|
|
978
|
+
span_end = min(today, service_end, retirement)
|
|
979
|
+
if span_end > pension.statement_date:
|
|
980
|
+
service_months = whole_months_between(pension.statement_date, span_end)
|
|
981
|
+
accrued = accrued + membership.pensionable_salary.value * (
|
|
982
|
+
membership.accrual_rate.value
|
|
983
|
+
* Decimal(service_months)
|
|
984
|
+
/ _MONTHS_PER_YEAR
|
|
985
|
+
)
|
|
986
|
+
if service_end > today:
|
|
987
|
+
accrual = _DbAccrual(
|
|
988
|
+
rate=membership.accrual_rate.value,
|
|
989
|
+
salary=membership.pensionable_salary.value,
|
|
990
|
+
service_end=service_end,
|
|
991
|
+
)
|
|
992
|
+
factor = db_early_late_factor(pension)
|
|
993
|
+
commuted = pension.commuted_fraction.value
|
|
994
|
+
lump_sum_factor = Decimal(0)
|
|
995
|
+
if commuted > Decimal(0) and pension.commutation_factor is not None:
|
|
996
|
+
lump_sum_factor = factor * commuted * pension.commutation_factor.value
|
|
997
|
+
return _DbStream(
|
|
998
|
+
basis=pension.revaluation_basis,
|
|
999
|
+
start=db_start_date(pension, person.date_of_birth.value),
|
|
1000
|
+
accrued_annual=accrued,
|
|
1001
|
+
payout_factor=factor * (_ONE - commuted),
|
|
1002
|
+
lump_sum_factor=lump_sum_factor,
|
|
1003
|
+
accrual=accrual,
|
|
1004
|
+
)
|
|
1005
|
+
|
|
1006
|
+
def _rolled_entitlement(
|
|
1007
|
+
self,
|
|
1008
|
+
record: StatePensionRecord,
|
|
1009
|
+
entitlement: StatePensionEntitlement,
|
|
1010
|
+
uprating: StatePensionUprating,
|
|
1011
|
+
cpi: Decimal,
|
|
1012
|
+
) -> StatePensionEntitlement:
|
|
1013
|
+
"""The entitlement with a stale forecast uprated to ``today``.
|
|
1014
|
+
|
|
1015
|
+
The DWP forecast states rates as of its own date, so — exactly
|
|
1016
|
+
like a stale balance fact (§4.8) — each slice is brought to the
|
|
1017
|
+
run start over the whole months from its fact's ``as_of``: the
|
|
1018
|
+
main amount at the uprating assumption's annual rate, any
|
|
1019
|
+
protected payment by CPI only, both already floored at zero
|
|
1020
|
+
like every statutory uprating step (planning §5.1). The
|
|
1021
|
+
whole-month convention makes a forecast dated within one month
|
|
1022
|
+
of ``today`` an exact no-op, and each adjustment is recorded
|
|
1023
|
+
for the run's provenance in the weekly rates the user stated —
|
|
1024
|
+
an estimate layered on the stated fact is never applied
|
|
1025
|
+
silently (§4.8). The deferral uplift needs no rolling: it is a
|
|
1026
|
+
fraction of whatever rate is payable at claim.
|
|
1027
|
+
|
|
1028
|
+
Raises:
|
|
1029
|
+
EngineError: If the forecast or protected payment is dated
|
|
1030
|
+
after ``today`` (planning §4.8).
|
|
1031
|
+
"""
|
|
1032
|
+
forecast = record.forecast_weekly_amount
|
|
1033
|
+
if forecast is None:
|
|
1034
|
+
return entitlement
|
|
1035
|
+
prefix = f"person[{self.person.id}].state_pension"
|
|
1036
|
+
forecast_label = f"{prefix}.forecast_weekly_amount"
|
|
1037
|
+
forecast_months = self._forecast_months(forecast, forecast_label)
|
|
1038
|
+
main_factor = revaluation_factor_for_months(
|
|
1039
|
+
uprating.annual_rate(cpi), forecast_months
|
|
1040
|
+
)
|
|
1041
|
+
protected = record.protected_payment
|
|
1042
|
+
protected_months = 0
|
|
1043
|
+
cpi_factor = _ONE
|
|
1044
|
+
protected_label = f"{prefix}.protected_payment"
|
|
1045
|
+
if protected is not None:
|
|
1046
|
+
protected_months = self._forecast_months(protected, protected_label)
|
|
1047
|
+
cpi_factor = revaluation_factor_for_months(
|
|
1048
|
+
max(cpi, Decimal(0)), protected_months
|
|
1049
|
+
)
|
|
1050
|
+
if forecast_months == 0 and protected_months == 0:
|
|
1051
|
+
return entitlement
|
|
1052
|
+
protected_weekly = _ZERO if protected is None else protected.value
|
|
1053
|
+
main_weekly = forecast.value - protected_weekly
|
|
1054
|
+
if forecast_months > 0:
|
|
1055
|
+
rolled_weekly = main_weekly * main_factor + protected_weekly * cpi_factor
|
|
1056
|
+
# The blend needs a divisor; a zero forecast (possible only
|
|
1057
|
+
# when a region answers a zero record with its own amounts)
|
|
1058
|
+
# has no protected slice to blend in anyway.
|
|
1059
|
+
blended = (
|
|
1060
|
+
main_factor
|
|
1061
|
+
if protected is None or forecast.value <= _ZERO
|
|
1062
|
+
else rolled_weekly.amount / forecast.value.amount
|
|
1063
|
+
)
|
|
1064
|
+
self._roll_forwards.append(
|
|
1065
|
+
BalanceRollForward(
|
|
1066
|
+
label=forecast_label,
|
|
1067
|
+
stated=forecast.value,
|
|
1068
|
+
as_of=forecast.as_of,
|
|
1069
|
+
months=forecast_months,
|
|
1070
|
+
factor=blended,
|
|
1071
|
+
opening=rolled_weekly.quantized(),
|
|
1072
|
+
)
|
|
1073
|
+
)
|
|
1074
|
+
if protected is not None and protected_months > 0:
|
|
1075
|
+
self._roll_forwards.append(
|
|
1076
|
+
BalanceRollForward(
|
|
1077
|
+
label=protected_label,
|
|
1078
|
+
stated=protected.value,
|
|
1079
|
+
as_of=protected.as_of,
|
|
1080
|
+
months=protected_months,
|
|
1081
|
+
factor=cpi_factor,
|
|
1082
|
+
opening=(protected.value * cpi_factor).quantized(),
|
|
1083
|
+
)
|
|
1084
|
+
)
|
|
1085
|
+
return StatePensionEntitlement(
|
|
1086
|
+
start_date=entitlement.start_date,
|
|
1087
|
+
annual_amount=(entitlement.annual_amount * main_factor).quantized(),
|
|
1088
|
+
cpi_uprated_annual_amount=(
|
|
1089
|
+
entitlement.cpi_uprated_annual_amount * cpi_factor
|
|
1090
|
+
).quantized(),
|
|
1091
|
+
deferral_uplift=entitlement.deferral_uplift,
|
|
1092
|
+
)
|
|
1093
|
+
|
|
1094
|
+
def _forecast_months(self, fact: Fact[Money], label: str) -> int:
|
|
1095
|
+
"""Whole months from a forecast fact's ``as_of`` to ``today``.
|
|
1096
|
+
|
|
1097
|
+
Raises:
|
|
1098
|
+
EngineError: If the fact is dated after ``today`` — a
|
|
1099
|
+
future-dated forecast cannot state today's rates
|
|
1100
|
+
(planning §4.8).
|
|
1101
|
+
"""
|
|
1102
|
+
today = self.config.today
|
|
1103
|
+
if fact.as_of > today:
|
|
1104
|
+
msg = f"{label}: as_of {fact.as_of} is after today {today} (planning §4.8)"
|
|
1105
|
+
raise EngineError(msg)
|
|
1106
|
+
return whole_months_between(fact.as_of, today)
|
|
1107
|
+
|
|
1108
|
+
def _horizon_end(self) -> date:
|
|
1109
|
+
"""The configured horizon end, or the planning-age default (§5.2)."""
|
|
1110
|
+
if self.config.horizon_end is not None:
|
|
1111
|
+
return self.config.horizon_end
|
|
1112
|
+
planning_age = int_assumption_value(
|
|
1113
|
+
self.tracked.get(AssumptionKey.HORIZON_PLANNING_AGE)
|
|
1114
|
+
)
|
|
1115
|
+
horizon_end = date_age_attained(self.person.date_of_birth.value, planning_age)
|
|
1116
|
+
if horizon_end < self.config.today:
|
|
1117
|
+
msg = (
|
|
1118
|
+
f"planning age {planning_age} was attained before today"
|
|
1119
|
+
f" {self.config.today}; set RunConfig.horizon_end explicitly"
|
|
1120
|
+
)
|
|
1121
|
+
raise EngineError(msg)
|
|
1122
|
+
return horizon_end
|
|
1123
|
+
|
|
1124
|
+
def _escalation_keys(self) -> set[AssumptionKey]:
|
|
1125
|
+
"""The real-growth assumption keys this plan escalates by."""
|
|
1126
|
+
keys: set[AssumptionKey] = set()
|
|
1127
|
+
if self.person.employment_income is not None or any(
|
|
1128
|
+
pension.active_membership is not None for pension in self.person.db_pensions
|
|
1129
|
+
):
|
|
1130
|
+
keys.add(AssumptionKey.EARNINGS_GROWTH_REAL)
|
|
1131
|
+
keys.update(
|
|
1132
|
+
wrapper.contributions.escalation
|
|
1133
|
+
for wrapper in self.person.wrappers
|
|
1134
|
+
if wrapper.contributions is not None
|
|
1135
|
+
and wrapper.contributions.escalation is not None
|
|
1136
|
+
)
|
|
1137
|
+
return keys
|
|
1138
|
+
|
|
1139
|
+
def _glide(self) -> GlidePathConfig:
|
|
1140
|
+
"""The person's glide path, or the default-shape assumption's."""
|
|
1141
|
+
if self.person.glide_path is not None:
|
|
1142
|
+
return self.person.glide_path
|
|
1143
|
+
shape = mapping_assumption_value(
|
|
1144
|
+
self.tracked.get(AssumptionKey.GLIDEPATH_DEFAULT_SHAPE)
|
|
1145
|
+
)
|
|
1146
|
+
return glide_path_from_shape(shape)
|
|
1147
|
+
|
|
1148
|
+
def _fees_for(self, wrapper: Wrapper) -> FeeSchedule:
|
|
1149
|
+
"""The wrapper's fee schedule, or the shipped fee assumptions.
|
|
1150
|
+
|
|
1151
|
+
A kind the region exempts from the default fees — a bare cash
|
|
1152
|
+
savings account, whose rate already is the whole deal — pays
|
|
1153
|
+
nothing unless the wrapper states its own schedule; the fee
|
|
1154
|
+
assumption keys are then never read, so they stay out of the
|
|
1155
|
+
run's provenance (issue #118).
|
|
1156
|
+
"""
|
|
1157
|
+
if wrapper.fees is not None:
|
|
1158
|
+
return wrapper.fees
|
|
1159
|
+
if not self.region.wrappers.bears_default_fees(wrapper.kind):
|
|
1160
|
+
return _NO_FEES
|
|
1161
|
+
return FeeSchedule(
|
|
1162
|
+
platform=Rate(
|
|
1163
|
+
decimal_assumption_value(self.tracked.get(AssumptionKey.FEES_PLATFORM))
|
|
1164
|
+
),
|
|
1165
|
+
fund=Rate(
|
|
1166
|
+
decimal_assumption_value(self.tracked.get(AssumptionKey.FEES_FUND))
|
|
1167
|
+
),
|
|
1168
|
+
)
|
|
1169
|
+
|
|
1170
|
+
def _project_period(
|
|
1171
|
+
self,
|
|
1172
|
+
period: Period,
|
|
1173
|
+
returns: PeriodReturns,
|
|
1174
|
+
inflation: Decimal,
|
|
1175
|
+
factors: _NominalFactors,
|
|
1176
|
+
fraction: Decimal,
|
|
1177
|
+
) -> PeriodSnapshot:
|
|
1178
|
+
"""Run the eight steps of planning §5.2 for one period.
|
|
1179
|
+
|
|
1180
|
+
``fraction`` is the whole-month share of the period inside the
|
|
1181
|
+
run window (roadmap 4.6): flows and the annual growth/fee rates
|
|
1182
|
+
are scaled by it, so a mid-period ``today`` never re-models
|
|
1183
|
+
months already reflected in the balance facts, and the final
|
|
1184
|
+
period never models time past the horizon end. Income
|
|
1185
|
+
entitlements pro-rate by their own start dates within the same
|
|
1186
|
+
window (§4.1).
|
|
1187
|
+
"""
|
|
1188
|
+
person = self.person
|
|
1189
|
+
# Step 1 — open.
|
|
1190
|
+
age = age_on(person.date_of_birth.value, period.start)
|
|
1191
|
+
ytr = years_to_target_retirement(
|
|
1192
|
+
person.date_of_birth.value, person.target_retirement_age.value, period
|
|
1193
|
+
)
|
|
1194
|
+
glide = self._glide()
|
|
1195
|
+
stage = glide.stage_at(ytr)
|
|
1196
|
+
retired = ytr <= 0
|
|
1197
|
+
ledgers = [
|
|
1198
|
+
self._open_ledger(wrapper, period, glide, ytr)
|
|
1199
|
+
for wrapper in person.wrappers
|
|
1200
|
+
]
|
|
1201
|
+
# Step 2 — income. Active DB accrual credits at the period open,
|
|
1202
|
+
# gated by retirement like employment income (planning §5.1).
|
|
1203
|
+
# The pre-credit entitlements are the year's DB opening values
|
|
1204
|
+
# for the annual-allowance measurement (§5.2 step 5).
|
|
1205
|
+
self._db_openings = tuple(stream.accrued_annual for stream in self._db_streams)
|
|
1206
|
+
if not retired:
|
|
1207
|
+
self._accrue_db_step(period, factors)
|
|
1208
|
+
employment = _ZERO
|
|
1209
|
+
if not retired and person.employment_income is not None:
|
|
1210
|
+
employment = (
|
|
1211
|
+
person.employment_income.value
|
|
1212
|
+
* factors.factor(AssumptionKey.EARNINGS_GROWTH_REAL)
|
|
1213
|
+
* fraction
|
|
1214
|
+
)
|
|
1215
|
+
db_income, db_lump_sum = self._db_amounts(period)
|
|
1216
|
+
db_lump_sum_excess = self._consume_lump_sum_headroom(db_lump_sum, period)
|
|
1217
|
+
annuity_lump_sum = self._annuity_purchase_step(ledgers, period)
|
|
1218
|
+
annuity_income = self._annuity_amount(period)
|
|
1219
|
+
state_pension = self._state_pension_amount(period)
|
|
1220
|
+
self._accrue_portfolio_income(ledgers, fraction)
|
|
1221
|
+
# Steps 3-4 — contributions, then withdrawals.
|
|
1222
|
+
self._taxable_income = (
|
|
1223
|
+
employment + db_income + state_pension + db_lump_sum_excess + annuity_income
|
|
1224
|
+
)
|
|
1225
|
+
self._relief_at_source = _ZERO
|
|
1226
|
+
self._net_pay_deductions = _ZERO
|
|
1227
|
+
if not retired:
|
|
1228
|
+
self._contribution_step(ledgers, period, employment, factors, fraction)
|
|
1229
|
+
# The annual-allowance measurement takes the trigger standing
|
|
1230
|
+
# when the contributions were made: a trigger a step-4 draw
|
|
1231
|
+
# records later this period leaves them pre-trigger inputs
|
|
1232
|
+
# (planning §5.2).
|
|
1233
|
+
self._mpaa_at_contributions = self._mpaa_triggered_on
|
|
1234
|
+
need = _ZERO
|
|
1235
|
+
outflows = self._outflows_due(period, inflation)
|
|
1236
|
+
pension_lump_sum = _ZERO
|
|
1237
|
+
income = _PeriodIncome(
|
|
1238
|
+
employment=employment,
|
|
1239
|
+
db_income=db_income,
|
|
1240
|
+
db_lump_sum=db_lump_sum,
|
|
1241
|
+
annuity_income=annuity_income,
|
|
1242
|
+
annuity_lump_sum=annuity_lump_sum,
|
|
1243
|
+
state_pension=state_pension,
|
|
1244
|
+
)
|
|
1245
|
+
if retired:
|
|
1246
|
+
if self.plan.spending is not None:
|
|
1247
|
+
need = _spending_need(self.plan.spending, stage, inflation) * fraction
|
|
1248
|
+
if self.config.tax_free_cash is TaxFreeCashStrategy.UP_FRONT_LUMP_SUM:
|
|
1249
|
+
pension_lump_sum = self._up_front_lump_sums(ledgers, period)
|
|
1250
|
+
income_net = self._decumulation_income_net(period, income, pension_lump_sum)
|
|
1251
|
+
wrapper_need = max(need + outflows - income_net, _ZERO)
|
|
1252
|
+
delivered, spend_target = self._withdrawal_step(
|
|
1253
|
+
ledgers,
|
|
1254
|
+
period,
|
|
1255
|
+
wrapper_need,
|
|
1256
|
+
fraction,
|
|
1257
|
+
self.config.withdrawal_strategy,
|
|
1258
|
+
)
|
|
1259
|
+
# Income and gross draws beyond the need bank into the
|
|
1260
|
+
# first uncapped taxable wrapper when one exists (roadmap
|
|
1261
|
+
# 9.2); with none they are spent, the pre-9.2 behaviour.
|
|
1262
|
+
# A net-defined strategy's adjusted target (a guardrails
|
|
1263
|
+
# prosperity rise) is spending, never swept back: only
|
|
1264
|
+
# delivery beyond that target is surplus.
|
|
1265
|
+
surplus = max(income_net - need - outflows, _ZERO) + max(
|
|
1266
|
+
delivered - spend_target, _ZERO
|
|
1267
|
+
)
|
|
1268
|
+
banked = self._bank_surplus(ledgers, surplus)
|
|
1269
|
+
else:
|
|
1270
|
+
wrapper_need, delivered, banked = self._accumulation_spending(
|
|
1271
|
+
ledgers, period, income, outflows, fraction
|
|
1272
|
+
)
|
|
1273
|
+
# Step 5 — allowance measurement, final assessment, wrapper
|
|
1274
|
+
# charge, and any annual-allowance charge, together.
|
|
1275
|
+
tax = self._tax_step(ledgers, period, returns, fraction)
|
|
1276
|
+
# Steps 6-8 — fees, growth, close.
|
|
1277
|
+
wrapper_results = tuple(
|
|
1278
|
+
self._close_wrapper(ledger, returns, fraction) for ledger in ledgers
|
|
1279
|
+
)
|
|
1280
|
+
# Portfolio-income tax or an annual-allowance charge a drained
|
|
1281
|
+
# wrapper could not fund is unmet need: it joins the shortfall
|
|
1282
|
+
# so the ledger reconciles and the roadmap-7.3 ruin signal
|
|
1283
|
+
# sees it (planning §5.2), as does the charge's cash share
|
|
1284
|
+
# when no taxable wrapper could take it at all.
|
|
1285
|
+
unfunded_tax = self._aa_charge_unallocated
|
|
1286
|
+
for ledger in ledgers:
|
|
1287
|
+
unfunded_tax = (
|
|
1288
|
+
unfunded_tax + ledger.growth_tax_unfunded + ledger.aa_charge_unfunded
|
|
1289
|
+
)
|
|
1290
|
+
shortfall = max(wrapper_need - delivered, _ZERO) + unfunded_tax
|
|
1291
|
+
person_result = PersonPeriodResult(
|
|
1292
|
+
person_id=person.id,
|
|
1293
|
+
age_at_period_start=age,
|
|
1294
|
+
years_to_retirement=ytr,
|
|
1295
|
+
stage=stage,
|
|
1296
|
+
employment_income=employment.quantized(),
|
|
1297
|
+
tax=tax,
|
|
1298
|
+
spending_need=need.quantized(),
|
|
1299
|
+
net_withdrawn=delivered.quantized(),
|
|
1300
|
+
shortfall=shortfall.quantized(),
|
|
1301
|
+
wrappers=wrapper_results,
|
|
1302
|
+
db_income=db_income.quantized(),
|
|
1303
|
+
db_lump_sum=db_lump_sum.quantized(),
|
|
1304
|
+
state_pension_income=state_pension.quantized(),
|
|
1305
|
+
annuity_income=annuity_income.quantized(),
|
|
1306
|
+
annuity_lump_sum=annuity_lump_sum.quantized(),
|
|
1307
|
+
planned_outflows=outflows.quantized(),
|
|
1308
|
+
pension_lump_sum=pension_lump_sum.quantized(),
|
|
1309
|
+
lsa_used=self._lsa_used.quantized(),
|
|
1310
|
+
mpaa_triggered_on=self._mpaa_triggered_on,
|
|
1311
|
+
banked=banked.quantized(),
|
|
1312
|
+
)
|
|
1313
|
+
return PeriodSnapshot(
|
|
1314
|
+
period=period,
|
|
1315
|
+
returns=returns,
|
|
1316
|
+
inflation_factor=inflation,
|
|
1317
|
+
persons=(person_result,),
|
|
1318
|
+
year_fraction=fraction,
|
|
1319
|
+
)
|
|
1320
|
+
|
|
1321
|
+
def _accrue_db_step(self, period: Period, factors: _NominalFactors) -> None:
|
|
1322
|
+
"""Credit each active DB stream's accrual for ``period`` (§5.1).
|
|
1323
|
+
|
|
1324
|
+
The credit is ``accrual rate x escalated pensionable salary``
|
|
1325
|
+
scaled by the whole months of service inside the period and the
|
|
1326
|
+
run window; it joins the entitlement at the period open and
|
|
1327
|
+
revalues with it from this period on. The retirement gate is
|
|
1328
|
+
the caller's (the §5.2 period-open convention shared with
|
|
1329
|
+
employment income).
|
|
1330
|
+
"""
|
|
1331
|
+
today = self.config.today
|
|
1332
|
+
horizon_end = self._horizon_end()
|
|
1333
|
+
for stream in self._db_streams:
|
|
1334
|
+
accrual = stream.accrual
|
|
1335
|
+
if accrual is None:
|
|
1336
|
+
continue
|
|
1337
|
+
share = service_active_fraction(
|
|
1338
|
+
accrual.service_end, period, today, horizon_end
|
|
1339
|
+
)
|
|
1340
|
+
if share <= Decimal(0):
|
|
1341
|
+
continue
|
|
1342
|
+
salary = accrual.salary * factors.factor(AssumptionKey.EARNINGS_GROWTH_REAL)
|
|
1343
|
+
stream.credit(salary * (accrual.rate * share))
|
|
1344
|
+
|
|
1345
|
+
def _db_amounts(self, period: Period) -> tuple[Money, Money]:
|
|
1346
|
+
"""Step 2: DB income in payment plus any commutation lump sum.
|
|
1347
|
+
|
|
1348
|
+
Income pro-rates from each pension's exact start date within
|
|
1349
|
+
the run window (§4.1). The lump sum lands once, in the period
|
|
1350
|
+
containing the start date — and only when that date is inside
|
|
1351
|
+
the window: an already-taken lump sum lives in the user's
|
|
1352
|
+
stated balances, not the model (module docstring).
|
|
1353
|
+
"""
|
|
1354
|
+
income = _ZERO
|
|
1355
|
+
lump_sum = _ZERO
|
|
1356
|
+
today = self.config.today
|
|
1357
|
+
horizon_end = self._horizon_end()
|
|
1358
|
+
for stream in self._db_streams:
|
|
1359
|
+
share = entitlement_active_fraction(
|
|
1360
|
+
stream.start, period, today, horizon_end
|
|
1361
|
+
)
|
|
1362
|
+
if share > Decimal(0):
|
|
1363
|
+
income = income + stream.income_annual() * share
|
|
1364
|
+
if period.contains(stream.start) and today <= stream.start <= horizon_end:
|
|
1365
|
+
lump_sum = lump_sum + stream.lump_sum()
|
|
1366
|
+
return income, lump_sum
|
|
1367
|
+
|
|
1368
|
+
def _state_pension_amount(self, period: Period) -> Money:
|
|
1369
|
+
"""Step 2: state pension income in payment for ``period``.
|
|
1370
|
+
|
|
1371
|
+
The uprated annual amount (both slices) pro-rated from the
|
|
1372
|
+
entitlement's exact start date — state pension age plus any
|
|
1373
|
+
deferral — within the run window (§4.1).
|
|
1374
|
+
"""
|
|
1375
|
+
stream = self._sp_stream
|
|
1376
|
+
if stream is None:
|
|
1377
|
+
return _ZERO
|
|
1378
|
+
share = entitlement_active_fraction(
|
|
1379
|
+
stream.entitlement.start_date,
|
|
1380
|
+
period,
|
|
1381
|
+
self.config.today,
|
|
1382
|
+
self._horizon_end(),
|
|
1383
|
+
)
|
|
1384
|
+
if share <= Decimal(0):
|
|
1385
|
+
return _ZERO
|
|
1386
|
+
return stream.annual_amount() * share
|
|
1387
|
+
|
|
1388
|
+
def _annuity_purchase_step(
|
|
1389
|
+
self, ledgers: list[_WrapperLedger], period: Period
|
|
1390
|
+
) -> Money:
|
|
1391
|
+
"""Step 2: execute annuity purchases due this period (roadmap 5.5).
|
|
1392
|
+
|
|
1393
|
+
Each due purchase converts its fraction of every pension
|
|
1394
|
+
wrapper's sub-balances — the pot as it stands at the period's
|
|
1395
|
+
open, before this period's contributions — into a lifetime
|
|
1396
|
+
income stream priced from the annuity-rate assumptions.
|
|
1397
|
+
Crystallised funds annuitise whole; uncrystallised funds
|
|
1398
|
+
crystallise on the way, delivering the region's tax-free
|
|
1399
|
+
fraction as cash — capped at the remaining lump-sum-allowance
|
|
1400
|
+
headroom, the remainder simply buying more annuity — exactly
|
|
1401
|
+
the §5.2 tax-free cash conventions. Buying a lifetime annuity
|
|
1402
|
+
is not flexible access, so no MPAA trigger is recorded
|
|
1403
|
+
(planning §5.1). Returns the tax-free cash delivered, which
|
|
1404
|
+
joins the period's income offset like a commutation lump sum.
|
|
1405
|
+
|
|
1406
|
+
Raises:
|
|
1407
|
+
EngineError: If a purchase must crystallise a pot whose
|
|
1408
|
+
access gate has not opened (§4.1), or its age lies
|
|
1409
|
+
outside the shipped rate table.
|
|
1410
|
+
"""
|
|
1411
|
+
total_lump_sum = _ZERO
|
|
1412
|
+
horizon_end = self._horizon_end()
|
|
1413
|
+
for purchase in self.person.annuity_purchases:
|
|
1414
|
+
due = annuity_start_date(purchase, self.person.date_of_birth.value)
|
|
1415
|
+
if period.contains(due) and due <= horizon_end:
|
|
1416
|
+
total_lump_sum = total_lump_sum + self._execute_annuity_purchase(
|
|
1417
|
+
purchase, ledgers, period, due
|
|
1418
|
+
)
|
|
1419
|
+
return total_lump_sum
|
|
1420
|
+
|
|
1421
|
+
def _execute_annuity_purchase(
|
|
1422
|
+
self,
|
|
1423
|
+
purchase: AnnuityPurchase,
|
|
1424
|
+
ledgers: list[_WrapperLedger],
|
|
1425
|
+
period: Period,
|
|
1426
|
+
due: date,
|
|
1427
|
+
) -> Money:
|
|
1428
|
+
"""Execute one due purchase; return its tax-free cash (§5.2).
|
|
1429
|
+
|
|
1430
|
+
Annuitises the purchase's fraction of each pension wrapper and,
|
|
1431
|
+
when any capital was converted, opens the income stream at the
|
|
1432
|
+
priced rate. A zero-pot purchase converts nothing and opens no
|
|
1433
|
+
stream — a depleted pot is a legitimate simulation outcome.
|
|
1434
|
+
"""
|
|
1435
|
+
rate = self._annuity_rate_for(purchase.annuity_type, purchase)
|
|
1436
|
+
capital = _ZERO
|
|
1437
|
+
lump_sum = _ZERO
|
|
1438
|
+
for ledger in ledgers:
|
|
1439
|
+
partial = (
|
|
1440
|
+
ledger.treatment.withdrawals
|
|
1441
|
+
is WithdrawalTaxTreatment.PARTIALLY_TAX_FREE
|
|
1442
|
+
)
|
|
1443
|
+
if not partial:
|
|
1444
|
+
continue
|
|
1445
|
+
annuitised, tax_free = self._annuitise_wrapper(purchase, ledger, period)
|
|
1446
|
+
capital = capital + annuitised
|
|
1447
|
+
lump_sum = lump_sum + tax_free
|
|
1448
|
+
if capital > _ZERO:
|
|
1449
|
+
self._annuity_streams.append(
|
|
1450
|
+
_AnnuityStream(
|
|
1451
|
+
annuity_type=purchase.annuity_type,
|
|
1452
|
+
start=due,
|
|
1453
|
+
base_annual=capital * rate,
|
|
1454
|
+
escalation=self._annuity_pricing_table().escalation,
|
|
1455
|
+
purchase_period_share=entitlement_active_fraction(
|
|
1456
|
+
due, period, self.config.today, self._horizon_end()
|
|
1457
|
+
),
|
|
1458
|
+
)
|
|
1459
|
+
)
|
|
1460
|
+
return lump_sum
|
|
1461
|
+
|
|
1462
|
+
def _annuitise_wrapper(
|
|
1463
|
+
self, purchase: AnnuityPurchase, ledger: _WrapperLedger, period: Period
|
|
1464
|
+
) -> tuple[Money, Money]:
|
|
1465
|
+
"""Annuitise one pension wrapper's share of a purchase.
|
|
1466
|
+
|
|
1467
|
+
Draws the purchase fraction of both sub-balances, pays the
|
|
1468
|
+
uncrystallised draw's tax-free element (headroom-capped, §5.2)
|
|
1469
|
+
through the wrapper like an up-front lump sum, and returns the
|
|
1470
|
+
capital annuitised alongside the tax-free cash delivered.
|
|
1471
|
+
|
|
1472
|
+
Raises:
|
|
1473
|
+
EngineError: If the draw must crystallise a pot whose
|
|
1474
|
+
access gate has not opened (§4.1).
|
|
1475
|
+
"""
|
|
1476
|
+
fraction = purchase.fraction_of_pot.value
|
|
1477
|
+
crystallised_draw = ledger.crystallised * fraction
|
|
1478
|
+
uncrystallised_draw = ledger.uncrystallised * fraction
|
|
1479
|
+
gate_open = self.region.wrappers.is_access_open(
|
|
1480
|
+
ledger.wrapper.kind, self.person.date_of_birth.value, period
|
|
1481
|
+
)
|
|
1482
|
+
if uncrystallised_draw > _ZERO and not gate_open:
|
|
1483
|
+
msg = (
|
|
1484
|
+
f"annuity purchase {purchase.id} crystallises wrapper"
|
|
1485
|
+
f" {ledger.wrapper.id} before its access gate opens"
|
|
1486
|
+
)
|
|
1487
|
+
raise EngineError(msg)
|
|
1488
|
+
tax_free = _ZERO
|
|
1489
|
+
free_fraction = ledger.treatment.tax_free_fraction
|
|
1490
|
+
if uncrystallised_draw > _ZERO and free_fraction is not None:
|
|
1491
|
+
tax_free = uncrystallised_draw * free_fraction.value
|
|
1492
|
+
headroom = self._lsa_headroom(period)
|
|
1493
|
+
if headroom is not None:
|
|
1494
|
+
tax_free = min(tax_free, headroom)
|
|
1495
|
+
self._lsa_used = self._lsa_used + tax_free
|
|
1496
|
+
ledger.uncrystallised = ledger.uncrystallised - uncrystallised_draw
|
|
1497
|
+
ledger.crystallised = ledger.crystallised - crystallised_draw
|
|
1498
|
+
ledger.withdrawn_uncrystallised = ledger.withdrawn_uncrystallised + tax_free
|
|
1499
|
+
ledger.withdrawal_tax_free = ledger.withdrawal_tax_free + tax_free
|
|
1500
|
+
annuitised = crystallised_draw + uncrystallised_draw - tax_free
|
|
1501
|
+
ledger.annuity_purchase = ledger.annuity_purchase + annuitised
|
|
1502
|
+
return annuitised, tax_free
|
|
1503
|
+
|
|
1504
|
+
def _annuity_rate_for(
|
|
1505
|
+
self, annuity_type: AnnuityType, purchase: AnnuityPurchase
|
|
1506
|
+
) -> Decimal:
|
|
1507
|
+
"""The annual income per pound of purchase capital (planning §7).
|
|
1508
|
+
|
|
1509
|
+
The single-life-at-65 base rate for the type, shaped by the
|
|
1510
|
+
``annuity.age_adjustment`` table's per-age multiplier and — on
|
|
1511
|
+
a joint basis — its joint-life factor. Read through the
|
|
1512
|
+
tracked view only when a purchase actually fires, so the rate
|
|
1513
|
+
assumptions enter provenance exactly when they enter the
|
|
1514
|
+
result.
|
|
1515
|
+
"""
|
|
1516
|
+
table = self._annuity_pricing_table()
|
|
1517
|
+
base = decimal_assumption_value(
|
|
1518
|
+
self.tracked.get(annuity_base_rate_key(annuity_type))
|
|
1519
|
+
)
|
|
1520
|
+
multiplier = table.age_multiplier(annuity_type, purchase.at_age.value)
|
|
1521
|
+
return base * multiplier * table.basis_factor(purchase.basis)
|
|
1522
|
+
|
|
1523
|
+
def _annuity_pricing_table(self) -> AnnuityRateTable:
|
|
1524
|
+
"""The parsed age-adjustment table, read once per run on first use."""
|
|
1525
|
+
if self._annuity_table is None:
|
|
1526
|
+
self._annuity_table = AnnuityRateTable.from_assumption_value(
|
|
1527
|
+
mapping_assumption_value(
|
|
1528
|
+
self.tracked.get(AssumptionKey.ANNUITY_AGE_ADJUSTMENT)
|
|
1529
|
+
)
|
|
1530
|
+
)
|
|
1531
|
+
return self._annuity_table
|
|
1532
|
+
|
|
1533
|
+
def _annuity_amount(self, period: Period) -> Money:
|
|
1534
|
+
"""Step 2: purchased annuity income in payment for ``period``.
|
|
1535
|
+
|
|
1536
|
+
Each stream's bought income, escalated per its type, pro-rated
|
|
1537
|
+
from its exact start date within the run window (§4.1). Wholly
|
|
1538
|
+
taxable: annuities bought with pension funds pay taxable
|
|
1539
|
+
income (planning §5.1).
|
|
1540
|
+
"""
|
|
1541
|
+
total = _ZERO
|
|
1542
|
+
horizon_end = self._horizon_end()
|
|
1543
|
+
for stream in self._annuity_streams:
|
|
1544
|
+
share = entitlement_active_fraction(
|
|
1545
|
+
stream.start, period, self.config.today, horizon_end
|
|
1546
|
+
)
|
|
1547
|
+
if share > Decimal(0):
|
|
1548
|
+
total = total + stream.base_annual * (stream.factor * share)
|
|
1549
|
+
return total
|
|
1550
|
+
|
|
1551
|
+
def _outflows_due(self, period: Period, inflation: Decimal) -> Money:
|
|
1552
|
+
"""The planned outflows landing in ``period``, in nominal money.
|
|
1553
|
+
|
|
1554
|
+
A planned outflow is a dated one-off (roadmap 5.4): it hits the
|
|
1555
|
+
period containing the date its person attains the stated age —
|
|
1556
|
+
whole, never pro-rated, the DB lump-sum convention — and only
|
|
1557
|
+
when that date lies inside the run window; an outflow already
|
|
1558
|
+
past lives in the stated balances, not the model. The real
|
|
1559
|
+
amount is inflated by the period-start price level, the same
|
|
1560
|
+
single inflation truth the spending need uses (§5.2).
|
|
1561
|
+
"""
|
|
1562
|
+
total = _ZERO
|
|
1563
|
+
if not self.plan.planned_outflows:
|
|
1564
|
+
return total
|
|
1565
|
+
horizon_end = self._horizon_end()
|
|
1566
|
+
births = {person.id: person.date_of_birth.value for person in self.plan.persons}
|
|
1567
|
+
for outflow in self.plan.planned_outflows:
|
|
1568
|
+
person_id, age = outflow.at_age_of
|
|
1569
|
+
due = date_age_attained(births[person_id], age)
|
|
1570
|
+
if period.contains(due) and self.config.today <= due <= horizon_end:
|
|
1571
|
+
total = total + outflow.amount_real.value * inflation
|
|
1572
|
+
return total
|
|
1573
|
+
|
|
1574
|
+
def _open_ledger(
|
|
1575
|
+
self, wrapper: Wrapper, period: Period, glide: GlidePathConfig, ytr: int
|
|
1576
|
+
) -> _WrapperLedger:
|
|
1577
|
+
"""Step 1 for one wrapper: allocation and opening balances.
|
|
1578
|
+
|
|
1579
|
+
A crystallised balance is meaningful only on a partially
|
|
1580
|
+
tax-free (pension) kind — funds already designated to drawdown
|
|
1581
|
+
(planning §5.1). Any other kind carrying one is an engine
|
|
1582
|
+
error: crystallised sub-balances are never re-gated, so
|
|
1583
|
+
accepting one on an age-gated kind (a LISA) would let money
|
|
1584
|
+
bypass its access gate.
|
|
1585
|
+
"""
|
|
1586
|
+
uncrystallised, crystallised = self._balances[wrapper.id]
|
|
1587
|
+
treatment = self.region.wrappers.tax_treatment(wrapper.kind, period)
|
|
1588
|
+
if (
|
|
1589
|
+
crystallised > _ZERO
|
|
1590
|
+
and treatment.withdrawals is not WithdrawalTaxTreatment.PARTIALLY_TAX_FREE
|
|
1591
|
+
):
|
|
1592
|
+
msg = (
|
|
1593
|
+
f"wrapper {wrapper.id}: kind {wrapper.kind!r} does not take a"
|
|
1594
|
+
" crystallised balance — only partially-tax-free (pension)"
|
|
1595
|
+
" kinds hold funds designated to drawdown"
|
|
1596
|
+
)
|
|
1597
|
+
raise EngineError(msg)
|
|
1598
|
+
allocation = (
|
|
1599
|
+
wrapper.allocation
|
|
1600
|
+
if wrapper.allocation is not None
|
|
1601
|
+
else glide.allocation_at(ytr)
|
|
1602
|
+
)
|
|
1603
|
+
return _WrapperLedger(
|
|
1604
|
+
wrapper=wrapper,
|
|
1605
|
+
allocation=allocation,
|
|
1606
|
+
treatment=treatment,
|
|
1607
|
+
uncrystallised=uncrystallised,
|
|
1608
|
+
crystallised=crystallised,
|
|
1609
|
+
opening_uncrystallised=uncrystallised,
|
|
1610
|
+
opening_crystallised=crystallised,
|
|
1611
|
+
)
|
|
1612
|
+
|
|
1613
|
+
def _contribution_step(
|
|
1614
|
+
self,
|
|
1615
|
+
ledgers: list[_WrapperLedger],
|
|
1616
|
+
period: Period,
|
|
1617
|
+
employment: Money,
|
|
1618
|
+
factors: _NominalFactors,
|
|
1619
|
+
fraction: Decimal,
|
|
1620
|
+
) -> None:
|
|
1621
|
+
"""Step 3: scheduled contributions through caps and relief rules.
|
|
1622
|
+
|
|
1623
|
+
The region's contribution terms govern each kind (roadmap
|
|
1624
|
+
9.2): scheduled amounts scale by the whole-month share of the
|
|
1625
|
+
period inside the intersection of the run window and the
|
|
1626
|
+
terms' contribution window (a LISA's stops at 50) — one
|
|
1627
|
+
overlap, never the product of separate fractions, so a run
|
|
1628
|
+
starting after the window closes contributes nothing; caps
|
|
1629
|
+
are annual allowances shared
|
|
1630
|
+
across wrappers through their allowance groups (LISA inside
|
|
1631
|
+
the overall ISA allowance), with employer amounts (employment
|
|
1632
|
+
terms, outside the member's control) consuming headroom first;
|
|
1633
|
+
a bonus rate (the LISA's 25%) credits the pot on top of the
|
|
1634
|
+
member's contribution without consuming any cap. Amounts a cap
|
|
1635
|
+
or the region's relief limit keeps out of the pot are recorded
|
|
1636
|
+
as the wrapper's contribution shortfall — never rerouted: a
|
|
1637
|
+
schedule states intent for one wrapper (§5.1). Caps stay
|
|
1638
|
+
whole-year — allowances are annual, and contributions already
|
|
1639
|
+
made this year live in the balance facts, not the model.
|
|
1640
|
+
"""
|
|
1641
|
+
used_by_group: dict[str, Money] = {}
|
|
1642
|
+
relieved_so_far = _ZERO
|
|
1643
|
+
for ledger in ledgers:
|
|
1644
|
+
schedule = ledger.wrapper.contributions
|
|
1645
|
+
if schedule is None:
|
|
1646
|
+
continue
|
|
1647
|
+
self._require_permitted_mechanic(ledger.wrapper, schedule)
|
|
1648
|
+
terms = self.region.wrappers.contribution_terms(
|
|
1649
|
+
ledger.wrapper.kind, self.person.date_of_birth.value, period
|
|
1650
|
+
)
|
|
1651
|
+
escalation = _ONE
|
|
1652
|
+
if schedule.escalation is not None:
|
|
1653
|
+
escalation = factors.factor(schedule.escalation)
|
|
1654
|
+
flow_fraction = fraction
|
|
1655
|
+
if terms.window is not None:
|
|
1656
|
+
flow_fraction = self._contribution_window_fraction(terms.window, period)
|
|
1657
|
+
scale = escalation * flow_fraction
|
|
1658
|
+
employee_intended = schedule.employee_amount.value * scale
|
|
1659
|
+
employer = _ZERO
|
|
1660
|
+
if schedule.employer_amount is not None:
|
|
1661
|
+
employer = schedule.employer_amount.value * scale
|
|
1662
|
+
employee, employer = _apply_contribution_caps(
|
|
1663
|
+
terms.caps,
|
|
1664
|
+
used_by_group,
|
|
1665
|
+
employee=employee_intended,
|
|
1666
|
+
employer=employer,
|
|
1667
|
+
)
|
|
1668
|
+
outcome = self.region.contributions.member_contribution(
|
|
1669
|
+
MemberContributionRequest(
|
|
1670
|
+
gross=employee,
|
|
1671
|
+
relevant_earnings=employment,
|
|
1672
|
+
date_of_birth=self.person.date_of_birth.value,
|
|
1673
|
+
mechanic=schedule.relief_mechanic,
|
|
1674
|
+
already_relieved_gross=relieved_so_far,
|
|
1675
|
+
),
|
|
1676
|
+
period,
|
|
1677
|
+
)
|
|
1678
|
+
if schedule.relief_mechanic is not None:
|
|
1679
|
+
relieved_so_far = relieved_so_far + outcome.gross_to_pot
|
|
1680
|
+
self._taxable_income = max(
|
|
1681
|
+
self._taxable_income - outcome.taxable_pay_deduction, _ZERO
|
|
1682
|
+
)
|
|
1683
|
+
self._net_pay_deductions = (
|
|
1684
|
+
self._net_pay_deductions + outcome.taxable_pay_deduction
|
|
1685
|
+
)
|
|
1686
|
+
self._relief_at_source = (
|
|
1687
|
+
self._relief_at_source + outcome.assessment_relief_gross
|
|
1688
|
+
)
|
|
1689
|
+
bonus = _ZERO
|
|
1690
|
+
if terms.bonus_rate is not None:
|
|
1691
|
+
bonus = terms.bonus_rate.of(outcome.gross_to_pot)
|
|
1692
|
+
ledger.employee_in = outcome.gross_to_pot
|
|
1693
|
+
ledger.employer_in = employer
|
|
1694
|
+
ledger.provider_relief = outcome.provider_relief
|
|
1695
|
+
ledger.bonus_in = bonus
|
|
1696
|
+
ledger.contribution_shortfall = (
|
|
1697
|
+
employee_intended - employee
|
|
1698
|
+
) + outcome.unrelieved_excess
|
|
1699
|
+
ledger.uncrystallised = (
|
|
1700
|
+
ledger.uncrystallised + outcome.gross_to_pot + employer + bonus
|
|
1701
|
+
)
|
|
1702
|
+
|
|
1703
|
+
def _contribution_window_fraction(self, window: Period, period: Period) -> Decimal:
|
|
1704
|
+
"""The period's whole-month share inside run ∩ eligibility window.
|
|
1705
|
+
|
|
1706
|
+
The run models only ``[today, horizon_end]`` (roadmap 4.6) and
|
|
1707
|
+
the region's contribution window is an exact date span (§4.1),
|
|
1708
|
+
so the contributable share of the period is the whole months
|
|
1709
|
+
of the *single* three-way overlap over the whole months of the
|
|
1710
|
+
period — never the product of separately measured fractions,
|
|
1711
|
+
which overstates disjoint windows (a run starting the day the
|
|
1712
|
+
window closes must contribute zero).
|
|
1713
|
+
"""
|
|
1714
|
+
start = max(self.config.today, window.start)
|
|
1715
|
+
end = min(self._horizon_end(), window.end)
|
|
1716
|
+
if end < start:
|
|
1717
|
+
return Decimal(0)
|
|
1718
|
+
return period_active_fraction(period, start, end)
|
|
1719
|
+
|
|
1720
|
+
def _require_permitted_mechanic(
|
|
1721
|
+
self, wrapper: Wrapper, schedule: ContributionSchedule
|
|
1722
|
+
) -> None:
|
|
1723
|
+
"""Reject a schedule whose relief mechanic the region forbids.
|
|
1724
|
+
|
|
1725
|
+
The region's permitted-mechanics set is the authority (planning
|
|
1726
|
+
§4.2): a mechanic outside it would fabricate relief (e.g.
|
|
1727
|
+
relief at source into an ISA), and a missing mechanic on a
|
|
1728
|
+
kind that operates one would bypass the relief limits entirely.
|
|
1729
|
+
"""
|
|
1730
|
+
permitted = self.region.wrappers.permitted_relief_mechanics(wrapper.kind)
|
|
1731
|
+
mechanic = schedule.relief_mechanic
|
|
1732
|
+
if mechanic is None and permitted:
|
|
1733
|
+
names = ", ".join(sorted(entry.name for entry in permitted))
|
|
1734
|
+
msg = (
|
|
1735
|
+
f"wrapper {wrapper.id}: contributions to kind {wrapper.kind!r}"
|
|
1736
|
+
f" require a relief mechanic (one of: {names})"
|
|
1737
|
+
)
|
|
1738
|
+
raise EngineError(msg)
|
|
1739
|
+
if mechanic is not None and mechanic not in permitted:
|
|
1740
|
+
msg = (
|
|
1741
|
+
f"wrapper {wrapper.id}: relief mechanic {mechanic.name} is not"
|
|
1742
|
+
f" permitted for kind {wrapper.kind!r}"
|
|
1743
|
+
)
|
|
1744
|
+
raise EngineError(msg)
|
|
1745
|
+
|
|
1746
|
+
def _decumulation_income_net(
|
|
1747
|
+
self, period: Period, income: _PeriodIncome, pension_lump_sum: Money
|
|
1748
|
+
) -> Money:
|
|
1749
|
+
"""The §5.2 decumulation income offset: net cash before draws.
|
|
1750
|
+
|
|
1751
|
+
Net-of-tax pension, state-pension and annuity income, any
|
|
1752
|
+
commutation lump sum (gross here; the tax on its over-headroom
|
|
1753
|
+
excess is in the assessment), and any up-front or
|
|
1754
|
+
annuity-purchase tax-free cash meet the net need — spending
|
|
1755
|
+
plus planned outflows — first; only the remainder is drawn
|
|
1756
|
+
from wrappers. The offset excludes the portfolio-income
|
|
1757
|
+
layers: their tax is charged to the taxable wrappers at
|
|
1758
|
+
close, never to the need.
|
|
1759
|
+
"""
|
|
1760
|
+
income_tax = self.region.tax.assess(
|
|
1761
|
+
period, self._tax_input(include_portfolio=False)
|
|
1762
|
+
).tax_due
|
|
1763
|
+
return (
|
|
1764
|
+
income.db_income
|
|
1765
|
+
+ income.state_pension
|
|
1766
|
+
+ income.annuity_income
|
|
1767
|
+
+ income.db_lump_sum
|
|
1768
|
+
+ income.annuity_lump_sum
|
|
1769
|
+
+ pension_lump_sum
|
|
1770
|
+
- income_tax
|
|
1771
|
+
)
|
|
1772
|
+
|
|
1773
|
+
def _accumulation_spending(
|
|
1774
|
+
self,
|
|
1775
|
+
ledgers: list[_WrapperLedger],
|
|
1776
|
+
period: Period,
|
|
1777
|
+
income: _PeriodIncome,
|
|
1778
|
+
outflows: Money,
|
|
1779
|
+
fraction: Decimal,
|
|
1780
|
+
) -> tuple[Money, Money, Money]:
|
|
1781
|
+
"""Steps 3-4 before retirement: fund outflows, bank the rest.
|
|
1782
|
+
|
|
1783
|
+
Retirement income already in payment before the target
|
|
1784
|
+
retirement age — an early DB start or annuity purchase, the
|
|
1785
|
+
state pension alongside work — is real cash: net of the
|
|
1786
|
+
marginal tax it adds on top of employment income
|
|
1787
|
+
(:meth:`_pre_retirement_income_tax`) it meets the period's
|
|
1788
|
+
planned outflows first, and the remainder banks like
|
|
1789
|
+
decumulation surplus (roadmap 9.2). Employment income itself
|
|
1790
|
+
never offsets or banks — net pay funds working-life spending,
|
|
1791
|
+
which the model does not track (planning §5.2). An outflow
|
|
1792
|
+
beyond the offset is a net cash need met in the default
|
|
1793
|
+
tax-aware order; the configured strategy governs decumulation
|
|
1794
|
+
only (module docstring). Returns the wrapper need, the net
|
|
1795
|
+
cash delivered toward it, and the surplus banked.
|
|
1796
|
+
"""
|
|
1797
|
+
income_net = (
|
|
1798
|
+
income.db_income
|
|
1799
|
+
+ income.state_pension
|
|
1800
|
+
+ income.annuity_income
|
|
1801
|
+
+ income.db_lump_sum
|
|
1802
|
+
+ income.annuity_lump_sum
|
|
1803
|
+
- self._pre_retirement_income_tax(period, income.employment)
|
|
1804
|
+
)
|
|
1805
|
+
wrapper_need = max(outflows - income_net, _ZERO)
|
|
1806
|
+
delivered = _ZERO
|
|
1807
|
+
spend_target = wrapper_need
|
|
1808
|
+
if wrapper_need > _ZERO:
|
|
1809
|
+
delivered, spend_target = self._withdrawal_step(
|
|
1810
|
+
ledgers, period, wrapper_need, fraction, _OUTFLOW_FUNDING
|
|
1811
|
+
)
|
|
1812
|
+
surplus = max(income_net - outflows, _ZERO) + max(
|
|
1813
|
+
delivered - spend_target, _ZERO
|
|
1814
|
+
)
|
|
1815
|
+
return wrapper_need, delivered, self._bank_surplus(ledgers, surplus)
|
|
1816
|
+
|
|
1817
|
+
def _withdrawal_step(
|
|
1818
|
+
self,
|
|
1819
|
+
ledgers: list[_WrapperLedger],
|
|
1820
|
+
period: Period,
|
|
1821
|
+
need: Money,
|
|
1822
|
+
fraction: Decimal,
|
|
1823
|
+
strategy: WithdrawalStrategy,
|
|
1824
|
+
) -> tuple[Money, Money]:
|
|
1825
|
+
"""Step 4: run the withdrawal strategy over the drawable sources.
|
|
1826
|
+
|
|
1827
|
+
The strategy sees every sub-balance — gate-closed ones flagged
|
|
1828
|
+
(planning §5.2) — and returns a net-defined or gross-defined
|
|
1829
|
+
plan; execution enforces the access gates, so a plan drawing on
|
|
1830
|
+
a closed source is an error, never a silent draw. For a
|
|
1831
|
+
strategy declaring ``uses_natural_yield`` (roadmap 5.3), each
|
|
1832
|
+
source also carries the income its balance throws off — the
|
|
1833
|
+
wrapper allocation's weighted ``yield.*`` assumptions, scaled
|
|
1834
|
+
by the period's active fraction; the yield keys are read only
|
|
1835
|
+
then, so other runs' provenance never lists them. Returns the
|
|
1836
|
+
net cash delivered and the plan's net spending target — the
|
|
1837
|
+
strategy-adjusted need for a net-defined plan (a guardrails
|
|
1838
|
+
rise or cut), the caller's need for a gross-defined one, which
|
|
1839
|
+
sets no net target. Delivery toward the target is spending;
|
|
1840
|
+
only delivery beyond it is surplus for the caller to bank
|
|
1841
|
+
(roadmap 9.2).
|
|
1842
|
+
"""
|
|
1843
|
+
sources = self._withdrawal_sources(ledgers, period)
|
|
1844
|
+
# Opt-in marker, deliberately not a protocol member: a strategy
|
|
1845
|
+
# that never declares it simply gets no yield pricing (§5.2).
|
|
1846
|
+
price_yield = getattr(strategy, "uses_natural_yield", False)
|
|
1847
|
+
views: list[WithdrawalSource] = []
|
|
1848
|
+
for source in sources.values():
|
|
1849
|
+
natural_yield = _ZERO
|
|
1850
|
+
if price_yield and source.available > _ZERO:
|
|
1851
|
+
natural_yield = source.available * (
|
|
1852
|
+
self._portfolio_yield(source.ledger.allocation) * fraction
|
|
1853
|
+
)
|
|
1854
|
+
views.append(source.view(natural_yield=natural_yield))
|
|
1855
|
+
state = WithdrawalState(
|
|
1856
|
+
sources=tuple(views),
|
|
1857
|
+
year_fraction=fraction,
|
|
1858
|
+
tax_free_cash_headroom=self._lsa_headroom(period),
|
|
1859
|
+
)
|
|
1860
|
+
plan = strategy.withdraw(state, need)
|
|
1861
|
+
if isinstance(plan, NetWithdrawalPlan):
|
|
1862
|
+
return self._execute_net_plan(sources, period, plan), plan.target
|
|
1863
|
+
return self._execute_gross_plan(sources, period, plan), need
|
|
1864
|
+
|
|
1865
|
+
def _portfolio_yield(self, allocation: AssetAllocation) -> Decimal:
|
|
1866
|
+
"""The allocation-weighted annual natural yield (roadmap 5.3).
|
|
1867
|
+
|
|
1868
|
+
The income an invested balance throws off per year — dividends,
|
|
1869
|
+
coupons, interest — priced from the per-asset ``yield.*``
|
|
1870
|
+
assumptions (planning §7); nominal, like the balances it
|
|
1871
|
+
applies to.
|
|
1872
|
+
"""
|
|
1873
|
+
return (
|
|
1874
|
+
allocation.equity
|
|
1875
|
+
* decimal_assumption_value(self.tracked.get(AssumptionKey.YIELD_EQUITY))
|
|
1876
|
+
+ allocation.bonds
|
|
1877
|
+
* decimal_assumption_value(self.tracked.get(AssumptionKey.YIELD_BONDS))
|
|
1878
|
+
+ allocation.cash
|
|
1879
|
+
* decimal_assumption_value(self.tracked.get(AssumptionKey.YIELD_CASH))
|
|
1880
|
+
)
|
|
1881
|
+
|
|
1882
|
+
def _withdrawal_sources(
|
|
1883
|
+
self, ledgers: list[_WrapperLedger], period: Period
|
|
1884
|
+
) -> dict[WithdrawalSourceId, _WithdrawalSource]:
|
|
1885
|
+
"""Every sub-balance keyed for plan execution, in wrapper order.
|
|
1886
|
+
|
|
1887
|
+
On every kind — wholly tax-free ones included, since tax
|
|
1888
|
+
treatment says nothing about accessibility — the uncrystallised
|
|
1889
|
+
pot answers to the region's access gate (§4.1); crystallised
|
|
1890
|
+
funds are always drawable (already accessed, never re-gated —
|
|
1891
|
+
planning §5.1).
|
|
1892
|
+
"""
|
|
1893
|
+
sources: dict[WithdrawalSourceId, _WithdrawalSource] = {}
|
|
1894
|
+
for ledger in ledgers:
|
|
1895
|
+
treatment = ledger.treatment
|
|
1896
|
+
pension = treatment.withdrawals is WithdrawalTaxTreatment.PARTIALLY_TAX_FREE
|
|
1897
|
+
if treatment.withdrawals is WithdrawalTaxTreatment.TAX_FREE:
|
|
1898
|
+
free_fraction = _ONE
|
|
1899
|
+
crystallised_fraction = _ONE
|
|
1900
|
+
else:
|
|
1901
|
+
free_fraction = Decimal(0)
|
|
1902
|
+
crystallised_fraction = Decimal(0)
|
|
1903
|
+
if pension and treatment.tax_free_fraction is not None:
|
|
1904
|
+
free_fraction = treatment.tax_free_fraction.value
|
|
1905
|
+
entries = (
|
|
1906
|
+
_WithdrawalSource(
|
|
1907
|
+
ledger=ledger,
|
|
1908
|
+
crystallised=False,
|
|
1909
|
+
tax_free_fraction=free_fraction,
|
|
1910
|
+
access_open=self.region.wrappers.is_access_open(
|
|
1911
|
+
ledger.wrapper.kind,
|
|
1912
|
+
self.person.date_of_birth.value,
|
|
1913
|
+
period,
|
|
1914
|
+
),
|
|
1915
|
+
pension=pension,
|
|
1916
|
+
),
|
|
1917
|
+
_WithdrawalSource(
|
|
1918
|
+
ledger=ledger,
|
|
1919
|
+
crystallised=True,
|
|
1920
|
+
tax_free_fraction=crystallised_fraction,
|
|
1921
|
+
pension=pension,
|
|
1922
|
+
),
|
|
1923
|
+
)
|
|
1924
|
+
for source in entries:
|
|
1925
|
+
sources[source.source_id] = source
|
|
1926
|
+
return sources
|
|
1927
|
+
|
|
1928
|
+
def _execute_net_plan(
|
|
1929
|
+
self,
|
|
1930
|
+
sources: dict[WithdrawalSourceId, _WithdrawalSource],
|
|
1931
|
+
period: Period,
|
|
1932
|
+
plan: NetWithdrawalPlan,
|
|
1933
|
+
) -> Money:
|
|
1934
|
+
"""Deliver the plan's net target, grossing up source by source.
|
|
1935
|
+
|
|
1936
|
+
Walks the plan's order, drawing from each source until the
|
|
1937
|
+
target is met to within ledger tolerance or the listed sources
|
|
1938
|
+
are exhausted; the unmet remainder is the caller's shortfall.
|
|
1939
|
+
"""
|
|
1940
|
+
delivered = _ZERO
|
|
1941
|
+
for source_id in plan.order:
|
|
1942
|
+
remaining = plan.target - delivered
|
|
1943
|
+
if remaining <= _NET_TOLERANCE:
|
|
1944
|
+
break
|
|
1945
|
+
source = _plan_source(sources, source_id)
|
|
1946
|
+
if source.available <= _ZERO:
|
|
1947
|
+
continue
|
|
1948
|
+
delivered = delivered + self._draw_from(source, period, remaining)
|
|
1949
|
+
return delivered
|
|
1950
|
+
|
|
1951
|
+
def _execute_gross_plan(
|
|
1952
|
+
self,
|
|
1953
|
+
sources: dict[WithdrawalSourceId, _WithdrawalSource],
|
|
1954
|
+
period: Period,
|
|
1955
|
+
plan: GrossWithdrawalPlan,
|
|
1956
|
+
) -> Money:
|
|
1957
|
+
"""Take the plan's exact gross draws; net is what survives tax.
|
|
1958
|
+
|
|
1959
|
+
No fixed-point iteration (planning §5.2): a gross-defined
|
|
1960
|
+
strategy declares itself gross. Each draw is capped at what its
|
|
1961
|
+
source holds and resolved as a split payment whatever the
|
|
1962
|
+
run's tax-free cash mode — an exact gross amount is a payment
|
|
1963
|
+
instruction, not a designation (planning §5.2). The marginal
|
|
1964
|
+
tax on the taxable share is priced through the same regional
|
|
1965
|
+
assessment the final step-5 pass uses, so the two cannot
|
|
1966
|
+
disagree.
|
|
1967
|
+
"""
|
|
1968
|
+
delivered = _ZERO
|
|
1969
|
+
for draw in plan.draws:
|
|
1970
|
+
source = _plan_source(sources, draw.source)
|
|
1971
|
+
remaining = min(draw.amount, source.available)
|
|
1972
|
+
for tranche in self._tranches(
|
|
1973
|
+
source, period, TaxFreeCashStrategy.SPLIT_EACH_PAYMENT
|
|
1974
|
+
):
|
|
1975
|
+
if remaining <= _ZERO:
|
|
1976
|
+
break
|
|
1977
|
+
gross = min(remaining, tranche.max_gross)
|
|
1978
|
+
if gross <= _ZERO:
|
|
1979
|
+
continue
|
|
1980
|
+
delivered = delivered + self._apply_tranche(
|
|
1981
|
+
source, period, tranche, gross
|
|
1982
|
+
)
|
|
1983
|
+
remaining = remaining - gross
|
|
1984
|
+
return delivered
|
|
1985
|
+
|
|
1986
|
+
def _draw_from(
|
|
1987
|
+
self, source: _WithdrawalSource, period: Period, need: Money
|
|
1988
|
+
) -> Money:
|
|
1989
|
+
"""Draw up to ``need`` net from one source, grossing up for tax.
|
|
1990
|
+
|
|
1991
|
+
The source resolves into linear tranches per the run's
|
|
1992
|
+
tax-free cash mode (roadmap 5.2) — for a plain source exactly
|
|
1993
|
+
one — each drawn through the fixed point of
|
|
1994
|
+
:meth:`_draw_tranche` until the need is met to within ledger
|
|
1995
|
+
tolerance or the tranches are exhausted.
|
|
1996
|
+
"""
|
|
1997
|
+
mode = self.config.tax_free_cash
|
|
1998
|
+
if mode is TaxFreeCashStrategy.UP_FRONT_LUMP_SUM:
|
|
1999
|
+
# The up-front mode is a decumulation crystallisation
|
|
2000
|
+
# event; any other draw it leaves to make (an outflow
|
|
2001
|
+
# funded before retirement) is a split payment (§5.2).
|
|
2002
|
+
mode = TaxFreeCashStrategy.SPLIT_EACH_PAYMENT
|
|
2003
|
+
delivered = _ZERO
|
|
2004
|
+
for tranche in self._tranches(source, period, mode):
|
|
2005
|
+
remaining = need - delivered
|
|
2006
|
+
if remaining <= _NET_TOLERANCE:
|
|
2007
|
+
break
|
|
2008
|
+
if tranche.max_gross <= _ZERO:
|
|
2009
|
+
continue
|
|
2010
|
+
delivered = delivered + self._draw_tranche(
|
|
2011
|
+
source, period, tranche, remaining
|
|
2012
|
+
)
|
|
2013
|
+
return delivered
|
|
2014
|
+
|
|
2015
|
+
def _draw_tranche(
|
|
2016
|
+
self,
|
|
2017
|
+
source: _WithdrawalSource,
|
|
2018
|
+
period: Period,
|
|
2019
|
+
tranche: _DrawTranche,
|
|
2020
|
+
need: Money,
|
|
2021
|
+
) -> Money:
|
|
2022
|
+
"""Draw up to ``need`` net from one tranche, grossing up for tax.
|
|
2023
|
+
|
|
2024
|
+
The fixed point of planning §5.2 step 4: iterate gross →
|
|
2025
|
+
assess → net until the net matches the need (piecewise-constant
|
|
2026
|
+
marginal rates converge in a few rounds), capped at
|
|
2027
|
+
``_GROSS_UP_ITERATION_CAP`` with any sub-penny residual settled
|
|
2028
|
+
rather than chased. A draw the tranche cannot cover takes the
|
|
2029
|
+
tranche's whole cap instead. ``cash_share`` divides because a
|
|
2030
|
+
designating tranche delivers only its tax-free slice as cash:
|
|
2031
|
+
meeting one pound of need takes ``1 / cash_share`` pounds
|
|
2032
|
+
gross.
|
|
2033
|
+
"""
|
|
2034
|
+
cash_share = tranche.free_share + tranche.taxable_share
|
|
2035
|
+
gross = min(Money(need.amount / cash_share), tranche.max_gross)
|
|
2036
|
+
for _ in range(_GROSS_UP_ITERATION_CAP):
|
|
2037
|
+
extra_tax = self._incremental_tax(period, gross * tranche.taxable_share)
|
|
2038
|
+
target = Money((need + extra_tax).amount / cash_share)
|
|
2039
|
+
if target >= tranche.max_gross:
|
|
2040
|
+
gross = tranche.max_gross
|
|
2041
|
+
break
|
|
2042
|
+
if (target - gross) < _NET_TOLERANCE and (gross - target) < _NET_TOLERANCE:
|
|
2043
|
+
break
|
|
2044
|
+
gross = target
|
|
2045
|
+
return self._apply_tranche(source, period, tranche, gross)
|
|
2046
|
+
|
|
2047
|
+
def _apply_tranche(
|
|
2048
|
+
self,
|
|
2049
|
+
source: _WithdrawalSource,
|
|
2050
|
+
period: Period,
|
|
2051
|
+
tranche: _DrawTranche,
|
|
2052
|
+
gross: Money,
|
|
2053
|
+
) -> Money:
|
|
2054
|
+
"""Execute ``gross`` against one tranche; return the net cash.
|
|
2055
|
+
|
|
2056
|
+
Splits the gross into tax-free cash, taxable income, and the
|
|
2057
|
+
designated residue (moved to the wrapper's crystallised
|
|
2058
|
+
sub-balance, not withdrawn), updates the ledger, consumes
|
|
2059
|
+
lump-sum-allowance headroom, and marks flexible access on a
|
|
2060
|
+
taxable pension draw (roadmap 5.2).
|
|
2061
|
+
"""
|
|
2062
|
+
tax_free = gross * tranche.free_share
|
|
2063
|
+
taxable = gross * tranche.taxable_share
|
|
2064
|
+
designated = gross - tax_free - taxable
|
|
2065
|
+
net = tax_free + taxable - self._incremental_tax(period, taxable)
|
|
2066
|
+
ledger = source.ledger
|
|
2067
|
+
if tranche.from_crystallised:
|
|
2068
|
+
ledger.crystallised = ledger.crystallised - gross
|
|
2069
|
+
ledger.withdrawn_crystallised = ledger.withdrawn_crystallised + gross
|
|
2070
|
+
else:
|
|
2071
|
+
ledger.uncrystallised = ledger.uncrystallised - gross
|
|
2072
|
+
ledger.crystallised = ledger.crystallised + designated
|
|
2073
|
+
ledger.withdrawn_uncrystallised = (
|
|
2074
|
+
ledger.withdrawn_uncrystallised + gross - designated
|
|
2075
|
+
)
|
|
2076
|
+
ledger.withdrawal_tax_free = ledger.withdrawal_tax_free + tax_free
|
|
2077
|
+
ledger.withdrawal_taxable = ledger.withdrawal_taxable + taxable
|
|
2078
|
+
self._taxable_income = self._taxable_income + taxable
|
|
2079
|
+
if source.pension:
|
|
2080
|
+
self._lsa_used = self._lsa_used + tax_free
|
|
2081
|
+
if taxable > _ZERO:
|
|
2082
|
+
self._mark_flexible_access(period)
|
|
2083
|
+
return net
|
|
2084
|
+
|
|
2085
|
+
def _tranches(
|
|
2086
|
+
self,
|
|
2087
|
+
source: _WithdrawalSource,
|
|
2088
|
+
period: Period,
|
|
2089
|
+
mode: TaxFreeCashStrategy,
|
|
2090
|
+
) -> Iterator[_DrawTranche]:
|
|
2091
|
+
"""The linear slices a draw on ``source`` resolves into (§5.2).
|
|
2092
|
+
|
|
2093
|
+
A non-pension sub-balance and a crystallised pension pot are a
|
|
2094
|
+
single tranche — the latter wholly taxable, never fresh
|
|
2095
|
+
tax-free cash (planning §5.1). An uncrystallised pension pot
|
|
2096
|
+
splits at the lump-sum-allowance boundary: the free-bearing
|
|
2097
|
+
slice runs while headroom lasts, then a wholly taxable slice —
|
|
2098
|
+
payments beyond the allowance keep flowing, just without the
|
|
2099
|
+
tax-free element. Under the lump-sum-as-needed mode the
|
|
2100
|
+
free-bearing slice designates its residue instead of paying it
|
|
2101
|
+
out, and the taxable slices draw drawdown income — first from
|
|
2102
|
+
the residue this draw just designated (never from pre-existing
|
|
2103
|
+
drawdown funds, which answer only to their own crystallised
|
|
2104
|
+
source id), then by crystallising the rest of the pot
|
|
2105
|
+
outright. Caps are evaluated lazily as the caller resumes the
|
|
2106
|
+
iterator, so each slice sees the balances and headroom its
|
|
2107
|
+
predecessors left.
|
|
2108
|
+
"""
|
|
2109
|
+
ledger = source.ledger
|
|
2110
|
+
if not source.pension or source.crystallised:
|
|
2111
|
+
yield _DrawTranche(
|
|
2112
|
+
free_share=source.tax_free_fraction,
|
|
2113
|
+
taxable_share=_ONE - source.tax_free_fraction,
|
|
2114
|
+
max_gross=source.available,
|
|
2115
|
+
from_crystallised=source.crystallised,
|
|
2116
|
+
)
|
|
2117
|
+
return
|
|
2118
|
+
fraction = source.tax_free_fraction
|
|
2119
|
+
headroom = self._lsa_headroom(period)
|
|
2120
|
+
free_cap = ledger.uncrystallised
|
|
2121
|
+
if headroom is not None:
|
|
2122
|
+
free_cap = min(Money(headroom.amount / fraction), free_cap)
|
|
2123
|
+
if mode is TaxFreeCashStrategy.LUMP_SUM_AS_NEEDED:
|
|
2124
|
+
crystallised_before = ledger.crystallised
|
|
2125
|
+
if free_cap > _ZERO:
|
|
2126
|
+
yield _DrawTranche(
|
|
2127
|
+
free_share=fraction,
|
|
2128
|
+
taxable_share=Decimal(0),
|
|
2129
|
+
max_gross=free_cap,
|
|
2130
|
+
from_crystallised=False,
|
|
2131
|
+
)
|
|
2132
|
+
residue = ledger.crystallised - crystallised_before
|
|
2133
|
+
if residue > _ZERO:
|
|
2134
|
+
yield _DrawTranche(
|
|
2135
|
+
free_share=Decimal(0),
|
|
2136
|
+
taxable_share=_ONE,
|
|
2137
|
+
max_gross=residue,
|
|
2138
|
+
from_crystallised=True,
|
|
2139
|
+
)
|
|
2140
|
+
if ledger.uncrystallised > _ZERO:
|
|
2141
|
+
yield _DrawTranche(
|
|
2142
|
+
free_share=Decimal(0),
|
|
2143
|
+
taxable_share=_ONE,
|
|
2144
|
+
max_gross=ledger.uncrystallised,
|
|
2145
|
+
from_crystallised=False,
|
|
2146
|
+
)
|
|
2147
|
+
return
|
|
2148
|
+
if free_cap > _ZERO:
|
|
2149
|
+
yield _DrawTranche(
|
|
2150
|
+
free_share=fraction,
|
|
2151
|
+
taxable_share=_ONE - fraction,
|
|
2152
|
+
max_gross=free_cap,
|
|
2153
|
+
from_crystallised=False,
|
|
2154
|
+
)
|
|
2155
|
+
if ledger.uncrystallised > _ZERO:
|
|
2156
|
+
yield _DrawTranche(
|
|
2157
|
+
free_share=Decimal(0),
|
|
2158
|
+
taxable_share=_ONE,
|
|
2159
|
+
max_gross=ledger.uncrystallised,
|
|
2160
|
+
from_crystallised=False,
|
|
2161
|
+
)
|
|
2162
|
+
|
|
2163
|
+
def _up_front_lump_sums(
|
|
2164
|
+
self, ledgers: list[_WrapperLedger], period: Period
|
|
2165
|
+
) -> Money:
|
|
2166
|
+
"""The ``UP_FRONT_LUMP_SUM`` crystallisation event (§5.2).
|
|
2167
|
+
|
|
2168
|
+
In a decumulation period, every uncrystallised pension pot
|
|
2169
|
+
whose access gate is open crystallises whole: the tax-free
|
|
2170
|
+
fraction — capped at the remaining lump-sum-allowance headroom
|
|
2171
|
+
— is paid out and joins the period's income offset; the rest
|
|
2172
|
+
moves to the crystallised sub-balance. Pure tax-free cash
|
|
2173
|
+
never marks flexible access (planning §5.2). Later periods
|
|
2174
|
+
find the pots already empty, so the event fires once per
|
|
2175
|
+
wrapper — or later, for a pot whose gate opens after
|
|
2176
|
+
retirement (the §4.1 NMPA schedule).
|
|
2177
|
+
"""
|
|
2178
|
+
total = _ZERO
|
|
2179
|
+
for ledger in ledgers:
|
|
2180
|
+
treatment = ledger.treatment
|
|
2181
|
+
fraction = treatment.tax_free_fraction
|
|
2182
|
+
partial = treatment.withdrawals is WithdrawalTaxTreatment.PARTIALLY_TAX_FREE
|
|
2183
|
+
if not partial or fraction is None or ledger.uncrystallised <= _ZERO:
|
|
2184
|
+
continue
|
|
2185
|
+
if not self.region.wrappers.is_access_open(
|
|
2186
|
+
ledger.wrapper.kind, self.person.date_of_birth.value, period
|
|
2187
|
+
):
|
|
2188
|
+
continue
|
|
2189
|
+
pot = ledger.uncrystallised
|
|
2190
|
+
tax_free = pot * fraction.value
|
|
2191
|
+
headroom = self._lsa_headroom(period)
|
|
2192
|
+
if headroom is not None:
|
|
2193
|
+
tax_free = min(tax_free, headroom)
|
|
2194
|
+
ledger.uncrystallised = _ZERO
|
|
2195
|
+
ledger.crystallised = ledger.crystallised + pot - tax_free
|
|
2196
|
+
ledger.withdrawn_uncrystallised = ledger.withdrawn_uncrystallised + tax_free
|
|
2197
|
+
ledger.withdrawal_tax_free = ledger.withdrawal_tax_free + tax_free
|
|
2198
|
+
self._lsa_used = self._lsa_used + tax_free
|
|
2199
|
+
total = total + tax_free
|
|
2200
|
+
return total
|
|
2201
|
+
|
|
2202
|
+
def _lsa_headroom(self, period: Period) -> Money | None:
|
|
2203
|
+
"""Tax-free cash still allowed under the region's lifetime cap.
|
|
2204
|
+
|
|
2205
|
+
``None`` when the region has no cap. Usage accumulates across
|
|
2206
|
+
the run from the person's ``lsa_used`` fact (roadmap 5.2).
|
|
2207
|
+
"""
|
|
2208
|
+
allowance = self.region.wrappers.lump_sum_allowance(period)
|
|
2209
|
+
if allowance is None:
|
|
2210
|
+
return None
|
|
2211
|
+
return max(allowance - self._lsa_used, _ZERO)
|
|
2212
|
+
|
|
2213
|
+
def _consume_lump_sum_headroom(self, lump_sum: Money, period: Period) -> Money:
|
|
2214
|
+
"""Count an income lump sum against the cap; return the excess.
|
|
2215
|
+
|
|
2216
|
+
A DB commencement (commutation) lump sum consumes the same
|
|
2217
|
+
lifetime allowance as wrapper tax-free cash (planning §5.2):
|
|
2218
|
+
the part within the remaining headroom is tax-free and recorded
|
|
2219
|
+
as used; the excess is returned for the caller to tax as income
|
|
2220
|
+
(the UK's pension commencement excess lump sum). Landing in
|
|
2221
|
+
step 2, it consumes headroom ahead of the period's wrapper
|
|
2222
|
+
draws. A lump sum never marks flexible access.
|
|
2223
|
+
"""
|
|
2224
|
+
if lump_sum <= _ZERO:
|
|
2225
|
+
return _ZERO
|
|
2226
|
+
headroom = self._lsa_headroom(period)
|
|
2227
|
+
tax_free = lump_sum if headroom is None else min(lump_sum, headroom)
|
|
2228
|
+
self._lsa_used = self._lsa_used + tax_free
|
|
2229
|
+
return lump_sum - tax_free
|
|
2230
|
+
|
|
2231
|
+
def _mark_flexible_access(self, period: Period) -> None:
|
|
2232
|
+
"""Record the first taxable pension draw as the MPAA trigger.
|
|
2233
|
+
|
|
2234
|
+
The trigger date is the later of the period's first day and
|
|
2235
|
+
the run's ``today`` — the first modelled day of the period
|
|
2236
|
+
(§5.2). A pre-existing ``mpaa_triggered_on`` fact wins: once a
|
|
2237
|
+
date is set it never moves.
|
|
2238
|
+
"""
|
|
2239
|
+
if self._mpaa_triggered_on is None:
|
|
2240
|
+
self._mpaa_triggered_on = max(period.start, self.config.today)
|
|
2241
|
+
|
|
2242
|
+
def _pre_retirement_income_tax(self, period: Period, employment: Money) -> Money:
|
|
2243
|
+
"""The tax the pre-retirement income offset bears (planning §5.2).
|
|
2244
|
+
|
|
2245
|
+
Employment income keeps its own tax — net pay funds
|
|
2246
|
+
working-life spending outside the model — so the offset's
|
|
2247
|
+
DB/state-pension/annuity income (and any commutation excess)
|
|
2248
|
+
is netted of only the marginal tax those layers add on top of
|
|
2249
|
+
it: the no-portfolio assessment less one of employment income
|
|
2250
|
+
alone. The portfolio layers' interaction stays with the
|
|
2251
|
+
wrapper charge, exactly as in decumulation
|
|
2252
|
+
(:meth:`_incremental_tax`).
|
|
2253
|
+
"""
|
|
2254
|
+
if self._taxable_income <= employment:
|
|
2255
|
+
return _ZERO
|
|
2256
|
+
full = self.region.tax.assess(period, self._tax_input(include_portfolio=False))
|
|
2257
|
+
base = self.region.tax.assess(
|
|
2258
|
+
period,
|
|
2259
|
+
self._tax_input(non_savings_override=employment, include_portfolio=False),
|
|
2260
|
+
)
|
|
2261
|
+
return full.tax_due - base.tax_due
|
|
2262
|
+
|
|
2263
|
+
def _incremental_tax(self, period: Period, taxable: Money) -> Money:
|
|
2264
|
+
"""The extra tax ``taxable`` adds on top of the period's income.
|
|
2265
|
+
|
|
2266
|
+
Both calls go through the region's one ``assess`` function —
|
|
2267
|
+
the same one the final step-5 assessment uses — so the gross-up
|
|
2268
|
+
and the final tax picture cannot disagree (planning §5.2).
|
|
2269
|
+
|
|
2270
|
+
Draw pricing deliberately excludes the portfolio-income layers
|
|
2271
|
+
(roadmap 9.2): a draw that shifts the taxpayer's band can raise
|
|
2272
|
+
the tax on savings/dividend income sitting above it (a PSA tier
|
|
2273
|
+
drop, dividends pushed up a rate), and that interaction belongs
|
|
2274
|
+
to the wrapper charge — :meth:`_charge_portfolio_tax` measures
|
|
2275
|
+
the portfolio layers' full cost against the final income
|
|
2276
|
+
picture, so pricing it into the gross-up as well would collect
|
|
2277
|
+
it twice. The decomposition is exact: the offset and gross-ups
|
|
2278
|
+
collect the no-portfolio assessment, the wrapper charge the
|
|
2279
|
+
remainder, and together they sum to the final full assessment.
|
|
2280
|
+
"""
|
|
2281
|
+
if taxable <= _ZERO:
|
|
2282
|
+
return _ZERO
|
|
2283
|
+
base = self.region.tax.assess(period, self._tax_input(include_portfolio=False))
|
|
2284
|
+
with_draw = self.region.tax.assess(
|
|
2285
|
+
period, self._tax_input(extra_income=taxable, include_portfolio=False)
|
|
2286
|
+
)
|
|
2287
|
+
return with_draw.tax_due - base.tax_due
|
|
2288
|
+
|
|
2289
|
+
def _tax_input(
|
|
2290
|
+
self,
|
|
2291
|
+
extra_income: Money = _ZERO,
|
|
2292
|
+
*,
|
|
2293
|
+
include_portfolio: bool = True,
|
|
2294
|
+
non_savings_override: Money | None = None,
|
|
2295
|
+
) -> TaxInput:
|
|
2296
|
+
"""The person's categorised income picture for assessment.
|
|
2297
|
+
|
|
2298
|
+
``include_portfolio=False`` drops the savings/dividend income
|
|
2299
|
+
of taxable-growth wrappers: the decumulation income offset and
|
|
2300
|
+
the growth-tax attribution both need the picture without those
|
|
2301
|
+
top-of-ladder layers, whose tax is charged to the wrappers
|
|
2302
|
+
(:meth:`_charge_portfolio_tax`), never to the spending need.
|
|
2303
|
+
``non_savings_override`` replaces the accumulated non-savings
|
|
2304
|
+
income — the employment-only baseline of the pre-retirement
|
|
2305
|
+
income offset (:meth:`_pre_retirement_income_tax`).
|
|
2306
|
+
"""
|
|
2307
|
+
non_savings = (
|
|
2308
|
+
self._taxable_income
|
|
2309
|
+
if non_savings_override is None
|
|
2310
|
+
else non_savings_override
|
|
2311
|
+
)
|
|
2312
|
+
return TaxInput(
|
|
2313
|
+
residency=self.person.tax_residency,
|
|
2314
|
+
non_savings_income=non_savings + extra_income,
|
|
2315
|
+
savings_income=self._savings_income if include_portfolio else _ZERO,
|
|
2316
|
+
dividend_income=self._dividend_income if include_portfolio else _ZERO,
|
|
2317
|
+
relief_at_source_contributions=self._relief_at_source,
|
|
2318
|
+
)
|
|
2319
|
+
|
|
2320
|
+
def _accrue_portfolio_income(
|
|
2321
|
+
self, ledgers: list[_WrapperLedger], fraction: Decimal
|
|
2322
|
+
) -> None:
|
|
2323
|
+
"""Step 2 for taxable-growth wrappers: price the period's income.
|
|
2324
|
+
|
|
2325
|
+
A bare account's holdings throw off dividends (the equity
|
|
2326
|
+
slice) and interest (the bond and cash slices), priced from
|
|
2327
|
+
the per-asset ``yield.*`` assumptions on the opening balance
|
|
2328
|
+
and scaled by the period's active fraction (roadmap 9.2). The
|
|
2329
|
+
income stays invested — the balance path is untouched — but it
|
|
2330
|
+
enters the person's categorised tax picture as the §6 savings
|
|
2331
|
+
and dividend layers, and the tax attributable is charged to
|
|
2332
|
+
the wrapper at close (:meth:`_charge_portfolio_tax`). The
|
|
2333
|
+
yield keys are read only when such a wrapper exists, so other
|
|
2334
|
+
runs' provenance never lists them.
|
|
2335
|
+
"""
|
|
2336
|
+
self._savings_income = _ZERO
|
|
2337
|
+
self._dividend_income = _ZERO
|
|
2338
|
+
for ledger in ledgers:
|
|
2339
|
+
if ledger.treatment.growth is not GrowthTaxTreatment.TAXABLE:
|
|
2340
|
+
continue
|
|
2341
|
+
balance = ledger.opening_uncrystallised + ledger.opening_crystallised
|
|
2342
|
+
if balance <= _ZERO:
|
|
2343
|
+
continue
|
|
2344
|
+
allocation = ledger.allocation
|
|
2345
|
+
equity_yield = decimal_assumption_value(
|
|
2346
|
+
self.tracked.get(AssumptionKey.YIELD_EQUITY)
|
|
2347
|
+
)
|
|
2348
|
+
bond_yield = decimal_assumption_value(
|
|
2349
|
+
self.tracked.get(AssumptionKey.YIELD_BONDS)
|
|
2350
|
+
)
|
|
2351
|
+
cash_yield = decimal_assumption_value(
|
|
2352
|
+
self.tracked.get(AssumptionKey.YIELD_CASH)
|
|
2353
|
+
)
|
|
2354
|
+
dividends = balance * (allocation.equity * equity_yield * fraction)
|
|
2355
|
+
interest = balance * (
|
|
2356
|
+
(allocation.bonds * bond_yield + allocation.cash * cash_yield)
|
|
2357
|
+
* fraction
|
|
2358
|
+
)
|
|
2359
|
+
ledger.taxable_dividends = dividends
|
|
2360
|
+
ledger.taxable_interest = interest
|
|
2361
|
+
self._dividend_income = self._dividend_income + dividends
|
|
2362
|
+
self._savings_income = self._savings_income + interest
|
|
2363
|
+
|
|
2364
|
+
def _charge_portfolio_tax(
|
|
2365
|
+
self, ledgers: list[_WrapperLedger], period: Period, tax: TaxResult
|
|
2366
|
+
) -> None:
|
|
2367
|
+
"""Attribute the savings/dividend layers' tax to their wrappers.
|
|
2368
|
+
|
|
2369
|
+
The attributable tax is the final assessment less an
|
|
2370
|
+
assessment of the same picture without the portfolio income —
|
|
2371
|
+
the marginal cost of the top-of-ladder savings and dividend
|
|
2372
|
+
layers, personal-allowance-taper interactions included. It is
|
|
2373
|
+
apportioned across the taxable-growth wrappers pro rata to
|
|
2374
|
+
their income (remainder on the last) and deducted from each
|
|
2375
|
+
balance at close (:meth:`_close_wrapper`) — the real-world
|
|
2376
|
+
drag of paying tax out of taxable savings.
|
|
2377
|
+
"""
|
|
2378
|
+
portfolio_income = self._savings_income + self._dividend_income
|
|
2379
|
+
if portfolio_income <= _ZERO:
|
|
2380
|
+
return
|
|
2381
|
+
base = self.region.tax.assess(period, self._tax_input(include_portfolio=False))
|
|
2382
|
+
total_tax = tax.tax_due - base.tax_due
|
|
2383
|
+
if total_tax <= _ZERO:
|
|
2384
|
+
return
|
|
2385
|
+
taxable = [
|
|
2386
|
+
ledger
|
|
2387
|
+
for ledger in ledgers
|
|
2388
|
+
if ledger.taxable_interest + ledger.taxable_dividends > _ZERO
|
|
2389
|
+
]
|
|
2390
|
+
charged = _ZERO
|
|
2391
|
+
for ledger in taxable[:-1]:
|
|
2392
|
+
income = ledger.taxable_interest + ledger.taxable_dividends
|
|
2393
|
+
share = Money(total_tax.amount * income.amount / portfolio_income.amount)
|
|
2394
|
+
ledger.growth_tax = share
|
|
2395
|
+
charged = charged + share
|
|
2396
|
+
taxable[-1].growth_tax = total_tax - charged
|
|
2397
|
+
|
|
2398
|
+
def _tax_step(
|
|
2399
|
+
self,
|
|
2400
|
+
ledgers: list[_WrapperLedger],
|
|
2401
|
+
period: Period,
|
|
2402
|
+
returns: PeriodReturns,
|
|
2403
|
+
fraction: Decimal,
|
|
2404
|
+
) -> TaxResult:
|
|
2405
|
+
"""Step 5: the period's whole tax picture, in order (§5.2).
|
|
2406
|
+
|
|
2407
|
+
The year's pension inputs are measured against the region's
|
|
2408
|
+
allowances first (the rolled carry-forward pool feeds the next
|
|
2409
|
+
period), then the final assessment prices the full categorised
|
|
2410
|
+
income, the portfolio-income slice is charged to its wrappers
|
|
2411
|
+
against that pre-charge result, and any annual-allowance
|
|
2412
|
+
charge is appended last — so the wrapper charge never absorbs
|
|
2413
|
+
it — and routed to the wrappers that fund it (#124).
|
|
2414
|
+
"""
|
|
2415
|
+
outcome = self._annual_allowance_step(ledgers, period, returns, fraction)
|
|
2416
|
+
self._aa_carry_forward = outcome.carry_forward
|
|
2417
|
+
tax = self.region.tax.assess(period, self._tax_input())
|
|
2418
|
+
self._charge_portfolio_tax(ledgers, period, tax)
|
|
2419
|
+
final = self._with_annual_allowance_charge(
|
|
2420
|
+
period, tax, outcome.chargeable_excess
|
|
2421
|
+
)
|
|
2422
|
+
self._fund_annual_allowance_charge(ledgers, period, final.tax_due - tax.tax_due)
|
|
2423
|
+
return final
|
|
2424
|
+
|
|
2425
|
+
def _annual_allowance_step(
|
|
2426
|
+
self,
|
|
2427
|
+
ledgers: list[_WrapperLedger],
|
|
2428
|
+
period: Period,
|
|
2429
|
+
returns: PeriodReturns,
|
|
2430
|
+
fraction: Decimal,
|
|
2431
|
+
) -> AnnualAllowanceOutcome:
|
|
2432
|
+
"""Measure the year's pension inputs (§5.2 step 5, roadmap 3.3).
|
|
2433
|
+
|
|
2434
|
+
Money-purchase inputs are the period's member gross (provider
|
|
2435
|
+
relief included) and employer contributions landed in pension
|
|
2436
|
+
(partially-tax-free) wrappers at step 3. Each DB stream not
|
|
2437
|
+
yet in payment contributes its opening entitlement (pre-credit,
|
|
2438
|
+
captured at the period open) and its closing entitlement — the
|
|
2439
|
+
credited value carried to the period end at the same
|
|
2440
|
+
revaluation the next boundary's advance applies — for the
|
|
2441
|
+
region to value (planning §5.2); a stream whose benefits start
|
|
2442
|
+
by the period end has crystallised and generates no input.
|
|
2443
|
+
``total_income`` is the period's full taxable picture before
|
|
2444
|
+
member pension deductions — net-pay amounts added back, the
|
|
2445
|
+
portfolio-income layers included — so the region's income
|
|
2446
|
+
measures see what HMRC's would. The carry-forward pool starts
|
|
2447
|
+
empty at the run start (§4.1 conservative: pre-run years'
|
|
2448
|
+
unused allowance is unknown, so none is assumed) and rolls
|
|
2449
|
+
forward with each period's outcome. Whole-year convention
|
|
2450
|
+
(§5.2): a partial period's pro-rated inputs meet the full
|
|
2451
|
+
year's allowances, and a DB opening value takes the full
|
|
2452
|
+
year's inflation uplift.
|
|
2453
|
+
"""
|
|
2454
|
+
member = _ZERO
|
|
2455
|
+
employer = _ZERO
|
|
2456
|
+
pension_wrapper = False
|
|
2457
|
+
for ledger in ledgers:
|
|
2458
|
+
partial = (
|
|
2459
|
+
ledger.treatment.withdrawals
|
|
2460
|
+
is WithdrawalTaxTreatment.PARTIALLY_TAX_FREE
|
|
2461
|
+
)
|
|
2462
|
+
if not partial:
|
|
2463
|
+
continue
|
|
2464
|
+
pension_wrapper = True
|
|
2465
|
+
member = member + ledger.employee_in
|
|
2466
|
+
employer = employer + ledger.employer_in
|
|
2467
|
+
cpi = returns.cpi.value
|
|
2468
|
+
arrangements: list[DbArrangementInput] = []
|
|
2469
|
+
for stream, opening in zip(self._db_streams, self._db_openings, strict=True):
|
|
2470
|
+
if stream.start <= period.end:
|
|
2471
|
+
continue
|
|
2472
|
+
closing = stream.accrued_annual * (
|
|
2473
|
+
_ONE + stream.basis.annual_rate(cpi) * fraction
|
|
2474
|
+
)
|
|
2475
|
+
arrangements.append(
|
|
2476
|
+
DbArrangementInput(opening_annual=opening, closing_annual=closing)
|
|
2477
|
+
)
|
|
2478
|
+
measurement = AnnualAllowanceMeasurement(
|
|
2479
|
+
member_money_purchase=member,
|
|
2480
|
+
employer_money_purchase=employer,
|
|
2481
|
+
db_arrangements=tuple(arrangements),
|
|
2482
|
+
total_income=(
|
|
2483
|
+
self._taxable_income
|
|
2484
|
+
+ self._net_pay_deductions
|
|
2485
|
+
+ self._savings_income
|
|
2486
|
+
+ self._dividend_income
|
|
2487
|
+
),
|
|
2488
|
+
net_pay_contributions=self._net_pay_deductions,
|
|
2489
|
+
relief_at_source_gross=self._relief_at_source,
|
|
2490
|
+
cpi=cpi,
|
|
2491
|
+
mpaa_triggered_on=self._mpaa_at_contributions,
|
|
2492
|
+
scheme_member=pension_wrapper or bool(self._db_streams),
|
|
2493
|
+
carry_forward=self._aa_carry_forward,
|
|
2494
|
+
)
|
|
2495
|
+
return self.region.contributions.annual_allowance(measurement, period)
|
|
2496
|
+
|
|
2497
|
+
def _with_annual_allowance_charge(
|
|
2498
|
+
self, period: Period, tax: TaxResult, excess: Money
|
|
2499
|
+
) -> TaxResult:
|
|
2500
|
+
"""Append the priced annual-allowance charge to the final result.
|
|
2501
|
+
|
|
2502
|
+
The region prices the excess against the period's full income
|
|
2503
|
+
picture as separate lines (a charge, not income — it never
|
|
2504
|
+
feeds back through ``assess``, so income-measured allowances
|
|
2505
|
+
and the step-4/5 decomposition are untouched), and the final
|
|
2506
|
+
result carries them: the snapshot's ``tax_due`` is the
|
|
2507
|
+
period's whole liability. Like the rest of an accumulation
|
|
2508
|
+
period's assessed tax, the charge is reported, not funded
|
|
2509
|
+
from modelled balances (planning §5.2).
|
|
2510
|
+
"""
|
|
2511
|
+
if excess <= _ZERO:
|
|
2512
|
+
return tax
|
|
2513
|
+
lines = self.region.tax.annual_allowance_charge(
|
|
2514
|
+
period, self._tax_input(), excess
|
|
2515
|
+
)
|
|
2516
|
+
charge = sum((line.tax for line in lines), start=_ZERO)
|
|
2517
|
+
return TaxResult(
|
|
2518
|
+
tax_due=tax.tax_due + charge,
|
|
2519
|
+
taxable_income=tax.taxable_income,
|
|
2520
|
+
tax_free_allowance=tax.tax_free_allowance,
|
|
2521
|
+
lines=(*tax.lines, *lines),
|
|
2522
|
+
)
|
|
2523
|
+
|
|
2524
|
+
def _fund_annual_allowance_charge(
|
|
2525
|
+
self, ledgers: list[_WrapperLedger], period: Period, charge: Money
|
|
2526
|
+
) -> None:
|
|
2527
|
+
"""Route the priced AA charge to the wrappers that fund it (#124).
|
|
2528
|
+
|
|
2529
|
+
The region splits the charge between scheme pays — a debit
|
|
2530
|
+
against a pension wrapper whose own input met its conditions —
|
|
2531
|
+
and cash. The cash share falls to the bare taxable wrappers in
|
|
2532
|
+
plan order, each capped at its balance at allocation; a share
|
|
2533
|
+
no wrapper can take joins the person's shortfall, as does
|
|
2534
|
+
whatever a wrapper's post-growth balance turns out unable to
|
|
2535
|
+
fund at close (planning §5.2). The amounts land as each
|
|
2536
|
+
ledger's ``aa_charge`` and are deducted at period close after
|
|
2537
|
+
fees and growth, exactly the portfolio-income tax convention
|
|
2538
|
+
(:meth:`_close_wrapper`); a scheme-pays debit is a
|
|
2539
|
+
scheme-administrator payment, not a member withdrawal — no tax
|
|
2540
|
+
lines, no MPAA trigger, no lump-sum-allowance use.
|
|
2541
|
+
|
|
2542
|
+
Raises:
|
|
2543
|
+
EngineError: If the region's split does not cover the
|
|
2544
|
+
charge exactly, or pays from an unknown wrapper.
|
|
2545
|
+
"""
|
|
2546
|
+
self._aa_charge_unallocated = _ZERO
|
|
2547
|
+
if charge <= _ZERO:
|
|
2548
|
+
return
|
|
2549
|
+
schemes = tuple(
|
|
2550
|
+
SchemeInput(
|
|
2551
|
+
wrapper_id=ledger.wrapper.id,
|
|
2552
|
+
input_amount=ledger.employee_in + ledger.employer_in,
|
|
2553
|
+
)
|
|
2554
|
+
for ledger in ledgers
|
|
2555
|
+
if ledger.treatment.withdrawals is WithdrawalTaxTreatment.PARTIALLY_TAX_FREE
|
|
2556
|
+
)
|
|
2557
|
+
funding = self.region.contributions.annual_allowance_funding(
|
|
2558
|
+
charge, schemes, period
|
|
2559
|
+
)
|
|
2560
|
+
routed = funding.cash
|
|
2561
|
+
by_id = {ledger.wrapper.id: ledger for ledger in ledgers}
|
|
2562
|
+
for payment in funding.scheme_payments:
|
|
2563
|
+
ledger = by_id.get(payment.wrapper_id)
|
|
2564
|
+
if ledger is None:
|
|
2565
|
+
msg = (
|
|
2566
|
+
"annual-allowance funding names an unknown wrapper:"
|
|
2567
|
+
f" {payment.wrapper_id}"
|
|
2568
|
+
)
|
|
2569
|
+
raise EngineError(msg)
|
|
2570
|
+
ledger.aa_charge = ledger.aa_charge + payment.amount
|
|
2571
|
+
routed = routed + payment.amount
|
|
2572
|
+
if routed != charge:
|
|
2573
|
+
msg = (
|
|
2574
|
+
"annual-allowance funding must split the charge exactly:"
|
|
2575
|
+
f" {routed} routed of {charge}"
|
|
2576
|
+
)
|
|
2577
|
+
raise EngineError(msg)
|
|
2578
|
+
remaining = funding.cash
|
|
2579
|
+
for ledger in ledgers:
|
|
2580
|
+
if remaining <= _ZERO:
|
|
2581
|
+
break
|
|
2582
|
+
if not _is_bare_taxable(ledger.treatment):
|
|
2583
|
+
continue
|
|
2584
|
+
share = min(remaining, max(ledger.uncrystallised, _ZERO))
|
|
2585
|
+
ledger.aa_charge = ledger.aa_charge + share
|
|
2586
|
+
remaining = remaining - share
|
|
2587
|
+
self._aa_charge_unallocated = remaining
|
|
2588
|
+
|
|
2589
|
+
def _bank_surplus(self, ledgers: list[_WrapperLedger], surplus: Money) -> Money:
|
|
2590
|
+
"""Sweep decumulation surplus into the first taxable wrapper.
|
|
2591
|
+
|
|
2592
|
+
Income and gross draws beyond the period's need land in the
|
|
2593
|
+
first wrapper (plan order) whose treatment marks it a bare
|
|
2594
|
+
taxable account — paid from taxed income, growth taxable,
|
|
2595
|
+
withdrawals tax-free (a GIA or cash account) — rather than
|
|
2596
|
+
being spent (roadmap 9.2). Banking is not a contribution: no
|
|
2597
|
+
cap, relief, or bonus machinery applies. With no such wrapper
|
|
2598
|
+
the surplus is spent, the pre-9.2 behaviour (planning §5.2).
|
|
2599
|
+
"""
|
|
2600
|
+
if surplus <= _ZERO:
|
|
2601
|
+
return _ZERO
|
|
2602
|
+
for ledger in ledgers:
|
|
2603
|
+
if _is_bare_taxable(ledger.treatment):
|
|
2604
|
+
ledger.uncrystallised = ledger.uncrystallised + surplus
|
|
2605
|
+
ledger.banked_in = ledger.banked_in + surplus
|
|
2606
|
+
return surplus
|
|
2607
|
+
return _ZERO
|
|
2608
|
+
|
|
2609
|
+
def _close_wrapper(
|
|
2610
|
+
self, ledger: _WrapperLedger, returns: PeriodReturns, fraction: Decimal
|
|
2611
|
+
) -> WrapperPeriodResult:
|
|
2612
|
+
"""Steps 6-8 for one wrapper: fees, growth, quantize, snapshot.
|
|
2613
|
+
|
|
2614
|
+
The fee (step 6) is charged on the wrapper's aggregate average
|
|
2615
|
+
balance — a provider charges the account, not its sub-balances,
|
|
2616
|
+
and the cannot-exceed-the-holdings cap binds at account level —
|
|
2617
|
+
then allocated across the sub-balances pro rata to their
|
|
2618
|
+
post-flow values. Growth (step 7) applies to each post-fee
|
|
2619
|
+
sub-balance; fees before growth per the §5.2 order. In a
|
|
2620
|
+
partial first/last period the annual fee rate scales linearly
|
|
2621
|
+
by ``fraction`` (the §5.2 roadmap-4.6 convention), and growth
|
|
2622
|
+
splits at the return model's expectation (issue #115): the
|
|
2623
|
+
expected component scales linearly, keeping the mean on the
|
|
2624
|
+
deterministic path, while the deviation from it — the
|
|
2625
|
+
stochastic shock — scales by ``sqrt(fraction)``, so a partial
|
|
2626
|
+
period's return standard deviation is sigma times root-f, not
|
|
2627
|
+
sigma times f (``Decimal.sqrt`` is correctly rounded, preserving §4.6
|
|
2628
|
+
reproducibility). Under the deterministic model the deviation
|
|
2629
|
+
is exactly zero — the expectation below is the same Fisher
|
|
2630
|
+
composition it returns — so that mode's linear scaling is
|
|
2631
|
+
bit-for-bit unchanged. A
|
|
2632
|
+
taxable-growth wrapper's attributed portfolio-income tax
|
|
2633
|
+
(:meth:`_charge_portfolio_tax`) leaves the balance last —
|
|
2634
|
+
settled at the period's close like a real self-assessment
|
|
2635
|
+
payment — capped at what the account then holds. Any routed
|
|
2636
|
+
annual-allowance charge (:meth:`_fund_annual_allowance_charge`)
|
|
2637
|
+
settles after it under the same convention — uncrystallised
|
|
2638
|
+
funds first, then crystallised — with the unfunded remainder
|
|
2639
|
+
joining the person's shortfall (#124).
|
|
2640
|
+
"""
|
|
2641
|
+
fees = self._fees_for(ledger.wrapper)
|
|
2642
|
+
opening_total = ledger.opening_uncrystallised + ledger.opening_crystallised
|
|
2643
|
+
after_total = ledger.uncrystallised + ledger.crystallised
|
|
2644
|
+
fee_total = period_fee(opening_total, after_total, fees, fraction)
|
|
2645
|
+
fee_uncrystallised = _ZERO
|
|
2646
|
+
if after_total > _ZERO:
|
|
2647
|
+
fee_uncrystallised = Money(
|
|
2648
|
+
fee_total.amount * ledger.uncrystallised.amount / after_total.amount
|
|
2649
|
+
)
|
|
2650
|
+
fee_crystallised = fee_total - fee_uncrystallised
|
|
2651
|
+
annual_rate = returns.assets.portfolio_growth_factor(ledger.allocation) - _ONE
|
|
2652
|
+
if fraction == _ONE:
|
|
2653
|
+
growth_rate = annual_rate
|
|
2654
|
+
else:
|
|
2655
|
+
expected_rate = (
|
|
2656
|
+
self._expected_asset_returns().portfolio_growth_factor(
|
|
2657
|
+
ledger.allocation
|
|
2658
|
+
)
|
|
2659
|
+
- _ONE
|
|
2660
|
+
)
|
|
2661
|
+
growth_rate = (
|
|
2662
|
+
expected_rate * fraction
|
|
2663
|
+
+ (annual_rate - expected_rate) * fraction.sqrt()
|
|
2664
|
+
)
|
|
2665
|
+
post_fee_uncrystallised = ledger.uncrystallised - fee_uncrystallised
|
|
2666
|
+
post_fee_crystallised = ledger.crystallised - fee_crystallised
|
|
2667
|
+
growth_uncrystallised = Money(post_fee_uncrystallised.amount * growth_rate)
|
|
2668
|
+
growth_crystallised = Money(post_fee_crystallised.amount * growth_rate)
|
|
2669
|
+
growth_tax = min(
|
|
2670
|
+
ledger.growth_tax,
|
|
2671
|
+
max(post_fee_uncrystallised + growth_uncrystallised, _ZERO),
|
|
2672
|
+
)
|
|
2673
|
+
# A drained account cannot fund its assessed portfolio-income
|
|
2674
|
+
# tax; the remainder joins the person's shortfall rather than
|
|
2675
|
+
# silently disappearing from the ledger (planning §5.2).
|
|
2676
|
+
ledger.growth_tax_unfunded = ledger.growth_tax - growth_tax
|
|
2677
|
+
after_tax_uncrystallised = (
|
|
2678
|
+
post_fee_uncrystallised + growth_uncrystallised - growth_tax
|
|
2679
|
+
)
|
|
2680
|
+
after_tax_crystallised = post_fee_crystallised + growth_crystallised
|
|
2681
|
+
aa_from_uncrystallised = min(
|
|
2682
|
+
ledger.aa_charge, max(after_tax_uncrystallised, _ZERO)
|
|
2683
|
+
)
|
|
2684
|
+
aa_from_crystallised = min(
|
|
2685
|
+
ledger.aa_charge - aa_from_uncrystallised,
|
|
2686
|
+
max(after_tax_crystallised, _ZERO),
|
|
2687
|
+
)
|
|
2688
|
+
aa_charge = aa_from_uncrystallised + aa_from_crystallised
|
|
2689
|
+
ledger.aa_charge_unfunded = ledger.aa_charge - aa_charge
|
|
2690
|
+
closing_uncrystallised = (
|
|
2691
|
+
after_tax_uncrystallised - aa_from_uncrystallised
|
|
2692
|
+
).quantized()
|
|
2693
|
+
closing_crystallised = (
|
|
2694
|
+
after_tax_crystallised - aa_from_crystallised
|
|
2695
|
+
).quantized()
|
|
2696
|
+
self._balances[ledger.wrapper.id] = (
|
|
2697
|
+
closing_uncrystallised,
|
|
2698
|
+
closing_crystallised,
|
|
2699
|
+
)
|
|
2700
|
+
return WrapperPeriodResult(
|
|
2701
|
+
wrapper_id=ledger.wrapper.id,
|
|
2702
|
+
kind=ledger.wrapper.kind,
|
|
2703
|
+
allocation=ledger.allocation,
|
|
2704
|
+
opening_uncrystallised=ledger.opening_uncrystallised.quantized(),
|
|
2705
|
+
opening_crystallised=ledger.opening_crystallised.quantized(),
|
|
2706
|
+
employee_contribution=ledger.employee_in.quantized(),
|
|
2707
|
+
employer_contribution=ledger.employer_in.quantized(),
|
|
2708
|
+
provider_relief=ledger.provider_relief.quantized(),
|
|
2709
|
+
contribution_shortfall=ledger.contribution_shortfall.quantized(),
|
|
2710
|
+
withdrawal_tax_free=ledger.withdrawal_tax_free.quantized(),
|
|
2711
|
+
withdrawal_taxable=ledger.withdrawal_taxable.quantized(),
|
|
2712
|
+
annuity_purchase=ledger.annuity_purchase.quantized(),
|
|
2713
|
+
fee=fee_total.quantized(),
|
|
2714
|
+
growth=(growth_uncrystallised + growth_crystallised).quantized(),
|
|
2715
|
+
closing_uncrystallised=closing_uncrystallised,
|
|
2716
|
+
closing_crystallised=closing_crystallised,
|
|
2717
|
+
contribution_bonus=ledger.bonus_in.quantized(),
|
|
2718
|
+
taxable_interest=ledger.taxable_interest.quantized(),
|
|
2719
|
+
taxable_dividends=ledger.taxable_dividends.quantized(),
|
|
2720
|
+
growth_tax=growth_tax.quantized(),
|
|
2721
|
+
aa_charge=aa_charge.quantized(),
|
|
2722
|
+
banked_in=ledger.banked_in.quantized(),
|
|
2723
|
+
)
|
|
2724
|
+
|
|
2725
|
+
|
|
2726
|
+
def _is_bare_taxable(treatment: WrapperTaxTreatment) -> bool:
|
|
2727
|
+
"""Whether a wrapper is a bare taxable account (a GIA or cash).
|
|
2728
|
+
|
|
2729
|
+
Paid from taxed income, growth taxable, withdrawals tax-free — the
|
|
2730
|
+
kind the decumulation surplus sweep banks into and the cash route
|
|
2731
|
+
of the annual-allowance charge pays from (planning §5.2).
|
|
2732
|
+
"""
|
|
2733
|
+
return (
|
|
2734
|
+
treatment.contributions is ContributionTaxTreatment.FROM_TAXED_INCOME
|
|
2735
|
+
and treatment.growth is GrowthTaxTreatment.TAXABLE
|
|
2736
|
+
and treatment.withdrawals is WithdrawalTaxTreatment.TAX_FREE
|
|
2737
|
+
)
|
|
2738
|
+
|
|
2739
|
+
|
|
2740
|
+
def _apply_contribution_caps(
|
|
2741
|
+
caps: tuple[ContributionCap, ...],
|
|
2742
|
+
used_by_group: dict[str, Money],
|
|
2743
|
+
*,
|
|
2744
|
+
employee: Money,
|
|
2745
|
+
employer: Money,
|
|
2746
|
+
) -> tuple[Money, Money]:
|
|
2747
|
+
"""Clip a contribution to its allowance groups' shared headroom.
|
|
2748
|
+
|
|
2749
|
+
The binding headroom is the tightest of the caps' remaining
|
|
2750
|
+
budgets; employer amounts (employment terms, outside the member's
|
|
2751
|
+
control) consume it first and the employee amount fills what
|
|
2752
|
+
remains. What fits is recorded against every listed group, so a
|
|
2753
|
+
sub-capped kind (a LISA) consumes the overall allowance too
|
|
2754
|
+
(planning §5.2). Returns the clipped ``(employee, employer)``.
|
|
2755
|
+
"""
|
|
2756
|
+
if not caps:
|
|
2757
|
+
return employee, employer
|
|
2758
|
+
headroom = min(
|
|
2759
|
+
max(cap.limit - used_by_group.get(cap.group, _ZERO), _ZERO) for cap in caps
|
|
2760
|
+
)
|
|
2761
|
+
employer = min(employer, headroom)
|
|
2762
|
+
employee = min(employee, max(headroom - employer, _ZERO))
|
|
2763
|
+
for cap in caps:
|
|
2764
|
+
used_by_group[cap.group] = (
|
|
2765
|
+
used_by_group.get(cap.group, _ZERO) + employer + employee
|
|
2766
|
+
)
|
|
2767
|
+
return employee, employer
|
|
2768
|
+
|
|
2769
|
+
|
|
2770
|
+
def _plan_source(
|
|
2771
|
+
sources: dict[WithdrawalSourceId, _WithdrawalSource],
|
|
2772
|
+
source_id: WithdrawalSourceId,
|
|
2773
|
+
) -> _WithdrawalSource:
|
|
2774
|
+
"""Resolve a plan's source reference, enforcing the access gates.
|
|
2775
|
+
|
|
2776
|
+
Raises:
|
|
2777
|
+
EngineError: If the plan references a source that does not
|
|
2778
|
+
exist or whose access gate has not opened (§4.1).
|
|
2779
|
+
"""
|
|
2780
|
+
source = sources.get(source_id)
|
|
2781
|
+
if source is None:
|
|
2782
|
+
msg = (
|
|
2783
|
+
f"withdrawal plan references unknown source: wrapper"
|
|
2784
|
+
f" {source_id.wrapper_id} (crystallised={source_id.crystallised})"
|
|
2785
|
+
)
|
|
2786
|
+
raise EngineError(msg)
|
|
2787
|
+
if not source.access_open:
|
|
2788
|
+
msg = (
|
|
2789
|
+
f"withdrawal plan draws on wrapper {source_id.wrapper_id}"
|
|
2790
|
+
" before its access gate opens"
|
|
2791
|
+
)
|
|
2792
|
+
raise EngineError(msg)
|
|
2793
|
+
return source
|
|
2794
|
+
|
|
2795
|
+
|
|
2796
|
+
def _spending_need(
|
|
2797
|
+
spending: SpendingPlan, stage: LifeStage, inflation: Decimal
|
|
2798
|
+
) -> Money:
|
|
2799
|
+
"""The period's net spending target in nominal money (§5.2 step 4).
|
|
2800
|
+
|
|
2801
|
+
The real (today's money) need is scaled by the stage multiplier
|
|
2802
|
+
when one is configured, then inflated by the run's cumulative CPI
|
|
2803
|
+
factor — the same single inflation truth the returns carry. The
|
|
2804
|
+
retirement sub-stage's own multiplier wins; the whole-retirement
|
|
2805
|
+
``DECUMULATION`` key covers sub-stages without one (planning §5.1).
|
|
2806
|
+
"""
|
|
2807
|
+
multiplier = _ONE
|
|
2808
|
+
if spending.stage_multipliers is not None:
|
|
2809
|
+
fallback = spending.stage_multipliers.get(LifeStage.DECUMULATION, _ONE)
|
|
2810
|
+
multiplier = spending.stage_multipliers.get(stage, fallback)
|
|
2811
|
+
return spending.annual_spending_real.value * multiplier * inflation
|