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,446 @@
|
|
|
1
|
+
"""The "When can I retire?" card: transition and panel (roadmap 9.14).
|
|
2
|
+
|
|
3
|
+
The Phase 9.14 core solver
|
|
4
|
+
(:func:`~glidepath.core.earliest_retirement_age`) surfaced per planning
|
|
5
|
+
§4.7: a card on the charts screen asking for a replacement-rate target
|
|
6
|
+
(default 66% of current employment income, user-adjustable) and
|
|
7
|
+
answering with the earliest retirement age that sustains it, the target
|
|
8
|
+
income, and the basis the answer was computed on. The search runs the
|
|
9
|
+
plan once per candidate age — an explicit user action like the Monte
|
|
10
|
+
Carlo run (roadmap 9.13), far too slow for every keystroke — and its
|
|
11
|
+
basis follows the screen's run mode: the deterministic projection, or
|
|
12
|
+
"at least N% Monte Carlo success" over the panel's seed and path count
|
|
13
|
+
once a Monte Carlo basis is selected. The transition re-anchors the
|
|
14
|
+
whole state through :func:`~glidepath.app.plan.replanned_state`, so a
|
|
15
|
+
held answer can never go stale against a changed plan.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass, replace
|
|
19
|
+
from decimal import Decimal
|
|
20
|
+
from typing import TYPE_CHECKING, Any, Final
|
|
21
|
+
|
|
22
|
+
from glidepath.app.display import format_money, format_percent
|
|
23
|
+
from glidepath.app.montecarlo import (
|
|
24
|
+
MAX_PATHS,
|
|
25
|
+
MONTE_CARLO_PATHS_MESSAGE,
|
|
26
|
+
MONTE_CARLO_SEED_MESSAGE,
|
|
27
|
+
path_pool,
|
|
28
|
+
)
|
|
29
|
+
from glidepath.app.plan import PlanState, region_for, replanned_state
|
|
30
|
+
from glidepath.core import (
|
|
31
|
+
AssumptionKey,
|
|
32
|
+
Money,
|
|
33
|
+
RetirementAgeSearch,
|
|
34
|
+
RunConfig,
|
|
35
|
+
RunMode,
|
|
36
|
+
age_on,
|
|
37
|
+
earliest_retirement_age,
|
|
38
|
+
int_assumption_value,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
if TYPE_CHECKING:
|
|
42
|
+
from datetime import date
|
|
43
|
+
|
|
44
|
+
from glidepath.core import AssumptionSet, Household
|
|
45
|
+
|
|
46
|
+
_ZERO = Money(Decimal(0))
|
|
47
|
+
_HUNDRED = Decimal(100)
|
|
48
|
+
|
|
49
|
+
RETIREMENT_HEADING: Final = "When can I retire?"
|
|
50
|
+
|
|
51
|
+
RETIREMENT_RATE_LABEL: Final = "Replacement rate (%)"
|
|
52
|
+
|
|
53
|
+
DEFAULT_RETIREMENT_RATE_VALUE: Final = "66"
|
|
54
|
+
|
|
55
|
+
RETIREMENT_SUCCESS_LABEL: Final = "Success target (%)"
|
|
56
|
+
|
|
57
|
+
DEFAULT_RETIREMENT_SUCCESS_VALUE: Final = "90"
|
|
58
|
+
|
|
59
|
+
FIND_RETIREMENT_AGE_LABEL: Final = "Find earliest age"
|
|
60
|
+
|
|
61
|
+
NO_RETIREMENT_MESSAGE: Final = (
|
|
62
|
+
"No answer yet — choose a replacement rate, then press Find earliest age."
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
RETIREMENT_NO_PLAN_MESSAGE: Final = (
|
|
66
|
+
"Save facts on the Facts tab before asking when you can retire."
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
RETIREMENT_NO_INCOME_MESSAGE: Final = (
|
|
70
|
+
"Add employment income on the Facts tab — the target income is a share of it."
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
RETIREMENT_RATE_MESSAGE: Final = (
|
|
74
|
+
"The replacement rate needs a whole number between 1 and 100."
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
RETIREMENT_SUCCESS_MESSAGE: Final = (
|
|
78
|
+
"The success target needs a whole number between 1 and 100."
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
RETIREMENT_HORIZON_MESSAGE: Final = (
|
|
82
|
+
"No ages to search — the planning horizon age is not beyond the current age."
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
MAX_RETIREMENT_PATH_RUNS: Final = 20_000
|
|
86
|
+
"""The most Monte Carlo path-projections one search may add up to.
|
|
87
|
+
|
|
88
|
+
The 9.13 per-run path cap bounds one Monte Carlo run; a search
|
|
89
|
+
multiplies its path count by up to a few dozen candidate ages, so it
|
|
90
|
+
carries its own aggregate budget — comparable to the accepted
|
|
91
|
+
worst-case cost of a single maximal Monte Carlo run.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
RETIREMENT_BUDGET_MESSAGE: Final = (
|
|
95
|
+
"Lower the path count — this search would project more than"
|
|
96
|
+
f" {MAX_RETIREMENT_PATH_RUNS:,} Monte Carlo paths across its candidate ages."
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
RETIREMENT_FAILED_PREFIX: Final = "The retirement-age search failed: "
|
|
100
|
+
|
|
101
|
+
RETIREMENT_RUNNING_MESSAGE: Final = "Searching for the earliest retirement age…"
|
|
102
|
+
|
|
103
|
+
RETIREMENT_STALE_MESSAGE: Final = (
|
|
104
|
+
"Retirement-age answer discarded — the plan changed while it ran."
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
RETIREMENT_ANSWER_PREFIX: Final = "Earliest retirement age: "
|
|
108
|
+
|
|
109
|
+
RETIREMENT_DETERMINISTIC_BASIS: Final = (
|
|
110
|
+
"Basis: deterministic projection, no unmet need in any period."
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass(frozen=True)
|
|
115
|
+
class RetirementRequest:
|
|
116
|
+
"""One card submission, as the shell captured it (planning §4.7).
|
|
117
|
+
|
|
118
|
+
``mode`` is the charts screen's run-mode selection — the answer's
|
|
119
|
+
basis. ``seed_text``, ``paths_text``, and ``success_text`` are read
|
|
120
|
+
only under the Monte Carlo mode: the seed and path count come from
|
|
121
|
+
the Monte Carlo panel's own controls, so the bands and the answer
|
|
122
|
+
always describe the same runs.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
mode: RunMode
|
|
126
|
+
rate_text: str
|
|
127
|
+
seed_text: str = ""
|
|
128
|
+
paths_text: str = ""
|
|
129
|
+
success_text: str = ""
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass(frozen=True)
|
|
133
|
+
class RetirementAnswer:
|
|
134
|
+
"""One solved answer with the inputs that produced it (§4.6).
|
|
135
|
+
|
|
136
|
+
``age`` is the earliest retirement age meeting the target, or
|
|
137
|
+
``None`` when no age in the searched bracket does. The rest is the
|
|
138
|
+
manifest side: the rate and the employment income it was taken of,
|
|
139
|
+
the derived target income, the searched bracket, and the basis —
|
|
140
|
+
``seed``, ``paths``, and ``target_success_rate`` are carried only
|
|
141
|
+
for a Monte Carlo basis.
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
age: int | None
|
|
145
|
+
replacement_rate: Decimal
|
|
146
|
+
employment_income: Money
|
|
147
|
+
target_income: Money
|
|
148
|
+
minimum_age: int
|
|
149
|
+
maximum_age: int
|
|
150
|
+
mode: RunMode
|
|
151
|
+
seed: int | None = None
|
|
152
|
+
paths: int | None = None
|
|
153
|
+
target_success_rate: Decimal | None = None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@dataclass(frozen=True)
|
|
157
|
+
class RetirementPanelViewModel:
|
|
158
|
+
"""The "When can I retire?" card (roadmap 9.14; planning §4.7).
|
|
159
|
+
|
|
160
|
+
``rate_value`` and ``success_value`` echo the held answer's actual
|
|
161
|
+
inputs or the defaults before any run. ``success_visible`` shows
|
|
162
|
+
the success-target control only under the Monte Carlo mode.
|
|
163
|
+
``answer`` is the headline; ``detail`` names the target income and
|
|
164
|
+
the basis; ``message`` carries the no-run or failure copy — blank
|
|
165
|
+
whenever ``answer`` is populated, and vice versa.
|
|
166
|
+
"""
|
|
167
|
+
|
|
168
|
+
heading: str
|
|
169
|
+
rate_label: str
|
|
170
|
+
rate_value: str
|
|
171
|
+
success_label: str
|
|
172
|
+
success_value: str
|
|
173
|
+
success_visible: bool
|
|
174
|
+
run_label: str
|
|
175
|
+
answer: str
|
|
176
|
+
detail: str
|
|
177
|
+
message: str
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@dataclass(frozen=True)
|
|
181
|
+
class _SolverInputs:
|
|
182
|
+
"""The parsed and derived inputs one search runs on."""
|
|
183
|
+
|
|
184
|
+
replacement_rate: Decimal
|
|
185
|
+
employment_income: Money
|
|
186
|
+
target_income: Money
|
|
187
|
+
minimum_age: int
|
|
188
|
+
maximum_age: int
|
|
189
|
+
seed: int | None
|
|
190
|
+
paths: int | None
|
|
191
|
+
target_success_rate: Decimal | None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def state_with_retirement(
|
|
195
|
+
state: PlanState, request: RetirementRequest, *, today: date
|
|
196
|
+
) -> PlanState:
|
|
197
|
+
"""The state after solving for the earliest retirement age (9.14).
|
|
198
|
+
|
|
199
|
+
Anything unusable — no plan, no employment income, an unparseable
|
|
200
|
+
rate or Monte Carlo input, an engine rejection — folds into
|
|
201
|
+
``retirement_error`` on the returned state, never raising at a
|
|
202
|
+
shell (the :mod:`glidepath.app.plan` rule). Before the search runs
|
|
203
|
+
the whole state is recomputed at the same ``today`` through
|
|
204
|
+
:func:`~glidepath.app.plan.replanned_state` — the answer and the
|
|
205
|
+
projection it summarises must share one anchor date — which also
|
|
206
|
+
drops any held Monte Carlo result rather than leaving one against
|
|
207
|
+
re-anchored charts. Same inputs (and seed, under the Monte Carlo
|
|
208
|
+
basis) reproduce the same answer (§4.6).
|
|
209
|
+
"""
|
|
210
|
+
household = state.household
|
|
211
|
+
if household is None:
|
|
212
|
+
return _with_retirement_error(state, RETIREMENT_NO_PLAN_MESSAGE)
|
|
213
|
+
try:
|
|
214
|
+
inputs = _solver_inputs(household, state.assumptions, request, today)
|
|
215
|
+
except ValueError as exc:
|
|
216
|
+
return _with_retirement_error(state, str(exc))
|
|
217
|
+
base = replanned_state(
|
|
218
|
+
state.assumptions,
|
|
219
|
+
household,
|
|
220
|
+
state.scenarios,
|
|
221
|
+
today=today,
|
|
222
|
+
modified=state.modified,
|
|
223
|
+
)
|
|
224
|
+
try:
|
|
225
|
+
config, search = _config_and_search(inputs, request.mode, today)
|
|
226
|
+
candidates = inputs.maximum_age - inputs.minimum_age + 1
|
|
227
|
+
total_paths = candidates * (inputs.paths or 0)
|
|
228
|
+
with path_pool(total_paths) as parallelism:
|
|
229
|
+
age = earliest_retirement_age(
|
|
230
|
+
household,
|
|
231
|
+
state.assumptions,
|
|
232
|
+
region_for(state.assumptions),
|
|
233
|
+
config,
|
|
234
|
+
search,
|
|
235
|
+
parallelism=parallelism,
|
|
236
|
+
)
|
|
237
|
+
except Exception as exc:
|
|
238
|
+
# Broad by design, exactly as in state_with_monte_carlo: a
|
|
239
|
+
# process-pool failure escaping here would hold the shell's
|
|
240
|
+
# in-flight guard forever, so every failure folds into the
|
|
241
|
+
# state (§4.7).
|
|
242
|
+
return _with_retirement_error(base, RETIREMENT_FAILED_PREFIX + str(exc))
|
|
243
|
+
answer = RetirementAnswer(
|
|
244
|
+
age=age,
|
|
245
|
+
replacement_rate=inputs.replacement_rate,
|
|
246
|
+
employment_income=inputs.employment_income,
|
|
247
|
+
target_income=inputs.target_income,
|
|
248
|
+
minimum_age=inputs.minimum_age,
|
|
249
|
+
maximum_age=inputs.maximum_age,
|
|
250
|
+
mode=request.mode,
|
|
251
|
+
seed=inputs.seed,
|
|
252
|
+
paths=inputs.paths,
|
|
253
|
+
target_success_rate=inputs.target_success_rate,
|
|
254
|
+
)
|
|
255
|
+
changes: dict[str, Any] = {"retirement": answer, "retirement_error": None}
|
|
256
|
+
return replace(base, **changes) if changes else base
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def build_retirement_panel(state: PlanState, mode: RunMode) -> RetirementPanelViewModel:
|
|
260
|
+
"""The "When can I retire?" card for the charts screen (9.14).
|
|
261
|
+
|
|
262
|
+
A held answer keeps showing under either run mode — its basis is
|
|
263
|
+
named in the detail copy, so switching the mode never mislabels
|
|
264
|
+
it; the mode governs only which controls the next search offers.
|
|
265
|
+
"""
|
|
266
|
+
answer = state.retirement
|
|
267
|
+
headline = ""
|
|
268
|
+
detail = ""
|
|
269
|
+
message = ""
|
|
270
|
+
if state.retirement_error is not None:
|
|
271
|
+
message = state.retirement_error
|
|
272
|
+
elif answer is None:
|
|
273
|
+
message = NO_RETIREMENT_MESSAGE
|
|
274
|
+
else:
|
|
275
|
+
headline = _headline(answer)
|
|
276
|
+
detail = _detail(answer)
|
|
277
|
+
return RetirementPanelViewModel(
|
|
278
|
+
heading=RETIREMENT_HEADING,
|
|
279
|
+
rate_label=RETIREMENT_RATE_LABEL,
|
|
280
|
+
rate_value=_rate_echo(answer),
|
|
281
|
+
success_label=RETIREMENT_SUCCESS_LABEL,
|
|
282
|
+
success_value=_success_echo(answer),
|
|
283
|
+
success_visible=mode is RunMode.MONTE_CARLO,
|
|
284
|
+
run_label=FIND_RETIREMENT_AGE_LABEL,
|
|
285
|
+
answer=headline,
|
|
286
|
+
detail=detail,
|
|
287
|
+
message=message,
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _headline(answer: RetirementAnswer) -> str:
|
|
292
|
+
"""The card's one-line answer."""
|
|
293
|
+
if answer.age is None:
|
|
294
|
+
return (
|
|
295
|
+
f"No age from {answer.minimum_age} to {answer.maximum_age}"
|
|
296
|
+
" meets the target."
|
|
297
|
+
)
|
|
298
|
+
return f"{RETIREMENT_ANSWER_PREFIX}{answer.age}"
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _detail(answer: RetirementAnswer) -> str:
|
|
302
|
+
"""The target income and basis under the headline."""
|
|
303
|
+
target = (
|
|
304
|
+
f"Target income {format_money(answer.target_income)} a year"
|
|
305
|
+
f" — {format_percent(answer.replacement_rate)} of employment income"
|
|
306
|
+
f" {format_money(answer.employment_income)} — searched ages"
|
|
307
|
+
f" {answer.minimum_age} to {answer.maximum_age}."
|
|
308
|
+
)
|
|
309
|
+
basis = RETIREMENT_DETERMINISTIC_BASIS
|
|
310
|
+
if answer.mode is RunMode.MONTE_CARLO and answer.target_success_rate is not None:
|
|
311
|
+
basis = (
|
|
312
|
+
f"Basis: at least {format_percent(answer.target_success_rate)}"
|
|
313
|
+
f" Monte Carlo success over {answer.paths} paths"
|
|
314
|
+
f" (seed {answer.seed})."
|
|
315
|
+
)
|
|
316
|
+
return f"{target}\n{basis}"
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _rate_echo(answer: RetirementAnswer | None) -> str:
|
|
320
|
+
"""The held answer's rate as whole percent, or the default."""
|
|
321
|
+
if answer is None:
|
|
322
|
+
return DEFAULT_RETIREMENT_RATE_VALUE
|
|
323
|
+
return str(int(answer.replacement_rate * _HUNDRED))
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _success_echo(answer: RetirementAnswer | None) -> str:
|
|
327
|
+
"""The held answer's success target as whole percent, or the default."""
|
|
328
|
+
if answer is None or answer.target_success_rate is None:
|
|
329
|
+
return DEFAULT_RETIREMENT_SUCCESS_VALUE
|
|
330
|
+
return str(int(answer.target_success_rate * _HUNDRED))
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def parsed_percent(text: str, message: str) -> Decimal:
|
|
334
|
+
"""A whole-number percentage in [1, 100] as a fraction.
|
|
335
|
+
|
|
336
|
+
Shared with the drawdown card (:mod:`glidepath.app.drawdown`,
|
|
337
|
+
roadmap 9.25), whose success target parses by the same rule.
|
|
338
|
+
|
|
339
|
+
Raises:
|
|
340
|
+
ValueError: With ``message``, if ``text`` is not a whole
|
|
341
|
+
number from 1 to 100.
|
|
342
|
+
"""
|
|
343
|
+
try:
|
|
344
|
+
percent = int(text.strip(), 10)
|
|
345
|
+
except ValueError:
|
|
346
|
+
raise ValueError(message) from None
|
|
347
|
+
if not 1 <= percent <= _HUNDRED:
|
|
348
|
+
raise ValueError(message)
|
|
349
|
+
return Decimal(percent) / _HUNDRED
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _solver_inputs(
|
|
353
|
+
household: Household,
|
|
354
|
+
assumptions: AssumptionSet,
|
|
355
|
+
request: RetirementRequest,
|
|
356
|
+
today: date,
|
|
357
|
+
) -> _SolverInputs:
|
|
358
|
+
"""Parse a card submission into the inputs one search runs on.
|
|
359
|
+
|
|
360
|
+
Raises:
|
|
361
|
+
ValueError: With the user-facing message, on anything unusable
|
|
362
|
+
— no (positive) employment income, a target income that
|
|
363
|
+
quantizes to nothing, an unparseable rate, an empty age
|
|
364
|
+
bracket, an unusable Monte Carlo seed, path count, or
|
|
365
|
+
success target, or a Monte Carlo search whose candidate
|
|
366
|
+
ages times paths would exceed the search budget.
|
|
367
|
+
"""
|
|
368
|
+
person = household.persons[0]
|
|
369
|
+
employment = person.employment_income
|
|
370
|
+
if employment is None or employment.value <= _ZERO:
|
|
371
|
+
raise ValueError(RETIREMENT_NO_INCOME_MESSAGE)
|
|
372
|
+
rate = parsed_percent(request.rate_text, RETIREMENT_RATE_MESSAGE)
|
|
373
|
+
target_income = (employment.value * rate).quantized()
|
|
374
|
+
if target_income <= _ZERO:
|
|
375
|
+
raise ValueError(RETIREMENT_NO_INCOME_MESSAGE)
|
|
376
|
+
minimum_age = age_on(person.date_of_birth.value, today)
|
|
377
|
+
planning_age = int_assumption_value(
|
|
378
|
+
assumptions.get(AssumptionKey.HORIZON_PLANNING_AGE)
|
|
379
|
+
)
|
|
380
|
+
maximum_age = planning_age - 1
|
|
381
|
+
if maximum_age < minimum_age:
|
|
382
|
+
raise ValueError(RETIREMENT_HORIZON_MESSAGE)
|
|
383
|
+
seed: int | None = None
|
|
384
|
+
paths: int | None = None
|
|
385
|
+
success: Decimal | None = None
|
|
386
|
+
if request.mode is RunMode.MONTE_CARLO:
|
|
387
|
+
try:
|
|
388
|
+
seed = int(request.seed_text.strip(), 10)
|
|
389
|
+
except ValueError:
|
|
390
|
+
raise ValueError(MONTE_CARLO_SEED_MESSAGE) from None
|
|
391
|
+
try:
|
|
392
|
+
paths = int(request.paths_text.strip(), 10)
|
|
393
|
+
except ValueError:
|
|
394
|
+
raise ValueError(MONTE_CARLO_PATHS_MESSAGE) from None
|
|
395
|
+
if not 1 <= paths <= MAX_PATHS:
|
|
396
|
+
raise ValueError(MONTE_CARLO_PATHS_MESSAGE)
|
|
397
|
+
candidates = maximum_age - minimum_age + 1
|
|
398
|
+
if candidates * paths > MAX_RETIREMENT_PATH_RUNS:
|
|
399
|
+
raise ValueError(RETIREMENT_BUDGET_MESSAGE)
|
|
400
|
+
success = parsed_percent(request.success_text, RETIREMENT_SUCCESS_MESSAGE)
|
|
401
|
+
return _SolverInputs(
|
|
402
|
+
replacement_rate=rate,
|
|
403
|
+
employment_income=employment.value,
|
|
404
|
+
target_income=target_income,
|
|
405
|
+
minimum_age=minimum_age,
|
|
406
|
+
maximum_age=maximum_age,
|
|
407
|
+
seed=seed,
|
|
408
|
+
paths=paths,
|
|
409
|
+
target_success_rate=success,
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _config_and_search(
|
|
414
|
+
inputs: _SolverInputs, mode: RunMode, today: date
|
|
415
|
+
) -> tuple[RunConfig, RetirementAgeSearch]:
|
|
416
|
+
"""The run config and search one parsed submission denotes.
|
|
417
|
+
|
|
418
|
+
Built inside the transition's exception boundary, so a search the
|
|
419
|
+
core rejects folds into ``retirement_error`` like any other
|
|
420
|
+
failure (the :mod:`glidepath.app.plan` rule).
|
|
421
|
+
"""
|
|
422
|
+
if mode is RunMode.MONTE_CARLO:
|
|
423
|
+
return (
|
|
424
|
+
RunConfig(today=today, mode=RunMode.MONTE_CARLO, seed=inputs.seed),
|
|
425
|
+
RetirementAgeSearch(
|
|
426
|
+
target_income=inputs.target_income,
|
|
427
|
+
minimum_age=inputs.minimum_age,
|
|
428
|
+
maximum_age=inputs.maximum_age,
|
|
429
|
+
paths=inputs.paths or 1,
|
|
430
|
+
target_success_rate=inputs.target_success_rate or Decimal(1),
|
|
431
|
+
),
|
|
432
|
+
)
|
|
433
|
+
return (
|
|
434
|
+
RunConfig(today=today),
|
|
435
|
+
RetirementAgeSearch(
|
|
436
|
+
target_income=inputs.target_income,
|
|
437
|
+
minimum_age=inputs.minimum_age,
|
|
438
|
+
maximum_age=inputs.maximum_age,
|
|
439
|
+
),
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _with_retirement_error(state: PlanState, message: str) -> PlanState:
|
|
444
|
+
"""The state carrying a search failure, any held answer dropped."""
|
|
445
|
+
changes: dict[str, Any] = {"retirement": None, "retirement_error": message}
|
|
446
|
+
return replace(state, **changes) if changes else state
|