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.
Files changed (93) hide show
  1. glidepath/__init__.py +3 -0
  2. glidepath/app/__init__.py +364 -0
  3. glidepath/app/backtest.py +281 -0
  4. glidepath/app/charts.py +759 -0
  5. glidepath/app/copy.py +174 -0
  6. glidepath/app/display.py +148 -0
  7. glidepath/app/drawdown.py +436 -0
  8. glidepath/app/example.py +66 -0
  9. glidepath/app/exports.py +487 -0
  10. glidepath/app/files.py +249 -0
  11. glidepath/app/firstrun.py +114 -0
  12. glidepath/app/forms.py +1750 -0
  13. glidepath/app/inspector.py +506 -0
  14. glidepath/app/labels.py +66 -0
  15. glidepath/app/montecarlo.py +399 -0
  16. glidepath/app/plan.py +354 -0
  17. glidepath/app/retirement.py +446 -0
  18. glidepath/app/scenarios.py +831 -0
  19. glidepath/app/shell.py +185 -0
  20. glidepath/app/tables.py +138 -0
  21. glidepath/core/__init__.py +390 -0
  22. glidepath/core/annuities.py +240 -0
  23. glidepath/core/backtest.py +514 -0
  24. glidepath/core/comparison.py +278 -0
  25. glidepath/core/config.py +82 -0
  26. glidepath/core/contributions.py +337 -0
  27. glidepath/core/engine.py +2811 -0
  28. glidepath/core/entities.py +264 -0
  29. glidepath/core/glide.py +289 -0
  30. glidepath/core/investments.py +175 -0
  31. glidepath/core/money.py +107 -0
  32. glidepath/core/montecarlo.py +609 -0
  33. glidepath/core/pensions.py +298 -0
  34. glidepath/core/periods.py +367 -0
  35. glidepath/core/provenance.py +271 -0
  36. glidepath/core/randomness.py +128 -0
  37. glidepath/core/region.py +46 -0
  38. glidepath/core/reporting.py +231 -0
  39. glidepath/core/results.py +504 -0
  40. glidepath/core/retirement.py +291 -0
  41. glidepath/core/returns.py +312 -0
  42. glidepath/core/scenarios.py +579 -0
  43. glidepath/core/state_pension.py +264 -0
  44. glidepath/core/tax.py +139 -0
  45. glidepath/core/withdrawals.py +461 -0
  46. glidepath/core/wrappers.py +278 -0
  47. glidepath/gui/__init__.py +6 -0
  48. glidepath/gui/assets/icon_128.png +0 -0
  49. glidepath/gui/assets/icon_16.png +0 -0
  50. glidepath/gui/assets/icon_24.png +0 -0
  51. glidepath/gui/assets/icon_256.png +0 -0
  52. glidepath/gui/assets/icon_32.png +0 -0
  53. glidepath/gui/assets/icon_48.png +0 -0
  54. glidepath/gui/assets/icon_64.png +0 -0
  55. glidepath/gui/assets/wordmark.png +0 -0
  56. glidepath/gui/charts.py +829 -0
  57. glidepath/gui/forms.py +359 -0
  58. glidepath/gui/inspector.py +186 -0
  59. glidepath/gui/main.py +51 -0
  60. glidepath/gui/scenarios.py +402 -0
  61. glidepath/gui/style.py +376 -0
  62. glidepath/gui/tableview.py +67 -0
  63. glidepath/gui/widgets.py +989 -0
  64. glidepath/persistence/__init__.py +48 -0
  65. glidepath/persistence/assumptions.py +112 -0
  66. glidepath/persistence/decode.py +747 -0
  67. glidepath/persistence/document.py +101 -0
  68. glidepath/persistence/encode.py +433 -0
  69. glidepath/persistence/migrations.py +158 -0
  70. glidepath/persistence/values.py +298 -0
  71. glidepath/py.typed +0 -0
  72. glidepath/regions/__init__.py +7 -0
  73. glidepath/regions/uk/__init__.py +189 -0
  74. glidepath/regions/uk/ages.py +156 -0
  75. glidepath/regions/uk/contributions.py +717 -0
  76. glidepath/regions/uk/data/age_rules.toml +78 -0
  77. glidepath/regions/uk/data/assumptions_default.toml +170 -0
  78. glidepath/regions/uk/data/returns_history.toml +150 -0
  79. glidepath/regions/uk/data/tax_year_2026_27.toml +98 -0
  80. glidepath/regions/uk/extension.py +479 -0
  81. glidepath/regions/uk/loader.py +704 -0
  82. glidepath/regions/uk/region.py +160 -0
  83. glidepath/regions/uk/schema.py +563 -0
  84. glidepath/regions/uk/state_pension.py +129 -0
  85. glidepath/regions/uk/tax.py +466 -0
  86. glidepath/regions/uk/wrappers.py +283 -0
  87. glidepath/regions/uk/years.py +92 -0
  88. glidepath-0.2.0.dist-info/METADATA +189 -0
  89. glidepath-0.2.0.dist-info/RECORD +93 -0
  90. glidepath-0.2.0.dist-info/WHEEL +4 -0
  91. glidepath-0.2.0.dist-info/entry_points.txt +3 -0
  92. glidepath-0.2.0.dist-info/licenses/LICENSE +21 -0
  93. glidepath-0.2.0.dist-info/licenses/LICENSE-DATA +28 -0
