glidepath 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. glidepath/__init__.py +3 -0
  2. glidepath/app/__init__.py +364 -0
  3. glidepath/app/backtest.py +281 -0
  4. glidepath/app/charts.py +759 -0
  5. glidepath/app/copy.py +174 -0
  6. glidepath/app/display.py +148 -0
  7. glidepath/app/drawdown.py +436 -0
  8. glidepath/app/example.py +66 -0
  9. glidepath/app/exports.py +487 -0
  10. glidepath/app/files.py +249 -0
  11. glidepath/app/firstrun.py +114 -0
  12. glidepath/app/forms.py +1750 -0
  13. glidepath/app/inspector.py +506 -0
  14. glidepath/app/labels.py +66 -0
  15. glidepath/app/montecarlo.py +399 -0
  16. glidepath/app/plan.py +354 -0
  17. glidepath/app/retirement.py +446 -0
  18. glidepath/app/scenarios.py +831 -0
  19. glidepath/app/shell.py +185 -0
  20. glidepath/app/tables.py +138 -0
  21. glidepath/core/__init__.py +390 -0
  22. glidepath/core/annuities.py +240 -0
  23. glidepath/core/backtest.py +514 -0
  24. glidepath/core/comparison.py +278 -0
  25. glidepath/core/config.py +82 -0
  26. glidepath/core/contributions.py +337 -0
  27. glidepath/core/engine.py +2811 -0
  28. glidepath/core/entities.py +264 -0
  29. glidepath/core/glide.py +289 -0
  30. glidepath/core/investments.py +175 -0
  31. glidepath/core/money.py +107 -0
  32. glidepath/core/montecarlo.py +609 -0
  33. glidepath/core/pensions.py +298 -0
  34. glidepath/core/periods.py +367 -0
  35. glidepath/core/provenance.py +271 -0
  36. glidepath/core/randomness.py +128 -0
  37. glidepath/core/region.py +46 -0
  38. glidepath/core/reporting.py +231 -0
  39. glidepath/core/results.py +504 -0
  40. glidepath/core/retirement.py +291 -0
  41. glidepath/core/returns.py +312 -0
  42. glidepath/core/scenarios.py +579 -0
  43. glidepath/core/state_pension.py +264 -0
  44. glidepath/core/tax.py +139 -0
  45. glidepath/core/withdrawals.py +461 -0
  46. glidepath/core/wrappers.py +278 -0
  47. glidepath/gui/__init__.py +6 -0
  48. glidepath/gui/assets/icon_128.png +0 -0
  49. glidepath/gui/assets/icon_16.png +0 -0
  50. glidepath/gui/assets/icon_24.png +0 -0
  51. glidepath/gui/assets/icon_256.png +0 -0
  52. glidepath/gui/assets/icon_32.png +0 -0
  53. glidepath/gui/assets/icon_48.png +0 -0
  54. glidepath/gui/assets/icon_64.png +0 -0
  55. glidepath/gui/assets/wordmark.png +0 -0
  56. glidepath/gui/charts.py +829 -0
  57. glidepath/gui/forms.py +359 -0
  58. glidepath/gui/inspector.py +186 -0
  59. glidepath/gui/main.py +51 -0
  60. glidepath/gui/scenarios.py +402 -0
  61. glidepath/gui/style.py +376 -0
  62. glidepath/gui/tableview.py +67 -0
  63. glidepath/gui/widgets.py +989 -0
  64. glidepath/persistence/__init__.py +48 -0
  65. glidepath/persistence/assumptions.py +112 -0
  66. glidepath/persistence/decode.py +747 -0
  67. glidepath/persistence/document.py +101 -0
  68. glidepath/persistence/encode.py +433 -0
  69. glidepath/persistence/migrations.py +158 -0
  70. glidepath/persistence/values.py +298 -0
  71. glidepath/py.typed +0 -0
  72. glidepath/regions/__init__.py +7 -0
  73. glidepath/regions/uk/__init__.py +189 -0
  74. glidepath/regions/uk/ages.py +156 -0
  75. glidepath/regions/uk/contributions.py +717 -0
  76. glidepath/regions/uk/data/age_rules.toml +78 -0
  77. glidepath/regions/uk/data/assumptions_default.toml +170 -0
  78. glidepath/regions/uk/data/returns_history.toml +150 -0
  79. glidepath/regions/uk/data/tax_year_2026_27.toml +98 -0
  80. glidepath/regions/uk/extension.py +479 -0
  81. glidepath/regions/uk/loader.py +704 -0
  82. glidepath/regions/uk/region.py +160 -0
  83. glidepath/regions/uk/schema.py +563 -0
  84. glidepath/regions/uk/state_pension.py +129 -0
  85. glidepath/regions/uk/tax.py +466 -0
  86. glidepath/regions/uk/wrappers.py +283 -0
  87. glidepath/regions/uk/years.py +92 -0
  88. glidepath-0.2.0.dist-info/METADATA +189 -0
  89. glidepath-0.2.0.dist-info/RECORD +93 -0
  90. glidepath-0.2.0.dist-info/WHEEL +4 -0
  91. glidepath-0.2.0.dist-info/entry_points.txt +3 -0
  92. glidepath-0.2.0.dist-info/licenses/LICENSE +21 -0
  93. glidepath-0.2.0.dist-info/licenses/LICENSE-DATA +28 -0
@@ -0,0 +1,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