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.
Files changed (94) hide show
  1. {glidepath-0.3.0 → glidepath-0.4.0}/PKG-INFO +10 -2
  2. {glidepath-0.3.0 → glidepath-0.4.0}/README.md +9 -1
  3. {glidepath-0.3.0 → glidepath-0.4.0}/pyproject.toml +1 -1
  4. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/app/__init__.py +16 -0
  5. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/app/charts.py +48 -32
  6. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/app/display.py +12 -3
  7. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/app/drawdown.py +75 -16
  8. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/app/example.py +7 -3
  9. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/app/exports.py +1 -1
  10. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/app/forms.py +530 -119
  11. glidepath-0.4.0/src/glidepath/app/labels.py +116 -0
  12. glidepath-0.4.0/src/glidepath/app/outlook.py +595 -0
  13. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/app/retirement.py +102 -20
  14. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/app/scenarios.py +126 -8
  15. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/core/__init__.py +10 -3
  16. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/core/annuities.py +29 -2
  17. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/core/engine.py +1457 -617
  18. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/core/entities.py +30 -17
  19. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/core/montecarlo.py +60 -6
  20. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/core/pensions.py +28 -11
  21. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/core/provenance.py +2 -0
  22. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/core/results.py +89 -47
  23. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/core/retirement.py +107 -42
  24. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/core/scenarios.py +95 -6
  25. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/core/tax.py +55 -5
  26. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/core/withdrawals.py +40 -23
  27. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/core/wrappers.py +26 -3
  28. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/gui/charts.py +115 -51
  29. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/gui/forms.py +159 -18
  30. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/gui/widgets.py +14 -2
  31. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/persistence/decode.py +38 -1
  32. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/persistence/document.py +19 -1
  33. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/persistence/encode.py +18 -0
  34. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/persistence/migrations.py +102 -1
  35. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/persistence/values.py +12 -0
  36. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/regions/uk/__init__.py +6 -0
  37. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/regions/uk/ages.py +15 -0
  38. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/regions/uk/data/age_rules.toml +12 -3
  39. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/regions/uk/data/assumptions_default.toml +14 -2
  40. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/regions/uk/data/returns_history.toml +1 -1
  41. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/regions/uk/data/tax_year_2026_27.toml +17 -4
  42. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/regions/uk/extension.py +40 -6
  43. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/regions/uk/loader.py +44 -0
  44. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/regions/uk/schema.py +67 -2
  45. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/regions/uk/tax.py +148 -2
  46. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/regions/uk/wrappers.py +12 -0
  47. glidepath-0.3.0/src/glidepath/app/labels.py +0 -66
  48. {glidepath-0.3.0 → glidepath-0.4.0}/LICENSE +0 -0
  49. {glidepath-0.3.0 → glidepath-0.4.0}/LICENSE-DATA +0 -0
  50. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/__init__.py +0 -0
  51. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/app/backtest.py +0 -0
  52. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/app/copy.py +0 -0
  53. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/app/files.py +0 -0
  54. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/app/firstrun.py +0 -0
  55. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/app/inspector.py +0 -0
  56. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/app/montecarlo.py +0 -0
  57. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/app/plan.py +0 -0
  58. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/app/shell.py +0 -0
  59. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/app/tables.py +0 -0
  60. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/core/backtest.py +0 -0
  61. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/core/comparison.py +0 -0
  62. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/core/config.py +0 -0
  63. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/core/contributions.py +0 -0
  64. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/core/glide.py +0 -0
  65. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/core/investments.py +0 -0
  66. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/core/money.py +0 -0
  67. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/core/periods.py +0 -0
  68. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/core/randomness.py +0 -0
  69. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/core/region.py +0 -0
  70. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/core/reporting.py +0 -0
  71. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/core/returns.py +0 -0
  72. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/core/state_pension.py +0 -0
  73. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/gui/__init__.py +0 -0
  74. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/gui/assets/icon_128.png +0 -0
  75. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/gui/assets/icon_16.png +0 -0
  76. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/gui/assets/icon_24.png +0 -0
  77. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/gui/assets/icon_256.png +0 -0
  78. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/gui/assets/icon_32.png +0 -0
  79. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/gui/assets/icon_48.png +0 -0
  80. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/gui/assets/icon_64.png +0 -0
  81. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/gui/assets/wordmark.png +0 -0
  82. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/gui/inspector.py +0 -0
  83. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/gui/main.py +0 -0
  84. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/gui/scenarios.py +0 -0
  85. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/gui/style.py +0 -0
  86. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/gui/tableview.py +0 -0
  87. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/persistence/__init__.py +0 -0
  88. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/persistence/assumptions.py +0 -0
  89. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/py.typed +0 -0
  90. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/regions/__init__.py +0 -0
  91. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/regions/uk/contributions.py +0 -0
  92. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/regions/uk/region.py +0 -0
  93. {glidepath-0.3.0 → glidepath-0.4.0}/src/glidepath/regions/uk/state_pension.py +0 -0
  94. {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.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 person, UK):
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 person, UK):
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "glidepath"
3
- version = "0.3.0"
3
+ version = "0.4.0"
4
4
  description = "Desktop retirement and investment planner (UK-first, region-extensible)."
5
5
  readme = "README.md"
6
6
  # MIT code plus one CC BY-NC-SA 4.0 data file (returns_history.toml —
@@ -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 the person's age at period
212
- start alongside (roadmap 9.11; year alone until couples activate,
213
- 9.4 — a two-person period has no single age to show). ``message``
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(row for rows in grouped.values() for row in rows)
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 person's age.
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). Only a single-person period carries an age — a
519
- two-person household has no one age to label with (revisit with
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
- if len(rows) == 1:
524
- return f"{year} · {rows[0].age_at_period_start}"
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(rows: Iterable[PeriodReportRow]) -> dict[EntityId, str]:
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
- The wrapper's kind name alone when unique; numbered in first-seen
580
- order when the household holds several of one kind (entity ids are
581
- generated UUIDs, so they are never shown as copy). Shared with the
582
- cash-flow export (9.19), which columns its balances the same way
583
- the balances chart stacks them.
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
- kinds: dict[EntityId, str] = {}
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
- kinds.setdefault(entry.wrapper_id, format_wrapper_kind(entry.kind))
589
- counts = Counter(kinds.values())
590
- numbered: Counter[str] = Counter()
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(row for rows in grouped.values() for row in rows)
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 format_date(value)
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, 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.
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. ``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.
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"Retiring at age {answer.age} — the highest net annual income"
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, 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.
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
- person = household.persons[0]
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
- person=dict(_PERSON),
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))