glidepath 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- glidepath/__init__.py +3 -0
- glidepath/app/__init__.py +364 -0
- glidepath/app/backtest.py +281 -0
- glidepath/app/charts.py +759 -0
- glidepath/app/copy.py +174 -0
- glidepath/app/display.py +148 -0
- glidepath/app/drawdown.py +436 -0
- glidepath/app/example.py +66 -0
- glidepath/app/exports.py +487 -0
- glidepath/app/files.py +249 -0
- glidepath/app/firstrun.py +114 -0
- glidepath/app/forms.py +1750 -0
- glidepath/app/inspector.py +506 -0
- glidepath/app/labels.py +66 -0
- glidepath/app/montecarlo.py +399 -0
- glidepath/app/plan.py +354 -0
- glidepath/app/retirement.py +446 -0
- glidepath/app/scenarios.py +831 -0
- glidepath/app/shell.py +185 -0
- glidepath/app/tables.py +138 -0
- glidepath/core/__init__.py +390 -0
- glidepath/core/annuities.py +240 -0
- glidepath/core/backtest.py +514 -0
- glidepath/core/comparison.py +278 -0
- glidepath/core/config.py +82 -0
- glidepath/core/contributions.py +337 -0
- glidepath/core/engine.py +2811 -0
- glidepath/core/entities.py +264 -0
- glidepath/core/glide.py +289 -0
- glidepath/core/investments.py +175 -0
- glidepath/core/money.py +107 -0
- glidepath/core/montecarlo.py +609 -0
- glidepath/core/pensions.py +298 -0
- glidepath/core/periods.py +367 -0
- glidepath/core/provenance.py +271 -0
- glidepath/core/randomness.py +128 -0
- glidepath/core/region.py +46 -0
- glidepath/core/reporting.py +231 -0
- glidepath/core/results.py +504 -0
- glidepath/core/retirement.py +291 -0
- glidepath/core/returns.py +312 -0
- glidepath/core/scenarios.py +579 -0
- glidepath/core/state_pension.py +264 -0
- glidepath/core/tax.py +139 -0
- glidepath/core/withdrawals.py +461 -0
- glidepath/core/wrappers.py +278 -0
- glidepath/gui/__init__.py +6 -0
- glidepath/gui/assets/icon_128.png +0 -0
- glidepath/gui/assets/icon_16.png +0 -0
- glidepath/gui/assets/icon_24.png +0 -0
- glidepath/gui/assets/icon_256.png +0 -0
- glidepath/gui/assets/icon_32.png +0 -0
- glidepath/gui/assets/icon_48.png +0 -0
- glidepath/gui/assets/icon_64.png +0 -0
- glidepath/gui/assets/wordmark.png +0 -0
- glidepath/gui/charts.py +829 -0
- glidepath/gui/forms.py +359 -0
- glidepath/gui/inspector.py +186 -0
- glidepath/gui/main.py +51 -0
- glidepath/gui/scenarios.py +402 -0
- glidepath/gui/style.py +376 -0
- glidepath/gui/tableview.py +67 -0
- glidepath/gui/widgets.py +989 -0
- glidepath/persistence/__init__.py +48 -0
- glidepath/persistence/assumptions.py +112 -0
- glidepath/persistence/decode.py +747 -0
- glidepath/persistence/document.py +101 -0
- glidepath/persistence/encode.py +433 -0
- glidepath/persistence/migrations.py +158 -0
- glidepath/persistence/values.py +298 -0
- glidepath/py.typed +0 -0
- glidepath/regions/__init__.py +7 -0
- glidepath/regions/uk/__init__.py +189 -0
- glidepath/regions/uk/ages.py +156 -0
- glidepath/regions/uk/contributions.py +717 -0
- glidepath/regions/uk/data/age_rules.toml +78 -0
- glidepath/regions/uk/data/assumptions_default.toml +170 -0
- glidepath/regions/uk/data/returns_history.toml +150 -0
- glidepath/regions/uk/data/tax_year_2026_27.toml +98 -0
- glidepath/regions/uk/extension.py +479 -0
- glidepath/regions/uk/loader.py +704 -0
- glidepath/regions/uk/region.py +160 -0
- glidepath/regions/uk/schema.py +563 -0
- glidepath/regions/uk/state_pension.py +129 -0
- glidepath/regions/uk/tax.py +466 -0
- glidepath/regions/uk/wrappers.py +283 -0
- glidepath/regions/uk/years.py +92 -0
- glidepath-0.2.0.dist-info/METADATA +189 -0
- glidepath-0.2.0.dist-info/RECORD +93 -0
- glidepath-0.2.0.dist-info/WHEEL +4 -0
- glidepath-0.2.0.dist-info/entry_points.txt +3 -0
- glidepath-0.2.0.dist-info/licenses/LICENSE +21 -0
- glidepath-0.2.0.dist-info/licenses/LICENSE-DATA +28 -0
|
@@ -0,0 +1,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)
|