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,436 @@
|
|
|
1
|
+
"""The "How much can I draw down?" card: transition and panel (9.25).
|
|
2
|
+
|
|
3
|
+
The Phase 9.25 core solver
|
|
4
|
+
(:func:`~glidepath.core.sustainable_income_at_age`) surfaced per
|
|
5
|
+
planning §4.7: a card on the charts screen asking for a retirement age
|
|
6
|
+
(defaulting to the plan's stated retirement-age decision) and
|
|
7
|
+
answering with the highest net annual income, in today's money, the
|
|
8
|
+
plan sustains when retiring at that age — the drawdown dual of the
|
|
9
|
+
"When can I retire?" card (roadmap 9.14). The search runs the plan
|
|
10
|
+
once per probed spending level — an explicit user action like the
|
|
11
|
+
Monte Carlo run (roadmap 9.13), far too slow for every keystroke —
|
|
12
|
+
and its basis follows the screen's run mode: the deterministic
|
|
13
|
+
projection, or "at least N% Monte Carlo success" over the panel's
|
|
14
|
+
seed and path count once a Monte Carlo basis is selected. The
|
|
15
|
+
transition re-anchors the whole state through
|
|
16
|
+
:func:`~glidepath.app.plan.replanned_state`, so a held answer can
|
|
17
|
+
never go stale against a changed plan.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from dataclasses import dataclass, replace
|
|
21
|
+
from decimal import Decimal
|
|
22
|
+
from typing import TYPE_CHECKING, Any, Final
|
|
23
|
+
|
|
24
|
+
from glidepath.app.display import format_money, format_percent
|
|
25
|
+
from glidepath.app.montecarlo import (
|
|
26
|
+
MAX_PATHS,
|
|
27
|
+
MONTE_CARLO_PATHS_MESSAGE,
|
|
28
|
+
MONTE_CARLO_SEED_MESSAGE,
|
|
29
|
+
path_pool,
|
|
30
|
+
)
|
|
31
|
+
from glidepath.app.plan import PlanState, region_for, replanned_state
|
|
32
|
+
from glidepath.app.retirement import parsed_percent
|
|
33
|
+
from glidepath.core import (
|
|
34
|
+
AssumptionKey,
|
|
35
|
+
Money,
|
|
36
|
+
RunConfig,
|
|
37
|
+
RunMode,
|
|
38
|
+
SustainableIncomeSearch,
|
|
39
|
+
age_on,
|
|
40
|
+
int_assumption_value,
|
|
41
|
+
sustainable_income_at_age,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
if TYPE_CHECKING:
|
|
45
|
+
from datetime import date
|
|
46
|
+
|
|
47
|
+
from glidepath.core import AssumptionSet, Household
|
|
48
|
+
|
|
49
|
+
_ONE = Decimal(1)
|
|
50
|
+
_TWO = Decimal(2)
|
|
51
|
+
|
|
52
|
+
DRAWDOWN_HEADING: Final = "How much can I draw down?"
|
|
53
|
+
|
|
54
|
+
DRAWDOWN_AGE_LABEL: Final = "Retirement age"
|
|
55
|
+
|
|
56
|
+
DRAWDOWN_SUCCESS_LABEL: Final = "Success target (%)"
|
|
57
|
+
|
|
58
|
+
DEFAULT_DRAWDOWN_SUCCESS_VALUE: Final = "90"
|
|
59
|
+
|
|
60
|
+
FIND_DRAWDOWN_LABEL: Final = "Find sustainable income"
|
|
61
|
+
|
|
62
|
+
NO_DRAWDOWN_MESSAGE: Final = (
|
|
63
|
+
"No answer yet — choose a retirement age, then press Find sustainable income."
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
DRAWDOWN_NO_PLAN_MESSAGE: Final = (
|
|
67
|
+
"Save facts on the Facts tab before asking how much you can draw down."
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
DRAWDOWN_AGE_MESSAGE: Final = (
|
|
71
|
+
"The retirement age needs a whole number from the current age"
|
|
72
|
+
" to one below the planning horizon age."
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
DRAWDOWN_SUCCESS_MESSAGE: Final = (
|
|
76
|
+
"The success target needs a whole number between 1 and 100."
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
DRAWDOWN_HORIZON_MESSAGE: Final = (
|
|
80
|
+
"No retirement age to test — the planning horizon age is not"
|
|
81
|
+
" beyond the current age."
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
DRAWDOWN_SEARCH_MAXIMUM: Final = Money(Decimal(1_000_000))
|
|
85
|
+
"""The income search's upper bracket: £1,000,000 of net annual income.
|
|
86
|
+
|
|
87
|
+
Generous beyond any plan a personal modelling tool holds, so the
|
|
88
|
+
bracket never clips a real answer; the cost of the headroom is only
|
|
89
|
+
the scan-plus-bisection's logarithmic probe count
|
|
90
|
+
(:func:`search_probe_bound`).
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
MAX_DRAWDOWN_PATH_RUNS: Final = 20_000
|
|
94
|
+
"""The most Monte Carlo path-projections one search may add up to.
|
|
95
|
+
|
|
96
|
+
The 9.13 per-run path cap bounds one Monte Carlo run; a search
|
|
97
|
+
multiplies its path count by the probe bound, so it carries the same
|
|
98
|
+
aggregate budget as the retirement-age search (roadmap 9.14).
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
DRAWDOWN_BUDGET_MESSAGE: Final = (
|
|
102
|
+
"Lower the path count — this search would project more than"
|
|
103
|
+
f" {MAX_DRAWDOWN_PATH_RUNS:,} Monte Carlo paths across its probed"
|
|
104
|
+
" spending levels."
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
DRAWDOWN_FAILED_PREFIX: Final = "The sustainable-income search failed: "
|
|
108
|
+
|
|
109
|
+
DRAWDOWN_RUNNING_MESSAGE: Final = "Searching for the sustainable income…"
|
|
110
|
+
|
|
111
|
+
DRAWDOWN_STALE_MESSAGE: Final = (
|
|
112
|
+
"Sustainable-income answer discarded — the plan changed while it ran."
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
DRAWDOWN_ANSWER_PREFIX: Final = "Sustainable income: "
|
|
116
|
+
|
|
117
|
+
DRAWDOWN_DETERMINISTIC_BASIS: Final = (
|
|
118
|
+
"Basis: deterministic projection, no unmet need in any period."
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def search_probe_bound(search: SustainableIncomeSearch) -> int:
|
|
123
|
+
"""The most spending levels one income search can probe.
|
|
124
|
+
|
|
125
|
+
One probe at the maximum, one per interior scan point and one at
|
|
126
|
+
the zero floor, then the bisection's halvings of one scan step
|
|
127
|
+
down to the tolerance — what the Monte Carlo budget check
|
|
128
|
+
multiplies by the path count, mirroring the candidate-age count of
|
|
129
|
+
the retirement-age search (roadmap 9.14).
|
|
130
|
+
"""
|
|
131
|
+
probes = 1 + search.scan_steps
|
|
132
|
+
step = search.maximum.amount / Decimal(search.scan_steps)
|
|
133
|
+
while step > search.tolerance.amount:
|
|
134
|
+
step = step / _TWO
|
|
135
|
+
probes += 1
|
|
136
|
+
return probes
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@dataclass(frozen=True)
|
|
140
|
+
class DrawdownRequest:
|
|
141
|
+
"""One card submission, as the shell captured it (planning §4.7).
|
|
142
|
+
|
|
143
|
+
``mode`` is the charts screen's run-mode selection — the answer's
|
|
144
|
+
basis. ``seed_text``, ``paths_text``, and ``success_text`` are read
|
|
145
|
+
only under the Monte Carlo mode: the seed and path count come from
|
|
146
|
+
the Monte Carlo panel's own controls, so the bands and the answer
|
|
147
|
+
always describe the same runs.
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
mode: RunMode
|
|
151
|
+
age_text: str
|
|
152
|
+
seed_text: str = ""
|
|
153
|
+
paths_text: str = ""
|
|
154
|
+
success_text: str = ""
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@dataclass(frozen=True)
|
|
158
|
+
class DrawdownAnswer:
|
|
159
|
+
"""One solved answer with the inputs that produced it (§4.6).
|
|
160
|
+
|
|
161
|
+
``income`` is the highest sustainable net annual income in today's
|
|
162
|
+
money, or ``None`` when not even zero spending survives the plan's
|
|
163
|
+
outflows. The rest is the manifest side: the retirement age the
|
|
164
|
+
answer assumed, the searched bracket's upper bound, and the basis
|
|
165
|
+
— ``seed``, ``paths``, and ``target_success_rate`` are carried
|
|
166
|
+
only for a Monte Carlo basis.
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
income: Money | None
|
|
170
|
+
age: int
|
|
171
|
+
maximum: Money
|
|
172
|
+
mode: RunMode
|
|
173
|
+
seed: int | None = None
|
|
174
|
+
paths: int | None = None
|
|
175
|
+
target_success_rate: Decimal | None = None
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@dataclass(frozen=True)
|
|
179
|
+
class DrawdownPanelViewModel:
|
|
180
|
+
"""The "How much can I draw down?" card (roadmap 9.25; §4.7).
|
|
181
|
+
|
|
182
|
+
``age_value`` echoes the held answer's actual age, or the plan's
|
|
183
|
+
stated retirement-age decision before any run — blank without a
|
|
184
|
+
plan. ``success_value`` echoes likewise or the default.
|
|
185
|
+
``success_visible`` shows the success-target control only under
|
|
186
|
+
the Monte Carlo mode. ``answer`` is the headline; ``detail`` names
|
|
187
|
+
the age, the searched bracket, and the basis; ``message`` carries
|
|
188
|
+
the no-run or failure copy — blank whenever ``answer`` is
|
|
189
|
+
populated, and vice versa.
|
|
190
|
+
"""
|
|
191
|
+
|
|
192
|
+
heading: str
|
|
193
|
+
age_label: str
|
|
194
|
+
age_value: str
|
|
195
|
+
success_label: str
|
|
196
|
+
success_value: str
|
|
197
|
+
success_visible: bool
|
|
198
|
+
run_label: str
|
|
199
|
+
answer: str
|
|
200
|
+
detail: str
|
|
201
|
+
message: str
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@dataclass(frozen=True)
|
|
205
|
+
class _SolverInputs:
|
|
206
|
+
"""The parsed and derived inputs one search runs on."""
|
|
207
|
+
|
|
208
|
+
age: int
|
|
209
|
+
seed: int | None
|
|
210
|
+
paths: int | None
|
|
211
|
+
target_success_rate: Decimal | None
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def state_with_drawdown(
|
|
215
|
+
state: PlanState, request: DrawdownRequest, *, today: date
|
|
216
|
+
) -> PlanState:
|
|
217
|
+
"""The state after solving for the sustainable income (9.25).
|
|
218
|
+
|
|
219
|
+
Anything unusable — no plan, an unparseable age or Monte Carlo
|
|
220
|
+
input, an engine rejection — folds into ``drawdown_error`` on the
|
|
221
|
+
returned state, never raising at a shell (the
|
|
222
|
+
:mod:`glidepath.app.plan` rule). Before the search runs the whole
|
|
223
|
+
state is recomputed at the same ``today`` through
|
|
224
|
+
:func:`~glidepath.app.plan.replanned_state` — the answer and the
|
|
225
|
+
projection it summarises must share one anchor date — which also
|
|
226
|
+
drops any held Monte Carlo result rather than leaving one against
|
|
227
|
+
re-anchored charts. Same inputs (and seed, under the Monte Carlo
|
|
228
|
+
basis) reproduce the same answer (§4.6).
|
|
229
|
+
"""
|
|
230
|
+
household = state.household
|
|
231
|
+
if household is None:
|
|
232
|
+
return _with_drawdown_error(state, DRAWDOWN_NO_PLAN_MESSAGE)
|
|
233
|
+
try:
|
|
234
|
+
inputs = _solver_inputs(household, state.assumptions, request, today)
|
|
235
|
+
except ValueError as exc:
|
|
236
|
+
return _with_drawdown_error(state, str(exc))
|
|
237
|
+
base = replanned_state(
|
|
238
|
+
state.assumptions,
|
|
239
|
+
household,
|
|
240
|
+
state.scenarios,
|
|
241
|
+
today=today,
|
|
242
|
+
modified=state.modified,
|
|
243
|
+
)
|
|
244
|
+
try:
|
|
245
|
+
config, search = _config_and_search(inputs, request.mode, today)
|
|
246
|
+
total_paths = search_probe_bound(search) * (inputs.paths or 0)
|
|
247
|
+
with path_pool(total_paths) as parallelism:
|
|
248
|
+
income = sustainable_income_at_age(
|
|
249
|
+
household,
|
|
250
|
+
state.assumptions,
|
|
251
|
+
region_for(state.assumptions),
|
|
252
|
+
config,
|
|
253
|
+
age=inputs.age,
|
|
254
|
+
search=search,
|
|
255
|
+
parallelism=parallelism,
|
|
256
|
+
)
|
|
257
|
+
except Exception as exc:
|
|
258
|
+
# Broad by design, exactly as in state_with_monte_carlo: a
|
|
259
|
+
# process-pool failure escaping here would hold the shell's
|
|
260
|
+
# in-flight guard forever, so every failure folds into the
|
|
261
|
+
# state (§4.7).
|
|
262
|
+
return _with_drawdown_error(base, DRAWDOWN_FAILED_PREFIX + str(exc))
|
|
263
|
+
answer = DrawdownAnswer(
|
|
264
|
+
income=income,
|
|
265
|
+
age=inputs.age,
|
|
266
|
+
maximum=DRAWDOWN_SEARCH_MAXIMUM,
|
|
267
|
+
mode=request.mode,
|
|
268
|
+
seed=inputs.seed,
|
|
269
|
+
paths=inputs.paths,
|
|
270
|
+
target_success_rate=inputs.target_success_rate,
|
|
271
|
+
)
|
|
272
|
+
changes: dict[str, Any] = {"drawdown": answer, "drawdown_error": None}
|
|
273
|
+
return replace(base, **changes) if changes else base
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def build_drawdown_panel(state: PlanState, mode: RunMode) -> DrawdownPanelViewModel:
|
|
277
|
+
"""The "How much can I draw down?" card for the charts screen (9.25).
|
|
278
|
+
|
|
279
|
+
A held answer keeps showing under either run mode — its basis is
|
|
280
|
+
named in the detail copy, so switching the mode never mislabels
|
|
281
|
+
it; the mode governs only which controls the next search offers.
|
|
282
|
+
"""
|
|
283
|
+
answer = state.drawdown
|
|
284
|
+
headline = ""
|
|
285
|
+
detail = ""
|
|
286
|
+
message = ""
|
|
287
|
+
if state.drawdown_error is not None:
|
|
288
|
+
message = state.drawdown_error
|
|
289
|
+
elif answer is None:
|
|
290
|
+
message = NO_DRAWDOWN_MESSAGE
|
|
291
|
+
else:
|
|
292
|
+
headline = _headline(answer)
|
|
293
|
+
detail = _detail(answer)
|
|
294
|
+
return DrawdownPanelViewModel(
|
|
295
|
+
heading=DRAWDOWN_HEADING,
|
|
296
|
+
age_label=DRAWDOWN_AGE_LABEL,
|
|
297
|
+
age_value=_age_echo(answer, state.household),
|
|
298
|
+
success_label=DRAWDOWN_SUCCESS_LABEL,
|
|
299
|
+
success_value=_success_echo(answer),
|
|
300
|
+
success_visible=mode is RunMode.MONTE_CARLO,
|
|
301
|
+
run_label=FIND_DRAWDOWN_LABEL,
|
|
302
|
+
answer=headline,
|
|
303
|
+
detail=detail,
|
|
304
|
+
message=message,
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _headline(answer: DrawdownAnswer) -> str:
|
|
309
|
+
"""The card's one-line answer."""
|
|
310
|
+
if answer.income is None:
|
|
311
|
+
return (
|
|
312
|
+
f"No income is sustainable retiring at {answer.age} —"
|
|
313
|
+
" the plan's outflows already exhaust it."
|
|
314
|
+
)
|
|
315
|
+
return f"{DRAWDOWN_ANSWER_PREFIX}{format_money(answer.income)} a year"
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _detail(answer: DrawdownAnswer) -> str:
|
|
319
|
+
"""The assumed age, searched bracket, and basis under the headline."""
|
|
320
|
+
target = (
|
|
321
|
+
f"Retiring at age {answer.age} — the highest net annual income"
|
|
322
|
+
" the plan sustains, in today's money, searched up to"
|
|
323
|
+
f" {format_money(answer.maximum)}."
|
|
324
|
+
)
|
|
325
|
+
basis = DRAWDOWN_DETERMINISTIC_BASIS
|
|
326
|
+
if answer.mode is RunMode.MONTE_CARLO and answer.target_success_rate is not None:
|
|
327
|
+
basis = (
|
|
328
|
+
f"Basis: at least {format_percent(answer.target_success_rate)}"
|
|
329
|
+
f" Monte Carlo success over {answer.paths} paths"
|
|
330
|
+
f" (seed {answer.seed})."
|
|
331
|
+
)
|
|
332
|
+
return f"{target}\n{basis}"
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _age_echo(answer: DrawdownAnswer | None, household: Household | None) -> str:
|
|
336
|
+
"""The held answer's age, the plan's stated decision, or blank."""
|
|
337
|
+
if answer is not None:
|
|
338
|
+
return str(answer.age)
|
|
339
|
+
if household is not None:
|
|
340
|
+
return str(household.persons[0].target_retirement_age.value)
|
|
341
|
+
return ""
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _success_echo(answer: DrawdownAnswer | None) -> str:
|
|
345
|
+
"""The held answer's success target as whole percent, or the default."""
|
|
346
|
+
if answer is None or answer.target_success_rate is None:
|
|
347
|
+
return DEFAULT_DRAWDOWN_SUCCESS_VALUE
|
|
348
|
+
return str(int(answer.target_success_rate * Decimal(100)))
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _solver_inputs(
|
|
352
|
+
household: Household,
|
|
353
|
+
assumptions: AssumptionSet,
|
|
354
|
+
request: DrawdownRequest,
|
|
355
|
+
today: date,
|
|
356
|
+
) -> _SolverInputs:
|
|
357
|
+
"""Parse a card submission into the inputs one search runs on.
|
|
358
|
+
|
|
359
|
+
Raises:
|
|
360
|
+
ValueError: With the user-facing message, on anything unusable
|
|
361
|
+
— a planning horizon the person has already reached, an
|
|
362
|
+
unparseable or out-of-bracket age, an unusable Monte Carlo
|
|
363
|
+
seed, path count, or success target, or a Monte Carlo
|
|
364
|
+
search whose probe bound times paths would exceed the
|
|
365
|
+
search budget.
|
|
366
|
+
"""
|
|
367
|
+
person = household.persons[0]
|
|
368
|
+
minimum_age = age_on(person.date_of_birth.value, today)
|
|
369
|
+
planning_age = int_assumption_value(
|
|
370
|
+
assumptions.get(AssumptionKey.HORIZON_PLANNING_AGE)
|
|
371
|
+
)
|
|
372
|
+
maximum_age = planning_age - 1
|
|
373
|
+
if maximum_age < minimum_age:
|
|
374
|
+
raise ValueError(DRAWDOWN_HORIZON_MESSAGE)
|
|
375
|
+
try:
|
|
376
|
+
age = int(request.age_text.strip(), 10)
|
|
377
|
+
except ValueError:
|
|
378
|
+
raise ValueError(DRAWDOWN_AGE_MESSAGE) from None
|
|
379
|
+
if not minimum_age <= age <= maximum_age:
|
|
380
|
+
raise ValueError(DRAWDOWN_AGE_MESSAGE)
|
|
381
|
+
seed: int | None = None
|
|
382
|
+
paths: int | None = None
|
|
383
|
+
success: Decimal | None = None
|
|
384
|
+
if request.mode is RunMode.MONTE_CARLO:
|
|
385
|
+
try:
|
|
386
|
+
seed = int(request.seed_text.strip(), 10)
|
|
387
|
+
except ValueError:
|
|
388
|
+
raise ValueError(MONTE_CARLO_SEED_MESSAGE) from None
|
|
389
|
+
try:
|
|
390
|
+
paths = int(request.paths_text.strip(), 10)
|
|
391
|
+
except ValueError:
|
|
392
|
+
raise ValueError(MONTE_CARLO_PATHS_MESSAGE) from None
|
|
393
|
+
if not 1 <= paths <= MAX_PATHS:
|
|
394
|
+
raise ValueError(MONTE_CARLO_PATHS_MESSAGE)
|
|
395
|
+
probes = search_probe_bound(
|
|
396
|
+
SustainableIncomeSearch(maximum=DRAWDOWN_SEARCH_MAXIMUM)
|
|
397
|
+
)
|
|
398
|
+
if probes * paths > MAX_DRAWDOWN_PATH_RUNS:
|
|
399
|
+
raise ValueError(DRAWDOWN_BUDGET_MESSAGE)
|
|
400
|
+
success = parsed_percent(request.success_text, DRAWDOWN_SUCCESS_MESSAGE)
|
|
401
|
+
return _SolverInputs(
|
|
402
|
+
age=age,
|
|
403
|
+
seed=seed,
|
|
404
|
+
paths=paths,
|
|
405
|
+
target_success_rate=success,
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _config_and_search(
|
|
410
|
+
inputs: _SolverInputs, mode: RunMode, today: date
|
|
411
|
+
) -> tuple[RunConfig, SustainableIncomeSearch]:
|
|
412
|
+
"""The run config and search one parsed submission denotes.
|
|
413
|
+
|
|
414
|
+
Built inside the transition's exception boundary, so a search the
|
|
415
|
+
core rejects folds into ``drawdown_error`` like any other failure
|
|
416
|
+
(the :mod:`glidepath.app.plan` rule).
|
|
417
|
+
"""
|
|
418
|
+
if mode is RunMode.MONTE_CARLO:
|
|
419
|
+
return (
|
|
420
|
+
RunConfig(today=today, mode=RunMode.MONTE_CARLO, seed=inputs.seed),
|
|
421
|
+
SustainableIncomeSearch(
|
|
422
|
+
maximum=DRAWDOWN_SEARCH_MAXIMUM,
|
|
423
|
+
paths=inputs.paths or 1,
|
|
424
|
+
target_success_rate=inputs.target_success_rate or _ONE,
|
|
425
|
+
),
|
|
426
|
+
)
|
|
427
|
+
return (
|
|
428
|
+
RunConfig(today=today),
|
|
429
|
+
SustainableIncomeSearch(maximum=DRAWDOWN_SEARCH_MAXIMUM),
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _with_drawdown_error(state: PlanState, message: str) -> PlanState:
|
|
434
|
+
"""The state carrying a search failure, any held answer dropped."""
|
|
435
|
+
changes: dict[str, Any] = {"drawdown": None, "drawdown_error": message}
|
|
436
|
+
return replace(state, **changes) if changes else state
|
glidepath/app/example.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""The example plan the facts form opens with (planning §4.9).
|
|
2
|
+
|
|
3
|
+
A first launch shows a filled form and a live projection instead of a
|
|
4
|
+
blank screen, so a new user sees what glidepath produces before typing
|
|
5
|
+
anything. The example is nothing but raw form text: it flows through
|
|
6
|
+
``parse_facts_form`` exactly like a user submission, is labelled as an
|
|
7
|
+
example in the UI copy, and one click clears it (§4.9 — the facts
|
|
8
|
+
principle survives because nothing here is ever silently treated as
|
|
9
|
+
the user's own statement).
|
|
10
|
+
|
|
11
|
+
The persona extends the §4.5 golden scenario: a 35-year-old on a
|
|
12
|
+
£52,000 salary targeting retirement at 62, with a workplace DC pension
|
|
13
|
+
(net pay, employer-matched, growing with earnings), a stocks & shares
|
|
14
|
+
ISA, a mid-career state pension forecast, and a £24,000 net spending
|
|
15
|
+
need in today's money. The numbers are tuned so the plan holds
|
|
16
|
+
together — the deterministic projection meets every period's need and
|
|
17
|
+
a seeded Monte Carlo run succeeds far more often than not, while still
|
|
18
|
+
leaving visibly failing paths on the fan chart — because the launch
|
|
19
|
+
surface is a demonstration, and a first impression of a plan already
|
|
20
|
+
in ruin reads as a broken app rather than an honest warning.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from glidepath.app.forms import FactsFormData
|
|
24
|
+
|
|
25
|
+
_PERSON = {
|
|
26
|
+
"date_of_birth": "1991-06-15",
|
|
27
|
+
"tax_residency": "uk.ruk",
|
|
28
|
+
"employment_income": "52000",
|
|
29
|
+
"target_retirement_age": "62",
|
|
30
|
+
}
|
|
31
|
+
_SPENDING = {"annual_spending_real": "24000"}
|
|
32
|
+
# Example values deliberately avoid every policy-figure literal (the
|
|
33
|
+
# boundary guard greps for those) — e.g. the forecast is below the full
|
|
34
|
+
# new state pension rate, as a real mid-career forecast would be.
|
|
35
|
+
_STATE_PENSION = {"forecast_weekly_amount": "230.25"}
|
|
36
|
+
_WORKPLACE_DC = {
|
|
37
|
+
"kind": "uk.workplace_dc",
|
|
38
|
+
"balance": "48000",
|
|
39
|
+
"employee_contribution": "4200",
|
|
40
|
+
"employer_contribution": "3150",
|
|
41
|
+
"relief_mechanic": "net_pay",
|
|
42
|
+
"escalation": "earnings",
|
|
43
|
+
}
|
|
44
|
+
# The ISA saving is £400/month — deliberately neither the £4,000 LISA
|
|
45
|
+
# allowance nor the £5,000 savings starting-rate limit either side of it.
|
|
46
|
+
_ISA = {
|
|
47
|
+
"kind": "uk.isa",
|
|
48
|
+
"balance": "16500",
|
|
49
|
+
"employee_contribution": "4800",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def example_facts_form_data() -> FactsFormData:
|
|
54
|
+
"""The example plan as raw form text (module docstring).
|
|
55
|
+
|
|
56
|
+
Guaranteed parseable: a test submits it through
|
|
57
|
+
``parse_facts_form`` and projects the result, so the form the app
|
|
58
|
+
opens with can never show an error.
|
|
59
|
+
"""
|
|
60
|
+
return FactsFormData(
|
|
61
|
+
person=dict(_PERSON),
|
|
62
|
+
spending=dict(_SPENDING),
|
|
63
|
+
state_pension=dict(_STATE_PENSION),
|
|
64
|
+
wrappers=(dict(_WORKPLACE_DC), dict(_ISA)),
|
|
65
|
+
db_pensions=(),
|
|
66
|
+
)
|