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,461 @@
1
+ """Withdrawal strategies and plans (roadmap 5.1; planning §5.2 step 4).
2
+
3
+ A :class:`WithdrawalStrategy` decides how a decumulation period's net
4
+ spending need is met from the person's wrappers. The engine builds a
5
+ :class:`WithdrawalState` — every drawable sub-balance with its balance,
6
+ tax-free fraction, and access-gate position — and the strategy returns a
7
+ :class:`WithdrawalPlan` for the engine to execute:
8
+
9
+ - a :class:`NetWithdrawalPlan` states a **net (after-tax) target** and an
10
+ ordered source list; the engine grosses each draw up against the
11
+ region tax system by fixed-point iteration (planning §5.2 step 4);
12
+ - a :class:`GrossWithdrawalPlan` states exact **gross** amounts per
13
+ source and skips the iteration entirely.
14
+
15
+ Strategies encode the wrapper ordering (planning §5.2): the tax-aware
16
+ default of :func:`tax_aware_order` draws taxable-growth accounts
17
+ (GIA/cash) first, then wholly tax-free sub-balances, then funds
18
+ already in drawdown (no fresh tax-free cash), then uncrystallised
19
+ funds whose access gate is open — the full GIA/cash → ISA → pension
20
+ default. Access ages are respected by construction: the ordering never
21
+ includes a gate-closed source, and the engine refuses any plan that
22
+ draws on one.
23
+
24
+ Everything here is region-agnostic: sources describe themselves through
25
+ the generic tax-treatment vocabulary of :mod:`glidepath.core.wrappers`,
26
+ so no account kind is ever named (planning §4.2).
27
+ """
28
+
29
+ from dataclasses import dataclass
30
+ from decimal import Decimal
31
+ from enum import Enum, auto
32
+ from typing import TYPE_CHECKING, ClassVar, Protocol
33
+
34
+ from glidepath.core.money import Money, Rate
35
+
36
+ if TYPE_CHECKING:
37
+ from collections.abc import Iterable
38
+
39
+ from glidepath.core.entities import EntityId
40
+ from glidepath.core.wrappers import WrapperKindId
41
+
42
+ _ZERO = Money(Decimal(0))
43
+ _ZERO_FRACTION = Decimal(0)
44
+ _ONE = Decimal(1)
45
+ _DEFAULT_UPPER_GUARDRAIL = Rate(Decimal("0.06"))
46
+ _DEFAULT_LOWER_GUARDRAIL = Rate(Decimal("0.04"))
47
+ _DEFAULT_ADJUSTMENT = Decimal("0.1")
48
+
49
+
50
+ class TaxFreeCashStrategy(Enum):
51
+ """How pension tax-free cash is taken (planning §5.2, roadmap 5.2).
52
+
53
+ A decision record on the run configuration, orthogonal to the
54
+ withdrawal strategy — any combination of the two is valid. The
55
+ names are generic (planning §4.2); the region's tax treatment
56
+ supplies the tax-free fraction and the lifetime cap
57
+ (:meth:`~glidepath.core.wrappers.WrapperRuleset.lump_sum_allowance`).
58
+ Gross-defined plans resolve every mode as
59
+ :attr:`SPLIT_EACH_PAYMENT` — an exact gross amount is a payment
60
+ instruction, not a designation (planning §5.2).
61
+ """
62
+
63
+ SPLIT_EACH_PAYMENT = auto()
64
+ """Every uncrystallised draw carries the tax-free fraction (UK: UFPLS).
65
+
66
+ The default. The remainder of each payment arrives as taxable
67
+ income, so the first payment marks flexible access.
68
+ """
69
+
70
+ LUMP_SUM_AS_NEEDED = auto()
71
+ """Tax-free cash first, designating the rest (UK: phased FAD).
72
+
73
+ An uncrystallised draw delivers tax-free cash only, moving the
74
+ crystallised remainder into the wrapper's drawdown sub-balance,
75
+ which stays invested; taxable income is drawn only once tax-free
76
+ cash cannot meet the remaining need — so flexible access is not
77
+ marked until taxable income actually flows.
78
+ """
79
+
80
+ UP_FRONT_LUMP_SUM = auto()
81
+ """Full crystallisation at first open access (UK: PCLS up front).
82
+
83
+ In the first decumulation period whose access gate is open, each
84
+ uncrystallised pension pot crystallises whole: the capped tax-free
85
+ lump sum joins the period's income offset and the remainder moves
86
+ to the crystallised sub-balance. Lump-sum cash beyond the period's
87
+ need banks into the person's first uncapped taxable wrapper
88
+ (GIA/cash, roadmap 9.2); with none it is spent (planning §5.2).
89
+ """
90
+
91
+
92
+ @dataclass(frozen=True, slots=True)
93
+ class WithdrawalSourceId:
94
+ """A stable reference to one drawable sub-balance.
95
+
96
+ Pension wrappers hold two (planning §5.1): the uncrystallised pot
97
+ and the funds already designated to drawdown. Plans reference
98
+ sources by this key, so a strategy never touches engine internals.
99
+ """
100
+
101
+ wrapper_id: EntityId
102
+ crystallised: bool
103
+
104
+
105
+ @dataclass(frozen=True, slots=True)
106
+ class WithdrawalSource:
107
+ """One drawable sub-balance as a strategy sees it (planning §5.2).
108
+
109
+ ``available`` is the balance at the start of the withdrawal step;
110
+ ``tax_free_fraction`` is the *nominal* share of a draw that
111
+ arrives tax-free (1 for a wholly tax-free wrapper, the region's
112
+ fraction for a partially tax-free pot, 0 for taxable income) —
113
+ for pension sources the tax-free element is additionally capped by
114
+ the remaining lifetime headroom
115
+ (:attr:`WithdrawalState.tax_free_cash_headroom`), so a draw past
116
+ the cap delivers less than the fraction alone promises (roadmap
117
+ 5.2); ``access_open`` follows the §4.1 gate convention —
118
+ crystallised funds are always open (already accessed, never
119
+ re-gated; planning §5.1).
120
+
121
+ ``natural_yield`` is the income this sub-balance throws off over
122
+ the period — its balance at the shipped per-asset yield
123
+ assumptions through the wrapper's allocation, scaled by the
124
+ period's active fraction (roadmap 5.3). The engine prices it only
125
+ for strategies declaring
126
+ :attr:`WithdrawalStrategy.uses_natural_yield`, so runs that never
127
+ read the yield assumptions never record them in provenance; other
128
+ strategies see zero.
129
+ """
130
+
131
+ id: WithdrawalSourceId
132
+ kind: WrapperKindId
133
+ available: Money
134
+ tax_free_fraction: Decimal
135
+ access_open: bool
136
+ natural_yield: Money = _ZERO
137
+ growth_taxable: bool = False
138
+ """Whether the wrapper's growth is taxed as it arises (roadmap 9.2).
139
+
140
+ Drawing a taxable-growth account (a GIA or cash account) first
141
+ stops future income tax accruing on what it holds, so the default
142
+ ordering spends these before tax-sheltered accounts — the core
143
+ reads the flag from the generic tax-treatment vocabulary, never
144
+ from the kind (planning §4.2).
145
+ """
146
+
147
+ def __post_init__(self) -> None:
148
+ """Reject a negative balance, yield, or fraction outside [0, 1]."""
149
+ if self.available < _ZERO:
150
+ msg = "WithdrawalSource.available must be non-negative"
151
+ raise ValueError(msg)
152
+ if not _ZERO_FRACTION <= self.tax_free_fraction <= _ONE:
153
+ msg = "WithdrawalSource.tax_free_fraction must lie between 0 and 1"
154
+ raise ValueError(msg)
155
+ if self.natural_yield < _ZERO:
156
+ msg = "WithdrawalSource.natural_yield must be non-negative"
157
+ raise ValueError(msg)
158
+
159
+
160
+ @dataclass(frozen=True, slots=True)
161
+ class WithdrawalState:
162
+ """What a strategy may read when planning a period's withdrawals.
163
+
164
+ ``sources`` lists every sub-balance in plan (wrapper) order —
165
+ gate-closed sources included, flagged, so a strategy can see the
166
+ whole pot; ``year_fraction`` is the period's active fraction
167
+ (roadmap 4.6), by which gross-defined annual amounts scale.
168
+ ``tax_free_cash_headroom`` is the tax-free cash still allowed
169
+ under the region's lifetime cap as the withdrawal step opens —
170
+ cumulative usage (the ``lsa_used`` fact plus everything this run
171
+ has paid, income lump sums included) already deducted — or
172
+ ``None`` where the region has no cap (roadmap 5.2), so a
173
+ tax-aware strategy can size pension draws against the cap the
174
+ engine will actually enforce.
175
+ """
176
+
177
+ sources: tuple[WithdrawalSource, ...]
178
+ year_fraction: Decimal
179
+ tax_free_cash_headroom: Money | None = None
180
+
181
+ def __post_init__(self) -> None:
182
+ """Require a fraction in [0, 1] and non-negative headroom."""
183
+ if not _ZERO_FRACTION <= self.year_fraction <= _ONE:
184
+ msg = "WithdrawalState.year_fraction must lie between 0 and 1"
185
+ raise ValueError(msg)
186
+ if self.tax_free_cash_headroom is not None and (
187
+ self.tax_free_cash_headroom < _ZERO
188
+ ):
189
+ msg = "WithdrawalState.tax_free_cash_headroom must be non-negative"
190
+ raise ValueError(msg)
191
+
192
+
193
+ @dataclass(frozen=True, slots=True)
194
+ class NetWithdrawalPlan:
195
+ """Deliver ``target`` net cash, drawing ``order`` front to back.
196
+
197
+ The engine grosses each draw up against the region tax system until
198
+ the target is met or the listed sources are exhausted (planning
199
+ §5.2 step 4); the unmet remainder is the period's shortfall.
200
+ """
201
+
202
+ target: Money
203
+ order: tuple[WithdrawalSourceId, ...]
204
+
205
+ def __post_init__(self) -> None:
206
+ """Reject a negative target."""
207
+ if self.target < _ZERO:
208
+ msg = "NetWithdrawalPlan.target must be non-negative"
209
+ raise ValueError(msg)
210
+
211
+
212
+ @dataclass(frozen=True, slots=True)
213
+ class GrossDraw:
214
+ """One gross draw; execution caps it at the source's balance."""
215
+
216
+ source: WithdrawalSourceId
217
+ amount: Money
218
+
219
+ def __post_init__(self) -> None:
220
+ """Reject a negative amount."""
221
+ if self.amount < _ZERO:
222
+ msg = "GrossDraw.amount must be non-negative"
223
+ raise ValueError(msg)
224
+
225
+
226
+ @dataclass(frozen=True, slots=True)
227
+ class GrossWithdrawalPlan:
228
+ """Draw exact gross amounts, in order, with no net gross-up.
229
+
230
+ The net cash delivered is whatever remains after tax; a gap between
231
+ it and the period's need is reported as shortfall (under-draw),
232
+ while an over-draw banks into the person's first uncapped taxable
233
+ wrapper — spent only when they hold none (roadmap 9.2).
234
+ """
235
+
236
+ draws: tuple[GrossDraw, ...]
237
+
238
+
239
+ type WithdrawalPlan = NetWithdrawalPlan | GrossWithdrawalPlan
240
+ """What a strategy returns: net-defined or gross-defined (planning §5.2)."""
241
+
242
+
243
+ class WithdrawalStrategy(Protocol):
244
+ """The decumulation withdrawal decision (planning §5.1, §5.2).
245
+
246
+ A strategy is a *decision record* — a user choice, part of the
247
+ scenario what-if whitelist (planning §4.3) — carried on the run
248
+ configuration (§5.2). Implementations must be pure: the same state
249
+ and need always produce the same plan (planning §4.6).
250
+
251
+ A strategy that spends portfolio income may additionally declare a
252
+ class-level ``uses_natural_yield = True`` (see
253
+ :class:`NaturalYieldWithdrawalStrategy`): the engine then prices
254
+ each source's natural yield from the ``yield.*`` assumption keys
255
+ (planning §7). The marker is deliberately *not* part of this
256
+ protocol — pricing is opt-in, so an absent marker simply means
257
+ ``False`` and a strategy that only implements ``withdraw`` keeps
258
+ working (roadmap 5.3).
259
+ """
260
+
261
+ def withdraw(self, state: WithdrawalState, need: Money) -> WithdrawalPlan:
262
+ """Plan one period's withdrawals toward ``need`` net cash.
263
+
264
+ ``need`` is the net (after-tax) cash still required once
265
+ net-of-tax pension income has met what it can (planning §5.1);
266
+ gross-defined strategies are free to ignore it.
267
+ """
268
+ ...
269
+
270
+
271
+ def tax_aware_order(
272
+ sources: Iterable[WithdrawalSource],
273
+ ) -> tuple[WithdrawalSource, ...]:
274
+ """The default draw order (planning §5.2), gate-closed excluded.
275
+
276
+ The full ordering is GIA/cash → ISA → pension: taxable-growth
277
+ accounts first — every pound left in them keeps accruing income
278
+ tax, so spending them shelters the rest — then wholly tax-free
279
+ sub-balances (drawing them never wastes a penny of allowance),
280
+ then funds already in drawdown — their tax-free cash is spent, so
281
+ they cost only income tax — and last open uncrystallised pension
282
+ funds, whose draws surrender future tax-free growth. A source
283
+ whose access gate has not opened is excluded whatever its group:
284
+ tax treatment says nothing about accessibility — an age-gated
285
+ tax-free account (a LISA) is just as ungated-by-§4.1 as a
286
+ pension. Within each group, plan (wrapper) order is preserved.
287
+ """
288
+ entries = tuple(entry for entry in sources if entry.access_open)
289
+ taxable_growth = [
290
+ entry
291
+ for entry in entries
292
+ if entry.tax_free_fraction == _ONE and entry.growth_taxable
293
+ ]
294
+ free = [
295
+ entry
296
+ for entry in entries
297
+ if entry.tax_free_fraction == _ONE and not entry.growth_taxable
298
+ ]
299
+ crystallised = [
300
+ entry
301
+ for entry in entries
302
+ if entry.tax_free_fraction != _ONE and entry.id.crystallised
303
+ ]
304
+ uncrystallised = [
305
+ entry
306
+ for entry in entries
307
+ if entry.tax_free_fraction != _ONE and not entry.id.crystallised
308
+ ]
309
+ return (*taxable_growth, *free, *crystallised, *uncrystallised)
310
+
311
+
312
+ @dataclass(frozen=True, slots=True)
313
+ class FixedRealWithdrawalStrategy:
314
+ """Fixed real spending: meet the net need, exactly (planning §5.2).
315
+
316
+ The need the engine passes in is already the real spending decision
317
+ inflated by the run's CPI path (one inflation truth per run), so
318
+ meeting it each period *is* constant real spending. Net-defined:
319
+ the engine grosses draws up against the tax system. This is the v1
320
+ default strategy.
321
+ """
322
+
323
+ def withdraw(self, state: WithdrawalState, need: Money) -> WithdrawalPlan:
324
+ """Target the whole need over the default tax-aware order."""
325
+ order = tuple(entry.id for entry in tax_aware_order(state.sources))
326
+ return NetWithdrawalPlan(target=need, order=order)
327
+
328
+
329
+ @dataclass(frozen=True, slots=True)
330
+ class FixedPercentWithdrawalStrategy:
331
+ """Fixed percentage of the pot, gross-defined (planning §5.2).
332
+
333
+ Each period draws ``rate`` of the *accessible* pot — every source
334
+ the default tax-aware order may touch, gate-closed funds excluded —
335
+ scaled by the period's active fraction, allocated across sources in
336
+ that same order. Gross-defined by declaration: the plan states
337
+ exact gross amounts and the engine skips the net gross-up
338
+ iteration. The net delivered therefore floats with the tax system;
339
+ any gap to the period's need is reported as shortfall.
340
+ """
341
+
342
+ rate: Rate
343
+
344
+ def __post_init__(self) -> None:
345
+ """Require a rate in [0, 1] — a share of the pot, per year."""
346
+ if not _ZERO_FRACTION <= self.rate.value <= _ONE:
347
+ msg = "FixedPercentWithdrawalStrategy.rate must lie between 0 and 1"
348
+ raise ValueError(msg)
349
+
350
+ def withdraw(self, state: WithdrawalState, need: Money) -> WithdrawalPlan:
351
+ """Draw the rate's share of the accessible pot, in order."""
352
+ del need # Gross-defined: the pot, not the need, sets the draw.
353
+ ordered = tax_aware_order(state.sources)
354
+ pot = _ZERO
355
+ for entry in ordered:
356
+ pot = pot + entry.available
357
+ remaining = pot * (self.rate.value * state.year_fraction)
358
+ draws: list[GrossDraw] = []
359
+ for entry in ordered:
360
+ if remaining <= _ZERO:
361
+ break
362
+ if entry.available <= _ZERO:
363
+ continue
364
+ amount = min(remaining, entry.available)
365
+ draws.append(GrossDraw(source=entry.id, amount=amount))
366
+ remaining = remaining - amount
367
+ return GrossWithdrawalPlan(draws=tuple(draws))
368
+
369
+
370
+ @dataclass(frozen=True, slots=True)
371
+ class GuardrailsWithdrawalStrategy:
372
+ """Guyton-Klinger-style guardrails, net-defined (roadmap 5.3).
373
+
374
+ The need the engine passes in — the CPI-inflated spending decision,
375
+ net of pension income — is the baseline; the strategy annualises
376
+ the withdrawal rate it implies (need over the period's active
377
+ fraction, over the accessible pot) and adjusts spending when that
378
+ rate crosses a configured guardrail: above ``upper_guardrail`` the
379
+ target is cut by ``cut_fraction`` (the capital-preservation rule),
380
+ below ``lower_guardrail`` it rises by ``rise_fraction`` (the
381
+ prosperity rule). The defaults are the conventional
382
+ Guyton-Klinger parameters: guardrails at 6%/4% around an implied
383
+ 5% initial rate, adjusting spending by 10%.
384
+
385
+ The protocol is pure — the same state and need always produce the
386
+ same plan — so each period is judged afresh from the pot alone and
387
+ adjustments never compound across periods (planning §5.2). A cut's
388
+ unspent remainder is reported as shortfall, exactly as a
389
+ gross-defined under-draw is: the roadmap-7.3 metrics read spending
390
+ cuts from there. A rise is genuinely spent: the engine treats the
391
+ adjusted target as the period's net need, so the roadmap-9.2
392
+ sweep banks only delivery beyond it — never the rise itself.
393
+ """
394
+
395
+ upper_guardrail: Rate = _DEFAULT_UPPER_GUARDRAIL
396
+ lower_guardrail: Rate = _DEFAULT_LOWER_GUARDRAIL
397
+ cut_fraction: Decimal = _DEFAULT_ADJUSTMENT
398
+ rise_fraction: Decimal = _DEFAULT_ADJUSTMENT
399
+
400
+ def __post_init__(self) -> None:
401
+ """Require ordered positive guardrails and fractions in [0, 1]."""
402
+ if not _ZERO_FRACTION < self.lower_guardrail.value < self.upper_guardrail.value:
403
+ msg = (
404
+ "GuardrailsWithdrawalStrategy guardrails must satisfy"
405
+ " 0 < lower_guardrail < upper_guardrail"
406
+ )
407
+ raise ValueError(msg)
408
+ for name, fraction in (
409
+ ("cut_fraction", self.cut_fraction),
410
+ ("rise_fraction", self.rise_fraction),
411
+ ):
412
+ if not _ZERO_FRACTION <= fraction <= _ONE:
413
+ msg = f"GuardrailsWithdrawalStrategy.{name} must lie between 0 and 1"
414
+ raise ValueError(msg)
415
+
416
+ def withdraw(self, state: WithdrawalState, need: Money) -> WithdrawalPlan:
417
+ """Target the need, adjusted on a guardrail crossing."""
418
+ ordered = tax_aware_order(state.sources)
419
+ order = tuple(entry.id for entry in ordered)
420
+ pot = _ZERO
421
+ for entry in ordered:
422
+ pot = pot + entry.available
423
+ target = need
424
+ if need > _ZERO and pot > _ZERO and state.year_fraction > _ZERO_FRACTION:
425
+ annualised = need.amount / state.year_fraction / pot.amount
426
+ if annualised > self.upper_guardrail.value:
427
+ target = need * (_ONE - self.cut_fraction)
428
+ elif annualised < self.lower_guardrail.value:
429
+ target = need * (_ONE + self.rise_fraction)
430
+ return NetWithdrawalPlan(target=target, order=order)
431
+
432
+
433
+ @dataclass(frozen=True, slots=True)
434
+ class NaturalYieldWithdrawalStrategy:
435
+ """Spend the portfolio's income, never its capital (roadmap 5.3).
436
+
437
+ Gross-defined: each accessible source is drawn by exactly its
438
+ period natural yield (:attr:`WithdrawalSource.natural_yield`) —
439
+ the income its balance throws off at the shipped per-asset yield
440
+ assumptions, which the engine prices only because this strategy
441
+ declares ``uses_natural_yield`` (an opt-in marker, not a protocol
442
+ member — see :class:`WithdrawalStrategy`). Draws follow the default
443
+ tax-aware order, and a yield taken from an uncrystallised pension
444
+ pot resolves through the normal payment machinery (in the model an
445
+ income draw is a withdrawal, so its tax follows the wrapper's
446
+ rules). The net delivered floats with the pot and the tax system;
447
+ any gap to the period's need is reported as shortfall (planning
448
+ §5.2).
449
+ """
450
+
451
+ uses_natural_yield: ClassVar[bool] = True
452
+
453
+ def withdraw(self, state: WithdrawalState, need: Money) -> WithdrawalPlan:
454
+ """Draw every accessible source's natural yield, in order."""
455
+ del need # Gross-defined: the yield, not the need, sets the draw.
456
+ draws = tuple(
457
+ GrossDraw(source=entry.id, amount=min(entry.natural_yield, entry.available))
458
+ for entry in tax_aware_order(state.sources)
459
+ if entry.natural_yield > _ZERO and entry.available > _ZERO
460
+ )
461
+ return GrossWithdrawalPlan(draws=draws)