@@ -0,0 +1,759 @@
1
+ """Projection chart view models (roadmap 8.4; planning §5.2, §4.7).
2
+
3
+ Chart series for the three projection surfaces — wrapper balances,
4
+ income composition, and tax over the horizon — presented through the
5
+ core reporting layer (roadmap 4.4) in either money basis, plus the
6
+ Monte Carlo fan chart a held run adds under the Monte Carlo mode
7
+ (roadmap 9.24). **Real (today's money) is the default presentation**;
8
+ the nominal toggle re-presents the same ledger, never a second
9
+ inflation source (planning §5.2). Amounts stay ``Decimal`` here:
10
+ converting them to plot coordinates is shell mechanics (§4.7).
11
+ """
12
+
13
+ from collections import Counter
14
+ from dataclasses import dataclass
15
+ from decimal import Decimal
16
+ from typing import TYPE_CHECKING, Final
17
+
18
+ from glidepath.app.backtest import (
19
+ BacktestPanelViewModel,
20
+ build_backtest_panel,
21
+ selected_window,
22
+ )
23
+ from glidepath.app.display import format_money, format_share, format_wrapper_kind
24
+ from glidepath.app.drawdown import (
25
+ DrawdownPanelViewModel,
26
+ build_drawdown_panel,
27
+ )
28
+ from glidepath.app.montecarlo import (
29
+ DEFAULT_RUN_MODE,
30
+ FAN_MEDIAN_LABEL,
31
+ FAN_SPECS,
32
+ MonteCarloPanelViewModel,
33
+ build_monte_carlo_panel,
34
+ )
35
+ from glidepath.app.retirement import (
36
+ RetirementPanelViewModel,
37
+ build_retirement_panel,
38
+ )
39
+ from glidepath.core import (
40
+ AssumptionKey,
41
+ Money,
42
+ Provenance,
43
+ ReportBasis,
44
+ RunMode,
45
+ build_report,
46
+ mapping_assumption_value,
47
+ )
48
+
49
+ if TYPE_CHECKING:
50
+ from collections.abc import Callable, Iterable, Mapping
51
+
52
+ from glidepath.app.plan import PlanState
53
+ from glidepath.core import (
54
+ AssetAllocation,
55
+ BacktestResult,
56
+ EntityId,
57
+ MonteCarloResult,
58
+ Period,
59
+ PeriodReportRow,
60
+ ProjectionReport,
61
+ WindowOutcome,
62
+ )
63
+
64
+ _ZERO = Money(Decimal(0))
65
+ _MIN_AXIS_MAX = Decimal(1)
66
+ _MEDIAN_PERCENTILE = Decimal(50)
67
+
68
+ DEFAULT_CHART_BASIS: Final = ReportBasis.REAL
69
+
70
+ BASIS_HEADING: Final = "Money basis"
71
+
72
+ NO_PROJECTION_MESSAGE: Final = (
73
+ "No projection yet — save facts on the Facts tab to see charts."
74
+ )
75
+
76
+ RUN_FAILED_PREFIX: Final = "The projection failed: "
77
+
78
+ BALANCES_CHART_TITLE: Final = "Wrapper balances"
79
+
80
+ INCOME_CHART_TITLE: Final = "Income composition"
81
+
82
+ TAX_CHART_TITLE: Final = "Tax"
83
+
84
+ TAX_SERIES_LABEL: Final = "Tax due"
85
+
86
+ MONTE_CARLO_CHART_TITLE: Final = "Monte Carlo"
87
+
88
+ CHART_VIEW_LABEL: Final = "Chart"
89
+
90
+ TABLE_VIEW_LABEL: Final = "Table"
91
+
92
+ PERIOD_COLUMN_LABEL: Final = "Period"
93
+
94
+ _BASIS_BY_KEY: Final[Mapping[str, ReportBasis]] = {
95
+ "real": ReportBasis.REAL,
96
+ "nominal": ReportBasis.NOMINAL,
97
+ }
98
+
99
+ _KEY_BY_BASIS: Final[Mapping[ReportBasis, str]] = {
100
+ basis: key for key, basis in _BASIS_BY_KEY.items()
101
+ }
102
+
103
+ _BASIS_LABELS: Final[Mapping[str, str]] = {
104
+ "real": "Real (today's money)",
105
+ "nominal": "Nominal",
106
+ }
107
+
108
+ _BASIS_SUFFIXES: Final[Mapping[str, str]] = {
109
+ "real": "today's money",
110
+ "nominal": "nominal",
111
+ }
112
+
113
+ _UNKNOWN_BASIS_MESSAGE: Final = "unknown chart basis key"
114
+
115
+ # The stacked sources must not overlap: the report's pension_lump_sum
116
+ # and annuity_lump_sum are column views of tax-free cash the wrappers
117
+ # already carry in withdrawal_tax_free, so they are already inside
118
+ # withdrawals_gross. Only the DB commutation lump sum is cash from
119
+ # outside the wrappers.
120
+ _INCOME_SOURCES: Final[tuple[tuple[str, Callable[[PeriodReportRow], Money]], ...]] = (
121
+ ("Employment", lambda row: row.employment_income),
122
+ ("DB pension", lambda row: row.db_income),
123
+ ("DB lump sum", lambda row: row.db_lump_sum),
124
+ ("State pension", lambda row: row.state_pension_income),
125
+ ("Annuity income", lambda row: row.annuity_income),
126
+ ("Withdrawals (gross)", lambda row: row.withdrawals_gross),
127
+ )
128
+
129
+
130
+ @dataclass(frozen=True)
131
+ class ChartBasisOption:
132
+ """One money-basis choice for the charts screen (planning §5.2)."""
133
+
134
+ key: str
135
+ label: str
136
+
137
+
138
+ @dataclass(frozen=True)
139
+ class ChartSeries:
140
+ """One stacked series: a label and a value per period category."""
141
+
142
+ label: str
143
+ values: tuple[Decimal, ...]
144
+
145
+
146
+ @dataclass(frozen=True)
147
+ class ChartBand:
148
+ """One overlay line over the categories (roadmap 9.13, 9.18)."""
149
+
150
+ label: str
151
+ values: tuple[Decimal, ...]
152
+
153
+
154
+ @dataclass(frozen=True)
155
+ class ChartFill:
156
+ """One filled band between two per-category value runs (9.24).
157
+
158
+ The Monte Carlo fan's building block: ``lower`` and ``upper``
159
+ bound one inter-percentile region, one value per period category
160
+ each. Shells fill between them; the tooltip copy comes from
161
+ :func:`fill_tooltip` over these exact ``Decimal`` amounts.
162
+ """
163
+
164
+ label: str
165
+ lower: tuple[Decimal, ...]
166
+ upper: tuple[Decimal, ...]
167
+
168
+
169
+ @dataclass(frozen=True)
170
+ class ChartSpec:
171
+ """One chart, ready for a shell to bind to a plotting widget.
172
+
173
+ ``bands`` are overlay lines over any stacked bars — backtest
174
+ trajectories on the balances chart (roadmap 9.18), the median on
175
+ the Monte Carlo fan chart (9.24). ``fills`` are the fan chart's
176
+ nested inter-percentile regions, outermost first — empty on every
177
+ other chart. ``y_axis_max`` is the largest stacked total, band, or
178
+ fill value across the periods (never below 1, so an all-zero chart
179
+ still renders a visible axis); every value here is non-negative,
180
+ so the y range is always ``[0, y_axis_max]``.
181
+ """
182
+
183
+ title: str
184
+ y_axis_label: str
185
+ y_axis_max: Decimal
186
+ series: tuple[ChartSeries, ...]
187
+ bands: tuple[ChartBand, ...] = ()
188
+ fills: tuple[ChartFill, ...] = ()
189
+
190
+
191
+ @dataclass(frozen=True)
192
+ class ChartTable:
193
+ """One chart's amounts as pre-formatted table cells (roadmap 9.26).
194
+
195
+ The tabular dual of a :class:`ChartSpec`: one row per period, one
196
+ column per stacked series, fan fill, and overlay line, in the
197
+ order the chart's legend reads them. Cells are already display
198
+ copy — money-formatted from the same exact ``Decimal`` amounts
199
+ the chart draws, so the table and the chart can never disagree.
200
+ """
201
+
202
+ columns: tuple[str, ...]
203
+ rows: tuple[tuple[str, ...], ...]
204
+
205
+
206
+ @dataclass(frozen=True)
207
+ class ChartsViewModel:
208
+ """The projection charts screen (roadmap 8.4).
209
+
210
+ ``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``
214
+ carries the empty-state copy when there is nothing to chart; it
215
+ is blank whenever ``charts`` is populated. ``allocation_note``
216
+ states the asset allocation each wrapper actually ran — stated
217
+ equity split, pinned cash, or the glide path with its provenance —
218
+ so a projection can never silently model a mix the user does not
219
+ hold.
220
+ """
221
+
222
+ basis_heading: str
223
+ basis_options: tuple[ChartBasisOption, ...]
224
+ selected_basis_key: str
225
+ categories: tuple[str, ...]
226
+ charts: tuple[ChartSpec, ...]
227
+ message: str
228
+ allocation_note: str
229
+ monte_carlo: MonteCarloPanelViewModel
230
+ retirement: RetirementPanelViewModel
231
+ drawdown: DrawdownPanelViewModel
232
+ backtest: BacktestPanelViewModel
233
+
234
+
235
+ def basis_from_key(key: str) -> ReportBasis:
236
+ """The report basis a shell-selected option key denotes.
237
+
238
+ Raises:
239
+ ValueError: If ``key`` is not a known basis option key.
240
+ """
241
+ basis = _BASIS_BY_KEY.get(key)
242
+ if basis is None:
243
+ raise ValueError(_UNKNOWN_BASIS_MESSAGE)
244
+ return basis
245
+
246
+
247
+ def basis_key(basis: ReportBasis) -> str:
248
+ """The option key denoting ``basis`` — the inverse of ``basis_from_key``."""
249
+ return _KEY_BY_BASIS[basis]
250
+
251
+
252
+ def basis_options() -> tuple[ChartBasisOption, ...]:
253
+ """The money-basis choices every basis toggle offers (planning §5.2)."""
254
+ return tuple(
255
+ ChartBasisOption(key=key, label=_BASIS_LABELS[key]) for key in _BASIS_BY_KEY
256
+ )
257
+
258
+
259
+ def basis_suffix(basis: ReportBasis) -> str:
260
+ """The axis-label suffix naming ``basis`` (e.g. ``today's money``)."""
261
+ return _BASIS_SUFFIXES[_KEY_BY_BASIS[basis]]
262
+
263
+
264
+ def bar_tooltip(category: str, series_label: str, value: Decimal) -> str:
265
+ """The hover copy for one bar segment or overlay-line point (§4.7).
266
+
267
+ Shells bind this to their plotting widget's hover affordance, so
268
+ the copy — series or line label, period, and the amount in pounds
269
+ and pence — stays app-layer like every other label on the chart
270
+ (roadmap 9.23 extends it from the bar segments to the overlay
271
+ lines).
272
+ """
273
+ return f"{series_label}\n{category}: {format_money(Money(value))}"
274
+
275
+
276
+ def fill_tooltip(category: str, label: str, lower: Decimal, upper: Decimal) -> str:
277
+ """The hover copy for one fan fill at one period (9.23, 9.24).
278
+
279
+ The fill's interval statement made concrete for the hovered
280
+ period: its label with the low and high amounts, from the same
281
+ exact ``Decimal`` values the fill draws.
282
+ """
283
+ low = format_money(Money(lower))
284
+ high = format_money(Money(upper))
285
+ return f"{label}\n{category}: {low} to {high}"
286
+
287
+
288
+ def _money_cell(value: Decimal) -> str:
289
+ """One chart amount as a table cell in pounds and pence."""
290
+ return format_money(Money(value))
291
+
292
+
293
+ def _interval_cell(fill: ChartFill, index: int) -> str:
294
+ """One fan fill's interval at one period, phrased like its tooltip."""
295
+ low = format_money(Money(fill.lower[index]))
296
+ high = format_money(Money(fill.upper[index]))
297
+ return f"{low} to {high}"
298
+
299
+
300
+ def chart_table(chart: ChartSpec, categories: tuple[str, ...]) -> ChartTable:
301
+ """``chart``'s numbers as a table over ``categories`` (roadmap 9.26).
302
+
303
+ Shells bind this beside the drawn chart so every graph is also
304
+ readable as figures. The period column leads; the value columns
305
+ follow the legend's reading order — stacked series, then fan
306
+ fills, then overlay lines — and a fill cell states its low-to-high
307
+ interval like :func:`fill_tooltip`.
308
+ """
309
+ columns = (
310
+ PERIOD_COLUMN_LABEL,
311
+ *(entry.label for entry in chart.series),
312
+ *(fill.label for fill in chart.fills),
313
+ *(band.label for band in chart.bands),
314
+ )
315
+ rows = tuple(
316
+ (
317
+ category,
318
+ *(_money_cell(entry.values[index]) for entry in chart.series),
319
+ *(_interval_cell(fill, index) for fill in chart.fills),
320
+ *(_money_cell(band.values[index]) for band in chart.bands),
321
+ )
322
+ for index, category in enumerate(categories)
323
+ )
324
+ return ChartTable(columns=columns, rows=rows)
325
+
326
+
327
+ def build_charts_view_model(
328
+ state: PlanState,
329
+ basis: ReportBasis = DEFAULT_CHART_BASIS,
330
+ mode: RunMode = DEFAULT_RUN_MODE,
331
+ *,
332
+ backtest_year: str = "",
333
+ ) -> ChartsViewModel:
334
+ """The charts screen for ``state``, presented in ``basis``.
335
+
336
+ Real (today's money) is the default; the deflators come from the
337
+ run's own CPI path via the core reporting layer (planning §5.2).
338
+ ``mode`` is the screen's run-mode selection (roadmap 9.13): under
339
+ ``MONTE_CARLO`` a held Monte Carlo run adds its fan chart as a
340
+ fourth chart (9.24) and its metrics to the panel.
341
+ ``backtest_year`` is the backtest card's starting-year picker as
342
+ raw text (presentation state the shell holds, like the basis and
343
+ mode): with a held backtest it adds that starting year's actual
344
+ trajectory to the balances chart alongside the worst and best
345
+ ones. Without a projection the screen carries only the
346
+ empty-state message — the no-run copy, or the run failure held on
347
+ the state.
348
+ """
349
+ selected_key = basis_key(basis)
350
+ options = basis_options()
351
+ suffix = basis_suffix(basis)
352
+ if state.result is None:
353
+ message = (
354
+ NO_PROJECTION_MESSAGE
355
+ if state.run_error is None
356
+ else RUN_FAILED_PREFIX + state.run_error
357
+ )
358
+ return ChartsViewModel(
359
+ basis_heading=BASIS_HEADING,
360
+ basis_options=options,
361
+ selected_basis_key=selected_key,
362
+ categories=(),
363
+ charts=(),
364
+ message=message,
365
+ allocation_note="",
366
+ monte_carlo=build_monte_carlo_panel(
367
+ state, mode, ending_pot_deflator=None, basis_suffix=suffix
368
+ ),
369
+ retirement=build_retirement_panel(state, mode),
370
+ drawdown=build_drawdown_panel(state, mode),
371
+ backtest=build_backtest_panel(
372
+ state,
373
+ ending_pot_deflator=None,
374
+ basis_suffix=suffix,
375
+ year_text=backtest_year,
376
+ ),
377
+ )
378
+ grouped = _rows_by_period(build_report(state.result, basis))
379
+ bands = _chart_bands(state, grouped, backtest_year)
380
+ final_rows = next(reversed(grouped.values()))
381
+ charts = [
382
+ _balances_chart(grouped, suffix, bands),
383
+ _income_chart(grouped, suffix),
384
+ _tax_chart(grouped, suffix),
385
+ ]
386
+ if mode is RunMode.MONTE_CARLO and state.monte_carlo is not None:
387
+ fan = _fan_chart(state.monte_carlo, grouped, suffix)
388
+ if fan is not None:
389
+ charts.append(fan)
390
+ return ChartsViewModel(
391
+ basis_heading=BASIS_HEADING,
392
+ basis_options=options,
393
+ selected_basis_key=selected_key,
394
+ categories=tuple(
395
+ _category_label(period, rows) for period, rows in grouped.items()
396
+ ),
397
+ charts=tuple(charts),
398
+ message="",
399
+ allocation_note=_allocation_note(state, grouped),
400
+ monte_carlo=build_monte_carlo_panel(
401
+ state,
402
+ mode,
403
+ ending_pot_deflator=final_rows[0].balance_deflator,
404
+ basis_suffix=suffix,
405
+ ),
406
+ retirement=build_retirement_panel(state, mode),
407
+ drawdown=build_drawdown_panel(state, mode),
408
+ backtest=build_backtest_panel(
409
+ state,
410
+ ending_pot_deflator=final_rows[0].balance_deflator,
411
+ basis_suffix=suffix,
412
+ year_text=backtest_year,
413
+ ),
414
+ )
415
+
416
+
417
+ def _chart_bands(
418
+ state: PlanState,
419
+ grouped: dict[Period, list[PeriodReportRow]],
420
+ backtest_year: str,
421
+ ) -> tuple[ChartBand, ...]:
422
+ """The overlay lines the balances chart draws, if any.
423
+
424
+ A held backtest supplies actual window trajectories in either run
425
+ mode (roadmap 9.18). A held Monte Carlo run no longer overlays
426
+ the balances chart — its percentiles moved to the fan chart's own
427
+ tab (9.24) so neither surface crowds the other.
428
+ """
429
+ if state.backtest is not None:
430
+ return _backtest_trajectories(state.backtest, grouped, backtest_year)
431
+ return ()
432
+
433
+
434
+ def _glide_note(state: PlanState) -> str:
435
+ """The glide path summarised with its provenance (planning §5.1).
436
+
437
+ The flat-shape check compares the shape's raw values — numeric
438
+ equality, so an overridden ``1`` and ``1.0`` are the same
439
+ allocation whatever their text.
440
+ """
441
+ assumption = state.assumptions.get(AssumptionKey.GLIDEPATH_DEFAULT_SHAPE)
442
+ shape = mapping_assumption_value(assumption)
443
+ start = shape["equity_start"]
444
+ at_retirement = shape["equity_at_retirement"]
445
+ years = shape["derisk_years_before_retirement"]
446
+ provenance = (
447
+ "shipped default"
448
+ if assumption.provenance is Provenance.DEFAULT_ASSUMPTION
449
+ else "your override"
450
+ )
451
+ if start == at_retirement:
452
+ return (
453
+ f"glide path — {format_share(Decimal(str(start)))} equity"
454
+ f" throughout ({provenance})"
455
+ )
456
+ return (
457
+ f"glide path — {format_share(Decimal(str(start)))} equity de-risking"
458
+ f" to {format_share(Decimal(str(at_retirement)))} over the {years}"
459
+ f" years before retirement ({provenance})"
460
+ )
461
+
462
+
463
+ def _allocation_text(allocation: AssetAllocation) -> str:
464
+ """One stated allocation as copy: ``70% equity, 30% bonds``.
465
+
466
+ Zero slices are dropped — the weights sum to one, so at least one
467
+ always remains.
468
+ """
469
+ slices = (
470
+ (allocation.equity, "equity"),
471
+ (allocation.bonds, "bonds"),
472
+ (allocation.cash, "cash"),
473
+ )
474
+ return ", ".join(
475
+ f"{format_share(value)} {name}" for value, name in slices if value != Decimal(0)
476
+ )
477
+
478
+
479
+ def _allocation_note(
480
+ state: PlanState, grouped: dict[Period, list[PeriodReportRow]]
481
+ ) -> str:
482
+ """What each wrapper is invested in, as one status line (§5.1).
483
+
484
+ Every projection surface — deterministic, Monte Carlo, backtest —
485
+ runs this allocation, so the line sits with the charts it
486
+ explains. A stated equity split reads ``(stated)``; a cash
487
+ account's pinned allocation is a rule and carries no suffix; a
488
+ wrapper with nothing stated names the glide path and whether it
489
+ is the shipped default or an override. A household holding no
490
+ wrappers at all (DB and state pension income only) has nothing to
491
+ invest, so the note is empty rather than a dangling prefix.
492
+ """
493
+ household = state.household
494
+ if household is None:
495
+ return ""
496
+ labels = wrapper_display_labels(row for rows in grouped.values() for row in rows)
497
+ glide = _glide_note(state)
498
+ parts = []
499
+ for person in household.persons:
500
+ for wrapper in person.wrappers:
501
+ label = labels.get(wrapper.id, format_wrapper_kind(wrapper.kind))
502
+ allocation = wrapper.allocation
503
+ if allocation is None:
504
+ parts.append(f"{label}: {glide}")
505
+ elif allocation.cash == Decimal(1):
506
+ parts.append(f"{label}: {_allocation_text(allocation)}")
507
+ else:
508
+ parts.append(f"{label}: {_allocation_text(allocation)} (stated)")
509
+ if not parts:
510
+ return ""
511
+ return "Invested as — " + "; ".join(parts)
512
+
513
+
514
+ def _category_label(period: Period, rows: list[PeriodReportRow]) -> str:
515
+ """One period's x-axis label: its start year, with the person's age.
516
+
517
+ ``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).
521
+ """
522
+ year = str(period.start.year)
523
+ if len(rows) == 1:
524
+ return f"{year} · {rows[0].age_at_period_start}"
525
+ return year
526
+
527
+
528
+ def _rows_by_period(
529
+ report: ProjectionReport,
530
+ ) -> dict[Period, list[PeriodReportRow]]:
531
+ """Report rows grouped by period, in period order.
532
+
533
+ With a multi-person household each period holds one row per
534
+ person; the charts aggregate them to household level.
535
+ """
536
+ grouped: dict[Period, list[PeriodReportRow]] = {}
537
+ for row in report.rows:
538
+ grouped.setdefault(row.period, []).append(row)
539
+ return grouped
540
+
541
+
542
+ def _period_total(
543
+ rows: list[PeriodReportRow], amount: Callable[[PeriodReportRow], Money]
544
+ ) -> Decimal:
545
+ """One period's household total of a per-person amount."""
546
+ total = _ZERO
547
+ for row in rows:
548
+ total = total + amount(row)
549
+ return total.amount
550
+
551
+
552
+ def _chart(
553
+ title: str,
554
+ y_axis_label: str,
555
+ series: tuple[ChartSeries, ...],
556
+ bands: tuple[ChartBand, ...] = (),
557
+ fills: tuple[ChartFill, ...] = (),
558
+ ) -> ChartSpec:
559
+ """A chart spec with its y axis covering stack, bands, and fills."""
560
+ stacked: dict[int, Decimal] = {}
561
+ for entry in series:
562
+ for index, value in enumerate(entry.values):
563
+ stacked[index] = stacked.get(index, Decimal(0)) + value
564
+ band_values = [value for band in bands for value in band.values]
565
+ fill_values = [value for fill in fills for value in fill.upper]
566
+ return ChartSpec(
567
+ title=title,
568
+ y_axis_label=y_axis_label,
569
+ y_axis_max=max([*stacked.values(), *band_values, *fill_values, _MIN_AXIS_MAX]),
570
+ series=series,
571
+ bands=bands,
572
+ fills=fills,
573
+ )
574
+
575
+
576
+ def wrapper_display_labels(rows: Iterable[PeriodReportRow]) -> dict[EntityId, str]:
577
+ """A display label per wrapper, in first-seen order.
578
+
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.
584
+ """
585
+ kinds: dict[EntityId, str] = {}
586
+ for row in rows:
587
+ 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
599
+
600
+
601
+ def _deflated(
602
+ values: tuple[Money, ...], deflators: tuple[Decimal, ...]
603
+ ) -> tuple[Decimal, ...]:
604
+ """Nominal per-period amounts presented by the report's deflators.
605
+
606
+ CPI is deterministic across Monte Carlo paths and backtest windows
607
+ alike (planning §5.2), so each period's nominal amount deflates by
608
+ the same balance deflator the deterministic report rows carry — 1
609
+ under the nominal basis.
610
+ """
611
+ return tuple(
612
+ Money(value.amount / deflator).quantized().amount
613
+ for value, deflator in zip(values, deflators, strict=True)
614
+ )
615
+
616
+
617
+ def _fan_chart(
618
+ result: MonteCarloResult,
619
+ grouped: dict[Period, list[PeriodReportRow]],
620
+ suffix: str,
621
+ ) -> ChartSpec | None:
622
+ """The Monte Carlo fan chart, or ``None`` if it cannot align (9.24).
623
+
624
+ Nested inter-percentile fills (outermost first, per ``FAN_SPECS``)
625
+ with the median as the single overlay line — each fill a genuine
626
+ interval statement over the paths' per-period closing balances,
627
+ deflated like every Monte Carlo amount (:func:`_deflated`). All
628
+ nine percentiles come from one ``balance_percentiles`` batch — the
629
+ view model is built on the GUI thread, and per-percentile calls
630
+ would re-sort a 10,000-path result nine times over. A held result
631
+ whose period count differs from the projection's (the runs
632
+ straddled a calendar day) draws no fan rather than a fan against
633
+ the wrong periods.
634
+ """
635
+ deflators = tuple(rows[0].balance_deflator for rows in grouped.values())
636
+ count = len(FAN_SPECS)
637
+ requested = (
638
+ *(spec.lower for spec in FAN_SPECS),
639
+ *(spec.upper for spec in FAN_SPECS),
640
+ _MEDIAN_PERCENTILE,
641
+ )
642
+ percentiles = result.balance_percentiles(requested)
643
+ if len(percentiles[0]) != len(deflators):
644
+ return None
645
+ fills = tuple(
646
+ ChartFill(
647
+ label=spec.label,
648
+ lower=_deflated(lower, deflators),
649
+ upper=_deflated(upper, deflators),
650
+ )
651
+ for spec, lower, upper in zip(
652
+ FAN_SPECS, percentiles[:count], percentiles[count : 2 * count], strict=True
653
+ )
654
+ )
655
+ median = ChartBand(
656
+ label=FAN_MEDIAN_LABEL,
657
+ values=_deflated(percentiles[-1], deflators),
658
+ )
659
+ return _chart(
660
+ MONTE_CARLO_CHART_TITLE,
661
+ f"Closing balance, £ ({suffix})",
662
+ (),
663
+ bands=(median,),
664
+ fills=fills,
665
+ )
666
+
667
+
668
+ def _trajectory_band(
669
+ outcome: WindowOutcome, label_prefix: str, deflators: tuple[Decimal, ...]
670
+ ) -> ChartBand:
671
+ """One window's actual balance path as a chart line (9.18).
672
+
673
+ Labelled with its starting year (`Worst start · 1907`), so the
674
+ legend names the history being replayed; the nominal balances
675
+ deflate by the report rows' own deflators like every band.
676
+ """
677
+ return ChartBand(
678
+ label=f"{label_prefix} · {outcome.start_year}",
679
+ values=_deflated(outcome.closing_balances, deflators),
680
+ )
681
+
682
+
683
+ def _backtest_trajectories(
684
+ result: BacktestResult,
685
+ grouped: dict[Period, list[PeriodReportRow]],
686
+ year_text: str,
687
+ ) -> tuple[ChartBand, ...]:
688
+ """The worst, best, and picked starting years' actual paths (9.18).
689
+
690
+ Unlike the Monte Carlo percentile bands — pointwise order
691
+ statistics that follow no single path — each backtest line is one
692
+ starting year's real trajectory: the worst and best windows
693
+ always, plus whichever starting year the picker's raw text names
694
+ (:func:`~glidepath.app.backtest.selected_window`; a miss draws
695
+ nothing and the card says why). Any drawn outcome whose period
696
+ count differs from the projection's (the runs straddled a
697
+ calendar day, or a hand-built result misaligned its windows)
698
+ draws no lines rather than lines against the wrong periods.
699
+ """
700
+ deflators = tuple(rows[0].balance_deflator for rows in grouped.values())
701
+ picked, _ = selected_window(result, year_text)
702
+ lines = [
703
+ (result.worst_window, "Worst start"),
704
+ (result.best_window, "Best start"),
705
+ ]
706
+ if picked is not None:
707
+ lines.append((picked, "Start"))
708
+ if any(len(outcome.closing_balances) != len(deflators) for outcome, _ in lines):
709
+ return ()
710
+ return tuple(
711
+ _trajectory_band(outcome, prefix, deflators) for outcome, prefix in lines
712
+ )
713
+
714
+
715
+ def _balances_chart(
716
+ grouped: dict[Period, list[PeriodReportRow]],
717
+ suffix: str,
718
+ bands: tuple[ChartBand, ...],
719
+ ) -> ChartSpec:
720
+ """Closing balance per wrapper per period, stacked to the total."""
721
+ series = []
722
+ labels = wrapper_display_labels(row for rows in grouped.values() for row in rows)
723
+ for wrapper_id, label in labels.items():
724
+ values = []
725
+ for rows in grouped.values():
726
+ total = _ZERO
727
+ for row in rows:
728
+ for entry in row.wrapper_balances:
729
+ if entry.wrapper_id == wrapper_id:
730
+ total = total + entry.closing_balance
731
+ values.append(total.amount)
732
+ series.append(ChartSeries(label=label, values=tuple(values)))
733
+ return _chart(
734
+ BALANCES_CHART_TITLE, f"Closing balance, £ ({suffix})", tuple(series), bands
735
+ )
736
+
737
+
738
+ def _income_chart(
739
+ grouped: dict[Period, list[PeriodReportRow]], suffix: str
740
+ ) -> ChartSpec:
741
+ """Income by source per period; sources never drawn on are dropped."""
742
+ series = []
743
+ for label, amount in _INCOME_SOURCES:
744
+ values = tuple(_period_total(rows, amount) for rows in grouped.values())
745
+ if any(value != 0 for value in values):
746
+ series.append(ChartSeries(label=label, values=values))
747
+ return _chart(INCOME_CHART_TITLE, f"Income, £ per period ({suffix})", tuple(series))
748
+
749
+
750
+ def _tax_chart(grouped: dict[Period, list[PeriodReportRow]], suffix: str) -> ChartSpec:
751
+ """Tax due per period across the household."""
752
+ values = tuple(
753
+ _period_total(rows, lambda row: row.tax_due) for rows in grouped.values()
754
+ )
755
+ return _chart(
756
+ TAX_CHART_TITLE,
757
+ f"Tax due, £ per period ({suffix})",
758
+ (ChartSeries(label=TAX_SERIES_LABEL, values=values),),
759
+ )