glidepath 0.3.0__tar.gz → 0.4.0__tar.gz
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-0.3.0 → glidepath-0.4.0}/PKG-INFO +10 -2
- {glidepath-0.3.0 → glidepath-0.4.0}/README.md +9 -1
- {glidepath-0.3.0 → glidepath-0.4.0}/pyproject.toml +1 -1
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/app/__init__.py +16 -0
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/app/charts.py +48 -32
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/app/display.py +12 -3
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/app/drawdown.py +75 -16
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/app/example.py +7 -3
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/app/exports.py +1 -1
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/app/forms.py +530 -119
- glidepath-0.4.0/src/glidepath/app/labels.py +116 -0
- glidepath-0.4.0/src/glidepath/app/outlook.py +595 -0
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/app/retirement.py +102 -20
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/app/scenarios.py +126 -8
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/core/__init__.py +10 -3
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/core/annuities.py +29 -2
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/core/engine.py +1457 -617
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/core/entities.py +30 -17
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/core/montecarlo.py +60 -6
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/core/pensions.py +28 -11
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/core/provenance.py +2 -0
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/core/results.py +89 -47
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/core/retirement.py +107 -42
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/core/scenarios.py +95 -6
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/core/tax.py +55 -5
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/core/withdrawals.py +40 -23
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/core/wrappers.py +26 -3
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/gui/charts.py +115 -51
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/gui/forms.py +159 -18
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/gui/widgets.py +14 -2
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/persistence/decode.py +38 -1
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/persistence/document.py +19 -1
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/persistence/encode.py +18 -0
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/persistence/migrations.py +102 -1
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/persistence/values.py +12 -0
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/regions/uk/__init__.py +6 -0
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/regions/uk/ages.py +15 -0
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/regions/uk/data/age_rules.toml +12 -3
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/regions/uk/data/assumptions_default.toml +14 -2
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/regions/uk/data/returns_history.toml +1 -1
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/regions/uk/data/tax_year_2026_27.toml +17 -4
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/regions/uk/extension.py +40 -6
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/regions/uk/loader.py +44 -0
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/regions/uk/schema.py +67 -2
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/regions/uk/tax.py +148 -2
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/regions/uk/wrappers.py +12 -0
- glidepath-0.3.0/src/glidepath/app/labels.py +0 -66
- {glidepath-0.3.0 → glidepath-0.4.0}/LICENSE +0 -0
- {glidepath-0.3.0 → glidepath-0.4.0}/LICENSE-DATA +0 -0
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/__init__.py +0 -0
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/app/backtest.py +0 -0
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/app/copy.py +0 -0
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/app/files.py +0 -0
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/app/firstrun.py +0 -0
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/app/inspector.py +0 -0
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/app/montecarlo.py +0 -0
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/app/plan.py +0 -0
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/app/shell.py +0 -0
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/app/tables.py +0 -0
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/core/backtest.py +0 -0
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/core/comparison.py +0 -0
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/core/config.py +0 -0
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/core/contributions.py +0 -0
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/core/glide.py +0 -0
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/core/investments.py +0 -0
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/core/money.py +0 -0
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/core/periods.py +0 -0
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/core/randomness.py +0 -0
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/core/region.py +0 -0
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/core/reporting.py +0 -0
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/core/returns.py +0 -0
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/core/state_pension.py +0 -0
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/gui/__init__.py +0 -0
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/gui/assets/icon_128.png +0 -0
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/gui/assets/icon_16.png +0 -0
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/gui/assets/icon_24.png +0 -0
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/gui/assets/icon_256.png +0 -0
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/gui/assets/icon_32.png +0 -0
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/gui/assets/icon_48.png +0 -0
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/gui/assets/icon_64.png +0 -0
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/gui/assets/wordmark.png +0 -0
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/gui/inspector.py +0 -0
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/gui/main.py +0 -0
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/gui/scenarios.py +0 -0
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/gui/style.py +0 -0
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/gui/tableview.py +0 -0
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/persistence/__init__.py +0 -0
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/persistence/assumptions.py +0 -0
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/py.typed +0 -0
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/regions/__init__.py +0 -0
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/regions/uk/contributions.py +0 -0
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/regions/uk/region.py +0 -0
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/regions/uk/state_pension.py +0 -0
- {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/regions/uk/years.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: glidepath
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Desktop retirement and investment planner (UK-first, region-extensible).
|
|
5
5
|
Keywords: retirement,pension,financial-planning,investment,uk,sipp,isa,monte-carlo
|
|
6
6
|
Author: James Williams
|
|
@@ -59,7 +59,7 @@ nothing is transmitted.
|
|
|
59
59
|
|
|
60
60
|
*(All screenshots show example data, not anyone's real finances.)*
|
|
61
61
|
|
|
62
|
-
What it models today (single
|
|
62
|
+
What it models today (UK, single or couple):
|
|
63
63
|
|
|
64
64
|
- **Wrappers** — workplace DC, SIPP, S&S ISA, LISA, GIA and cash, with
|
|
65
65
|
UK contribution relief mechanics and dividend/savings taxation.
|
|
@@ -69,6 +69,14 @@ What it models today (single person, UK):
|
|
|
69
69
|
including deferral.
|
|
70
70
|
- **Tax** — rUK and Scottish income tax from verified 2026/27 data
|
|
71
71
|
files; pension allowances (AA/taper/MPAA, lump-sum allowance).
|
|
72
|
+
- **Couples** — an optional partner, modelled end to end: one pooled
|
|
73
|
+
household decumulation drawing tax-efficiently across both partners'
|
|
74
|
+
wrappers with each person taxed individually (mixed rUK/Scottish
|
|
75
|
+
residency included), the marriage allowance claimed when eligible,
|
|
76
|
+
optional survivor modelling ("model death at age" — pensions pass as
|
|
77
|
+
beneficiary drawdown, ISAs via the additional permitted
|
|
78
|
+
subscription, DB schemes at their survivor fraction), and joint-life
|
|
79
|
+
annuities paying a 50/66/100% survivor income.
|
|
72
80
|
- **Projection** — deterministic or Monte Carlo runs from the app:
|
|
73
81
|
success rate, probability of ruin, ending-pot percentiles, and a
|
|
74
82
|
probability fan chart on its own tab, reproducible from a seed. With
|
|
@@ -31,7 +31,7 @@ nothing is transmitted.
|
|
|
31
31
|
|
|
32
32
|
*(All screenshots show example data, not anyone's real finances.)*
|
|
33
33
|
|
|
34
|
-
What it models today (single
|
|
34
|
+
What it models today (UK, single or couple):
|
|
35
35
|
|
|
36
36
|
- **Wrappers** — workplace DC, SIPP, S&S ISA, LISA, GIA and cash, with
|
|
37
37
|
UK contribution relief mechanics and dividend/savings taxation.
|
|
@@ -41,6 +41,14 @@ What it models today (single person, UK):
|
|
|
41
41
|
including deferral.
|
|
42
42
|
- **Tax** — rUK and Scottish income tax from verified 2026/27 data
|
|
43
43
|
files; pension allowances (AA/taper/MPAA, lump-sum allowance).
|
|
44
|
+
- **Couples** — an optional partner, modelled end to end: one pooled
|
|
45
|
+
household decumulation drawing tax-efficiently across both partners'
|
|
46
|
+
wrappers with each person taxed individually (mixed rUK/Scottish
|
|
47
|
+
residency included), the marriage allowance claimed when eligible,
|
|
48
|
+
optional survivor modelling ("model death at age" — pensions pass as
|
|
49
|
+
beneficiary drawdown, ISAs via the additional permitted
|
|
50
|
+
subscription, DB schemes at their survivor fraction), and joint-life
|
|
51
|
+
annuities paying a 50/66/100% survivor income.
|
|
44
52
|
- **Projection** — deterministic or Monte Carlo runs from the app:
|
|
45
53
|
success rate, probability of ruin, ending-pot percentiles, and a
|
|
46
54
|
probability fan chart on its own tab, reproducible from a seed. With
|
|
@@ -105,6 +105,7 @@ from glidepath.app.firstrun import (
|
|
|
105
105
|
)
|
|
106
106
|
from glidepath.app.forms import (
|
|
107
107
|
ENTITY_ID_KEY,
|
|
108
|
+
OWNER_KEY,
|
|
108
109
|
ChoiceOption,
|
|
109
110
|
FactsFormData,
|
|
110
111
|
FactsFormResult,
|
|
@@ -112,6 +113,7 @@ from glidepath.app.forms import (
|
|
|
112
113
|
FieldKind,
|
|
113
114
|
FieldSpec,
|
|
114
115
|
FormError,
|
|
116
|
+
PersonFormData,
|
|
115
117
|
PlanEntityIds,
|
|
116
118
|
SectionSpec,
|
|
117
119
|
build_facts_form_view_model,
|
|
@@ -147,6 +149,13 @@ from glidepath.app.montecarlo import (
|
|
|
147
149
|
run_mode_key,
|
|
148
150
|
state_with_monte_carlo,
|
|
149
151
|
)
|
|
152
|
+
from glidepath.app.outlook import (
|
|
153
|
+
NO_OUTLOOK_MESSAGE,
|
|
154
|
+
OUTLOOK_HEADING,
|
|
155
|
+
OUTLOOK_NO_PLAN_MESSAGE,
|
|
156
|
+
OutlookPanelViewModel,
|
|
157
|
+
build_outlook_panel,
|
|
158
|
+
)
|
|
150
159
|
from glidepath.app.plan import (
|
|
151
160
|
OVERRIDE_SOURCE,
|
|
152
161
|
OverrideOutcome,
|
|
@@ -235,9 +244,13 @@ __all__ = [
|
|
|
235
244
|
"NO_BACKTEST_MESSAGE",
|
|
236
245
|
"NO_DRAWDOWN_MESSAGE",
|
|
237
246
|
"NO_MONTE_CARLO_MESSAGE",
|
|
247
|
+
"NO_OUTLOOK_MESSAGE",
|
|
238
248
|
"NO_PROJECTION_MESSAGE",
|
|
239
249
|
"NO_RETIREMENT_MESSAGE",
|
|
250
|
+
"OUTLOOK_HEADING",
|
|
251
|
+
"OUTLOOK_NO_PLAN_MESSAGE",
|
|
240
252
|
"OVERRIDE_SOURCE",
|
|
253
|
+
"OWNER_KEY",
|
|
241
254
|
"REPORT_EXPORT_FAILED_PREFIX",
|
|
242
255
|
"REPORT_NOT_WRITTEN_MESSAGE",
|
|
243
256
|
"RETIREMENT_NO_INCOME_MESSAGE",
|
|
@@ -284,8 +297,10 @@ __all__ = [
|
|
|
284
297
|
"LoadOutcome",
|
|
285
298
|
"MonteCarloMetric",
|
|
286
299
|
"MonteCarloPanelViewModel",
|
|
300
|
+
"OutlookPanelViewModel",
|
|
287
301
|
"OverrideOutcome",
|
|
288
302
|
"OverrideRow",
|
|
303
|
+
"PersonFormData",
|
|
289
304
|
"PlanEntityIds",
|
|
290
305
|
"PlanReport",
|
|
291
306
|
"PlanState",
|
|
@@ -310,6 +325,7 @@ __all__ = [
|
|
|
310
325
|
"build_drawdown_panel",
|
|
311
326
|
"build_facts_form_view_model",
|
|
312
327
|
"build_inspector_view_model",
|
|
328
|
+
"build_outlook_panel",
|
|
313
329
|
"build_plan_report",
|
|
314
330
|
"build_retirement_panel",
|
|
315
331
|
"build_scenarios_view_model",
|
|
@@ -10,7 +10,6 @@ inflation source (planning §5.2). Amounts stay ``Decimal`` here:
|
|
|
10
10
|
converting them to plot coordinates is shell mechanics (§4.7).
|
|
11
11
|
"""
|
|
12
12
|
|
|
13
|
-
from collections import Counter
|
|
14
13
|
from dataclasses import dataclass
|
|
15
14
|
from decimal import Decimal
|
|
16
15
|
from typing import TYPE_CHECKING, Final
|
|
@@ -25,6 +24,7 @@ from glidepath.app.drawdown import (
|
|
|
25
24
|
DrawdownPanelViewModel,
|
|
26
25
|
build_drawdown_panel,
|
|
27
26
|
)
|
|
27
|
+
from glidepath.app.labels import numbered_unique
|
|
28
28
|
from glidepath.app.montecarlo import (
|
|
29
29
|
DEFAULT_RUN_MODE,
|
|
30
30
|
FAN_MEDIAN_LABEL,
|
|
@@ -32,6 +32,10 @@ from glidepath.app.montecarlo import (
|
|
|
32
32
|
MonteCarloPanelViewModel,
|
|
33
33
|
build_monte_carlo_panel,
|
|
34
34
|
)
|
|
35
|
+
from glidepath.app.outlook import (
|
|
36
|
+
OutlookPanelViewModel,
|
|
37
|
+
build_outlook_panel,
|
|
38
|
+
)
|
|
35
39
|
from glidepath.app.retirement import (
|
|
36
40
|
RetirementPanelViewModel,
|
|
37
41
|
build_retirement_panel,
|
|
@@ -54,6 +58,7 @@ if TYPE_CHECKING:
|
|
|
54
58
|
AssetAllocation,
|
|
55
59
|
BacktestResult,
|
|
56
60
|
EntityId,
|
|
61
|
+
Household,
|
|
57
62
|
MonteCarloResult,
|
|
58
63
|
Period,
|
|
59
64
|
PeriodReportRow,
|
|
@@ -208,9 +213,9 @@ class ChartsViewModel:
|
|
|
208
213
|
"""The projection charts screen (roadmap 8.4).
|
|
209
214
|
|
|
210
215
|
``categories`` labels the shared x axis — one label per projected
|
|
211
|
-
period: the period-start year with
|
|
212
|
-
start alongside (roadmap 9.11;
|
|
213
|
-
|
|
216
|
+
period: the period-start year with each person's age at period
|
|
217
|
+
start alongside (roadmap 9.11; both ages in household order for a
|
|
218
|
+
couple, e.g. ``2032 · 60/58`` — roadmap 9.31). ``message``
|
|
214
219
|
carries the empty-state copy when there is nothing to chart; it
|
|
215
220
|
is blank whenever ``charts`` is populated. ``allocation_note``
|
|
216
221
|
states the asset allocation each wrapper actually ran — stated
|
|
@@ -227,6 +232,7 @@ class ChartsViewModel:
|
|
|
227
232
|
message: str
|
|
228
233
|
allocation_note: str
|
|
229
234
|
monte_carlo: MonteCarloPanelViewModel
|
|
235
|
+
outlook: OutlookPanelViewModel
|
|
230
236
|
retirement: RetirementPanelViewModel
|
|
231
237
|
drawdown: DrawdownPanelViewModel
|
|
232
238
|
backtest: BacktestPanelViewModel
|
|
@@ -366,6 +372,7 @@ def build_charts_view_model(
|
|
|
366
372
|
monte_carlo=build_monte_carlo_panel(
|
|
367
373
|
state, mode, ending_pot_deflator=None, basis_suffix=suffix
|
|
368
374
|
),
|
|
375
|
+
outlook=build_outlook_panel(state),
|
|
369
376
|
retirement=build_retirement_panel(state, mode),
|
|
370
377
|
drawdown=build_drawdown_panel(state, mode),
|
|
371
378
|
backtest=build_backtest_panel(
|
|
@@ -379,7 +386,7 @@ def build_charts_view_model(
|
|
|
379
386
|
bands = _chart_bands(state, grouped, backtest_year)
|
|
380
387
|
final_rows = next(reversed(grouped.values()))
|
|
381
388
|
charts = [
|
|
382
|
-
_balances_chart(grouped, suffix, bands),
|
|
389
|
+
_balances_chart(grouped, suffix, bands, state.household),
|
|
383
390
|
_income_chart(grouped, suffix),
|
|
384
391
|
_tax_chart(grouped, suffix),
|
|
385
392
|
]
|
|
@@ -403,6 +410,7 @@ def build_charts_view_model(
|
|
|
403
410
|
ending_pot_deflator=final_rows[0].balance_deflator,
|
|
404
411
|
basis_suffix=suffix,
|
|
405
412
|
),
|
|
413
|
+
outlook=build_outlook_panel(state),
|
|
406
414
|
retirement=build_retirement_panel(state, mode),
|
|
407
415
|
drawdown=build_drawdown_panel(state, mode),
|
|
408
416
|
backtest=build_backtest_panel(
|
|
@@ -493,7 +501,9 @@ def _allocation_note(
|
|
|
493
501
|
household = state.household
|
|
494
502
|
if household is None:
|
|
495
503
|
return ""
|
|
496
|
-
labels = wrapper_display_labels(
|
|
504
|
+
labels = wrapper_display_labels(
|
|
505
|
+
(row for rows in grouped.values() for row in rows), household
|
|
506
|
+
)
|
|
497
507
|
glide = _glide_note(state)
|
|
498
508
|
parts = []
|
|
499
509
|
for person in household.persons:
|
|
@@ -512,17 +522,15 @@ def _allocation_note(
|
|
|
512
522
|
|
|
513
523
|
|
|
514
524
|
def _category_label(period: Period, rows: list[PeriodReportRow]) -> str:
|
|
515
|
-
"""One period's x-axis label: its start year, with the
|
|
525
|
+
"""One period's x-axis label: its start year, with the persons' ages.
|
|
516
526
|
|
|
517
527
|
``2032 · 60`` reads the horizon in ages as well as calendar years
|
|
518
|
-
(roadmap 9.11)
|
|
519
|
-
|
|
520
|
-
couples activation, 9.4).
|
|
528
|
+
(roadmap 9.11); a two-person period labels both ages in household
|
|
529
|
+
order — ``2032 · 60/58`` (roadmap 9.31).
|
|
521
530
|
"""
|
|
522
531
|
year = str(period.start.year)
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
return year
|
|
532
|
+
ages = "/".join(str(row.age_at_period_start) for row in rows)
|
|
533
|
+
return f"{year} · {ages}" if ages else year
|
|
526
534
|
|
|
527
535
|
|
|
528
536
|
def _rows_by_period(
|
|
@@ -573,29 +581,34 @@ def _chart(
|
|
|
573
581
|
)
|
|
574
582
|
|
|
575
583
|
|
|
576
|
-
def wrapper_display_labels(
|
|
584
|
+
def wrapper_display_labels(
|
|
585
|
+
rows: Iterable[PeriodReportRow], household: Household | None = None
|
|
586
|
+
) -> dict[EntityId, str]:
|
|
577
587
|
"""A display label per wrapper, in first-seen order.
|
|
578
588
|
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
589
|
+
A wrapper the user named shows its own label; the rest read their
|
|
590
|
+
kind name (entity ids are generated UUIDs, so they are never shown
|
|
591
|
+
as copy). Any name repeated across the final set — several unnamed
|
|
592
|
+
wrappers of one kind, a label colliding with a kind name, or one
|
|
593
|
+
label given twice in a hand-edited plan file — is numbered in
|
|
594
|
+
first-seen order (:func:`~glidepath.app.labels.numbered_unique`).
|
|
595
|
+
Shared with the cash-flow export (9.19), which columns its
|
|
596
|
+
balances the same way the balances chart stacks them.
|
|
584
597
|
"""
|
|
585
|
-
|
|
598
|
+
named: dict[EntityId, str] = {}
|
|
599
|
+
if household is not None:
|
|
600
|
+
named = {
|
|
601
|
+
wrapper.id: wrapper.label
|
|
602
|
+
for person in household.persons
|
|
603
|
+
for wrapper in person.wrappers
|
|
604
|
+
if wrapper.label is not None
|
|
605
|
+
}
|
|
606
|
+
bases: dict[EntityId, str] = {}
|
|
586
607
|
for row in rows:
|
|
587
608
|
for entry in row.wrapper_balances:
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
labels: dict[EntityId, str] = {}
|
|
592
|
-
for wrapper_id, kind in kinds.items():
|
|
593
|
-
if counts[kind] == 1:
|
|
594
|
-
labels[wrapper_id] = kind
|
|
595
|
-
else:
|
|
596
|
-
numbered[kind] += 1
|
|
597
|
-
labels[wrapper_id] = f"{kind} {numbered[kind]}"
|
|
598
|
-
return labels
|
|
609
|
+
base = named.get(entry.wrapper_id) or format_wrapper_kind(entry.kind)
|
|
610
|
+
bases.setdefault(entry.wrapper_id, base)
|
|
611
|
+
return numbered_unique(bases)
|
|
599
612
|
|
|
600
613
|
|
|
601
614
|
def _deflated(
|
|
@@ -716,10 +729,13 @@ def _balances_chart(
|
|
|
716
729
|
grouped: dict[Period, list[PeriodReportRow]],
|
|
717
730
|
suffix: str,
|
|
718
731
|
bands: tuple[ChartBand, ...],
|
|
732
|
+
household: Household | None,
|
|
719
733
|
) -> ChartSpec:
|
|
720
734
|
"""Closing balance per wrapper per period, stacked to the total."""
|
|
721
735
|
series = []
|
|
722
|
-
labels = wrapper_display_labels(
|
|
736
|
+
labels = wrapper_display_labels(
|
|
737
|
+
(row for rows in grouped.values() for row in rows), household
|
|
738
|
+
)
|
|
723
739
|
for wrapper_id, label in labels.items():
|
|
724
740
|
values = []
|
|
725
741
|
for rows in grouped.values():
|
|
@@ -70,6 +70,8 @@ ASSUMPTION_NAMES: Final[Mapping[str, str]] = {
|
|
|
70
70
|
"Annuity rate (inflation-linked, single life, age 65)"
|
|
71
71
|
),
|
|
72
72
|
"annuity.age_adjustment": "Annuity rate age adjustment",
|
|
73
|
+
"db.survivor_fraction": "DB survivor pension fraction",
|
|
74
|
+
"spending.survivor_multiplier": "Survivor spending multiplier",
|
|
73
75
|
}
|
|
74
76
|
"""Human display names for the shipped assumption keys (roadmap 8.3).
|
|
75
77
|
|
|
@@ -127,6 +129,13 @@ def format_recorded(moment: datetime) -> str:
|
|
|
127
129
|
return moment.date().isoformat()
|
|
128
130
|
|
|
129
131
|
|
|
132
|
+
def _format_temporal(value: date) -> str:
|
|
133
|
+
"""A datetime (a recorded-on moment, and a date subclass) or a date."""
|
|
134
|
+
if isinstance(value, datetime):
|
|
135
|
+
return format_recorded(value)
|
|
136
|
+
return format_date(value)
|
|
137
|
+
|
|
138
|
+
|
|
130
139
|
def format_value(value: object) -> str:
|
|
131
140
|
"""Any fact, decision, or assumption value as display text.
|
|
132
141
|
|
|
@@ -134,12 +143,12 @@ def format_value(value: object) -> str:
|
|
|
134
143
|
numbers, enum choices, policy strings, and structured tables
|
|
135
144
|
(rendered as compact ``key=value`` pairs, truncated when long).
|
|
136
145
|
"""
|
|
146
|
+
if isinstance(value, bool): # before any numeric type: bool is an int
|
|
147
|
+
return "Yes" if value else "No"
|
|
137
148
|
if isinstance(value, Money):
|
|
138
149
|
return format_money(value)
|
|
139
|
-
if isinstance(value, datetime): # before date: datetime is a date subclass
|
|
140
|
-
return format_recorded(value)
|
|
141
150
|
if isinstance(value, date):
|
|
142
|
-
return
|
|
151
|
+
return _format_temporal(value)
|
|
143
152
|
if isinstance(value, Enum):
|
|
144
153
|
generic = str(value.name).replace("_", " ").capitalize()
|
|
145
154
|
return _ENUM_LABELS.get(value, generic)
|
|
@@ -22,6 +22,7 @@ from decimal import Decimal
|
|
|
22
22
|
from typing import TYPE_CHECKING, Any, Final
|
|
23
23
|
|
|
24
24
|
from glidepath.app.display import format_money, format_percent
|
|
25
|
+
from glidepath.app.labels import PERSON_NAMES
|
|
25
26
|
from glidepath.app.montecarlo import (
|
|
26
27
|
MAX_PATHS,
|
|
27
28
|
MONTE_CARLO_PATHS_MESSAGE,
|
|
@@ -29,7 +30,7 @@ from glidepath.app.montecarlo import (
|
|
|
29
30
|
path_pool,
|
|
30
31
|
)
|
|
31
32
|
from glidepath.app.plan import PlanState, region_for, replanned_state
|
|
32
|
-
from glidepath.app.retirement import parsed_percent
|
|
33
|
+
from glidepath.app.retirement import parsed_percent, parsed_person_position
|
|
33
34
|
from glidepath.core import (
|
|
34
35
|
AssumptionKey,
|
|
35
36
|
Money,
|
|
@@ -44,7 +45,7 @@ from glidepath.core import (
|
|
|
44
45
|
if TYPE_CHECKING:
|
|
45
46
|
from datetime import date
|
|
46
47
|
|
|
47
|
-
from glidepath.core import AssumptionSet, Household
|
|
48
|
+
from glidepath.core import AssumptionSet, EntityId, Household
|
|
48
49
|
|
|
49
50
|
_ONE = Decimal(1)
|
|
50
51
|
_TWO = Decimal(2)
|
|
@@ -53,6 +54,10 @@ DRAWDOWN_HEADING: Final = "How much can I draw down?"
|
|
|
53
54
|
|
|
54
55
|
DRAWDOWN_AGE_LABEL: Final = "Retirement age"
|
|
55
56
|
|
|
57
|
+
DRAWDOWN_PERSON_LABEL: Final = "Whose retirement age"
|
|
58
|
+
|
|
59
|
+
DRAWDOWN_PERSON_MESSAGE: Final = "Pick whose retirement age to test."
|
|
60
|
+
|
|
56
61
|
DRAWDOWN_SUCCESS_LABEL: Final = "Success target (%)"
|
|
57
62
|
|
|
58
63
|
DEFAULT_DRAWDOWN_SUCCESS_VALUE: Final = "90"
|
|
@@ -144,7 +149,10 @@ class DrawdownRequest:
|
|
|
144
149
|
basis. ``seed_text``, ``paths_text``, and ``success_text`` are read
|
|
145
150
|
only under the Monte Carlo mode: the seed and path count come from
|
|
146
151
|
the Monte Carlo panel's own controls, so the bands and the answer
|
|
147
|
-
always describe the same runs.
|
|
152
|
+
always describe the same runs. ``person_text`` is the whose-age
|
|
153
|
+
selection ("0" or "1"; blank means the first person) — a couple's
|
|
154
|
+
probe moves one person's retirement age with the partner's held
|
|
155
|
+
fixed (planning §4.11).
|
|
148
156
|
"""
|
|
149
157
|
|
|
150
158
|
mode: RunMode
|
|
@@ -152,6 +160,7 @@ class DrawdownRequest:
|
|
|
152
160
|
seed_text: str = ""
|
|
153
161
|
paths_text: str = ""
|
|
154
162
|
success_text: str = ""
|
|
163
|
+
person_text: str = ""
|
|
155
164
|
|
|
156
165
|
|
|
157
166
|
@dataclass(frozen=True)
|
|
@@ -161,9 +170,10 @@ class DrawdownAnswer:
|
|
|
161
170
|
``income`` is the highest sustainable net annual income in today's
|
|
162
171
|
money, or ``None`` when not even zero spending survives the plan's
|
|
163
172
|
outflows. The rest is the manifest side: the retirement age the
|
|
164
|
-
answer assumed
|
|
165
|
-
|
|
166
|
-
|
|
173
|
+
answer assumed and whose it was (``person_position``, the
|
|
174
|
+
household position), the searched bracket's upper bound, and the
|
|
175
|
+
basis — ``seed``, ``paths``, and ``target_success_rate`` are
|
|
176
|
+
carried only for a Monte Carlo basis.
|
|
167
177
|
"""
|
|
168
178
|
|
|
169
179
|
income: Money | None
|
|
@@ -173,6 +183,7 @@ class DrawdownAnswer:
|
|
|
173
183
|
seed: int | None = None
|
|
174
184
|
paths: int | None = None
|
|
175
185
|
target_success_rate: Decimal | None = None
|
|
186
|
+
person_position: int = 0
|
|
176
187
|
|
|
177
188
|
|
|
178
189
|
@dataclass(frozen=True)
|
|
@@ -183,10 +194,18 @@ class DrawdownPanelViewModel:
|
|
|
183
194
|
stated retirement-age decision before any run — blank without a
|
|
184
195
|
plan. ``success_value`` echoes likewise or the default.
|
|
185
196
|
``success_visible`` shows the success-target control only under
|
|
186
|
-
the Monte Carlo mode. ``
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
197
|
+
the Monte Carlo mode. ``person_options`` name the persons a probe
|
|
198
|
+
can move (household order); ``person_visible`` shows that selector
|
|
199
|
+
only for a couple, and ``person_value`` echoes the held answer's
|
|
200
|
+
selection as its option position. ``person_age_defaults`` carry
|
|
201
|
+
each person's stated retirement-age decision (household order): a
|
|
202
|
+
shell writes the newly selected person's default into the age
|
|
203
|
+
field when the selector changes, so switching to the partner
|
|
204
|
+
tests *their* stated age rather than the first person's (§4.11).
|
|
205
|
+
``answer`` is the headline; ``detail`` names the age, the
|
|
206
|
+
searched bracket, and the basis; ``message`` carries the no-run
|
|
207
|
+
or failure copy — blank whenever ``answer`` is populated, and
|
|
208
|
+
vice versa.
|
|
190
209
|
"""
|
|
191
210
|
|
|
192
211
|
heading: str
|
|
@@ -195,6 +214,11 @@ class DrawdownPanelViewModel:
|
|
|
195
214
|
success_label: str
|
|
196
215
|
success_value: str
|
|
197
216
|
success_visible: bool
|
|
217
|
+
person_label: str
|
|
218
|
+
person_options: tuple[str, ...]
|
|
219
|
+
person_value: str
|
|
220
|
+
person_visible: bool
|
|
221
|
+
person_age_defaults: tuple[str, ...]
|
|
198
222
|
run_label: str
|
|
199
223
|
answer: str
|
|
200
224
|
detail: str
|
|
@@ -209,6 +233,8 @@ class _SolverInputs:
|
|
|
209
233
|
seed: int | None
|
|
210
234
|
paths: int | None
|
|
211
235
|
target_success_rate: Decimal | None
|
|
236
|
+
person_id: EntityId
|
|
237
|
+
person_position: int
|
|
212
238
|
|
|
213
239
|
|
|
214
240
|
def state_with_drawdown(
|
|
@@ -252,6 +278,7 @@ def state_with_drawdown(
|
|
|
252
278
|
config,
|
|
253
279
|
age=inputs.age,
|
|
254
280
|
search=search,
|
|
281
|
+
person_id=inputs.person_id,
|
|
255
282
|
parallelism=parallelism,
|
|
256
283
|
)
|
|
257
284
|
except Exception as exc: # noqa: BLE001
|
|
@@ -268,6 +295,7 @@ def state_with_drawdown(
|
|
|
268
295
|
seed=inputs.seed,
|
|
269
296
|
paths=inputs.paths,
|
|
270
297
|
target_success_rate=inputs.target_success_rate,
|
|
298
|
+
person_position=inputs.person_position,
|
|
271
299
|
)
|
|
272
300
|
changes: dict[str, Any] = {"drawdown": answer, "drawdown_error": None}
|
|
273
301
|
return replace(base, **changes) if changes else base
|
|
@@ -291,6 +319,15 @@ def build_drawdown_panel(state: PlanState, mode: RunMode) -> DrawdownPanelViewMo
|
|
|
291
319
|
else:
|
|
292
320
|
headline = _headline(answer)
|
|
293
321
|
detail = _detail(answer)
|
|
322
|
+
person_count = 1 if state.household is None else len(state.household.persons)
|
|
323
|
+
age_defaults = (
|
|
324
|
+
()
|
|
325
|
+
if state.household is None
|
|
326
|
+
else tuple(
|
|
327
|
+
str(person.target_retirement_age.value)
|
|
328
|
+
for person in state.household.persons
|
|
329
|
+
)
|
|
330
|
+
)
|
|
294
331
|
return DrawdownPanelViewModel(
|
|
295
332
|
heading=DRAWDOWN_HEADING,
|
|
296
333
|
age_label=DRAWDOWN_AGE_LABEL,
|
|
@@ -298,6 +335,11 @@ def build_drawdown_panel(state: PlanState, mode: RunMode) -> DrawdownPanelViewMo
|
|
|
298
335
|
success_label=DRAWDOWN_SUCCESS_LABEL,
|
|
299
336
|
success_value=_success_echo(answer),
|
|
300
337
|
success_visible=mode is RunMode.MONTE_CARLO,
|
|
338
|
+
person_label=DRAWDOWN_PERSON_LABEL,
|
|
339
|
+
person_options=PERSON_NAMES[:person_count],
|
|
340
|
+
person_value=_person_echo(answer),
|
|
341
|
+
person_visible=person_count > 1,
|
|
342
|
+
person_age_defaults=age_defaults,
|
|
301
343
|
run_label=FIND_DRAWDOWN_LABEL,
|
|
302
344
|
answer=headline,
|
|
303
345
|
detail=detail,
|
|
@@ -317,8 +359,13 @@ def _headline(answer: DrawdownAnswer) -> str:
|
|
|
317
359
|
|
|
318
360
|
def _detail(answer: DrawdownAnswer) -> str:
|
|
319
361
|
"""The assumed age, searched bracket, and basis under the headline."""
|
|
362
|
+
retiring = (
|
|
363
|
+
f"Retiring at age {answer.age}"
|
|
364
|
+
if answer.person_position == 0
|
|
365
|
+
else f"Your partner retiring at age {answer.age}"
|
|
366
|
+
)
|
|
320
367
|
target = (
|
|
321
|
-
f"
|
|
368
|
+
f"{retiring} — the highest net annual income"
|
|
322
369
|
" the plan sustains, in today's money, searched up to"
|
|
323
370
|
f" {format_money(answer.maximum)}."
|
|
324
371
|
)
|
|
@@ -348,6 +395,13 @@ def _success_echo(answer: DrawdownAnswer | None) -> str:
|
|
|
348
395
|
return str(int(answer.target_success_rate * Decimal(100)))
|
|
349
396
|
|
|
350
397
|
|
|
398
|
+
def _person_echo(answer: DrawdownAnswer | None) -> str:
|
|
399
|
+
"""The held answer's tested person, or the first person."""
|
|
400
|
+
if answer is None:
|
|
401
|
+
return "0"
|
|
402
|
+
return str(answer.person_position)
|
|
403
|
+
|
|
404
|
+
|
|
351
405
|
def _solver_inputs(
|
|
352
406
|
household: Household,
|
|
353
407
|
assumptions: AssumptionSet,
|
|
@@ -359,12 +413,15 @@ def _solver_inputs(
|
|
|
359
413
|
Raises:
|
|
360
414
|
ValueError: With the user-facing message, on anything unusable
|
|
361
415
|
— a planning horizon the person has already reached, an
|
|
362
|
-
unparseable or out-of-bracket age
|
|
363
|
-
seed, path count, or success target,
|
|
364
|
-
search whose probe bound times paths
|
|
365
|
-
search budget.
|
|
416
|
+
unparseable or out-of-bracket age or person selection, an
|
|
417
|
+
unusable Monte Carlo seed, path count, or success target,
|
|
418
|
+
or a Monte Carlo search whose probe bound times paths
|
|
419
|
+
would exceed the search budget.
|
|
366
420
|
"""
|
|
367
|
-
|
|
421
|
+
position = parsed_person_position(
|
|
422
|
+
request.person_text, len(household.persons), DRAWDOWN_PERSON_MESSAGE
|
|
423
|
+
)
|
|
424
|
+
person = household.persons[position]
|
|
368
425
|
minimum_age = age_on(person.date_of_birth.value, today)
|
|
369
426
|
planning_age = int_assumption_value(
|
|
370
427
|
assumptions.get(AssumptionKey.HORIZON_PLANNING_AGE)
|
|
@@ -403,6 +460,8 @@ def _solver_inputs(
|
|
|
403
460
|
seed=seed,
|
|
404
461
|
paths=paths,
|
|
405
462
|
target_success_rate=success,
|
|
463
|
+
person_id=person.id,
|
|
464
|
+
person_position=position,
|
|
406
465
|
)
|
|
407
466
|
|
|
408
467
|
|
|
@@ -20,7 +20,7 @@ surface is a demonstration, and a first impression of a plan already
|
|
|
20
20
|
in ruin reads as a broken app rather than an honest warning.
|
|
21
21
|
"""
|
|
22
22
|
|
|
23
|
-
from glidepath.app.forms import FactsFormData
|
|
23
|
+
from glidepath.app.forms import FactsFormData, PersonFormData
|
|
24
24
|
|
|
25
25
|
_PERSON = {
|
|
26
26
|
"date_of_birth": "1991-06-15",
|
|
@@ -58,9 +58,13 @@ def example_facts_form_data() -> FactsFormData:
|
|
|
58
58
|
opens with can never show an error.
|
|
59
59
|
"""
|
|
60
60
|
return FactsFormData(
|
|
61
|
-
|
|
61
|
+
persons=(
|
|
62
|
+
PersonFormData(
|
|
63
|
+
person=dict(_PERSON),
|
|
64
|
+
state_pension=dict(_STATE_PENSION),
|
|
65
|
+
),
|
|
66
|
+
),
|
|
62
67
|
spending=dict(_SPENDING),
|
|
63
|
-
state_pension=dict(_STATE_PENSION),
|
|
64
68
|
wrappers=(dict(_WORKPLACE_DC), dict(_ISA)),
|
|
65
69
|
db_pensions=(),
|
|
66
70
|
)
|
|
@@ -211,7 +211,7 @@ def cash_flow_csv(
|
|
|
211
211
|
return None
|
|
212
212
|
report = build_report(state.result, basis)
|
|
213
213
|
names = entity_names(state.household)
|
|
214
|
-
wrappers = wrapper_display_labels(report.rows)
|
|
214
|
+
wrappers = wrapper_display_labels(report.rows, state.household)
|
|
215
215
|
buffer = StringIO()
|
|
216
216
|
writer = csv.writer(buffer)
|
|
217
217
|
writer.writerow(("Plan", plan_name))
|