glidepath 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- glidepath/__init__.py +3 -0
- glidepath/app/__init__.py +364 -0
- glidepath/app/backtest.py +281 -0
- glidepath/app/charts.py +759 -0
- glidepath/app/copy.py +174 -0
- glidepath/app/display.py +148 -0
- glidepath/app/drawdown.py +436 -0
- glidepath/app/example.py +66 -0
- glidepath/app/exports.py +487 -0
- glidepath/app/files.py +249 -0
- glidepath/app/firstrun.py +114 -0
- glidepath/app/forms.py +1750 -0
- glidepath/app/inspector.py +506 -0
- glidepath/app/labels.py +66 -0
- glidepath/app/montecarlo.py +399 -0
- glidepath/app/plan.py +354 -0
- glidepath/app/retirement.py +446 -0
- glidepath/app/scenarios.py +831 -0
- glidepath/app/shell.py +185 -0
- glidepath/app/tables.py +138 -0
- glidepath/core/__init__.py +390 -0
- glidepath/core/annuities.py +240 -0
- glidepath/core/backtest.py +514 -0
- glidepath/core/comparison.py +278 -0
- glidepath/core/config.py +82 -0
- glidepath/core/contributions.py +337 -0
- glidepath/core/engine.py +2811 -0
- glidepath/core/entities.py +264 -0
- glidepath/core/glide.py +289 -0
- glidepath/core/investments.py +175 -0
- glidepath/core/money.py +107 -0
- glidepath/core/montecarlo.py +609 -0
- glidepath/core/pensions.py +298 -0
- glidepath/core/periods.py +367 -0
- glidepath/core/provenance.py +271 -0
- glidepath/core/randomness.py +128 -0
- glidepath/core/region.py +46 -0
- glidepath/core/reporting.py +231 -0
- glidepath/core/results.py +504 -0
- glidepath/core/retirement.py +291 -0
- glidepath/core/returns.py +312 -0
- glidepath/core/scenarios.py +579 -0
- glidepath/core/state_pension.py +264 -0
- glidepath/core/tax.py +139 -0
- glidepath/core/withdrawals.py +461 -0
- glidepath/core/wrappers.py +278 -0
- glidepath/gui/__init__.py +6 -0
- glidepath/gui/assets/icon_128.png +0 -0
- glidepath/gui/assets/icon_16.png +0 -0
- glidepath/gui/assets/icon_24.png +0 -0
- glidepath/gui/assets/icon_256.png +0 -0
- glidepath/gui/assets/icon_32.png +0 -0
- glidepath/gui/assets/icon_48.png +0 -0
- glidepath/gui/assets/icon_64.png +0 -0
- glidepath/gui/assets/wordmark.png +0 -0
- glidepath/gui/charts.py +829 -0
- glidepath/gui/forms.py +359 -0
- glidepath/gui/inspector.py +186 -0
- glidepath/gui/main.py +51 -0
- glidepath/gui/scenarios.py +402 -0
- glidepath/gui/style.py +376 -0
- glidepath/gui/tableview.py +67 -0
- glidepath/gui/widgets.py +989 -0
- glidepath/persistence/__init__.py +48 -0
- glidepath/persistence/assumptions.py +112 -0
- glidepath/persistence/decode.py +747 -0
- glidepath/persistence/document.py +101 -0
- glidepath/persistence/encode.py +433 -0
- glidepath/persistence/migrations.py +158 -0
- glidepath/persistence/values.py +298 -0
- glidepath/py.typed +0 -0
- glidepath/regions/__init__.py +7 -0
- glidepath/regions/uk/__init__.py +189 -0
- glidepath/regions/uk/ages.py +156 -0
- glidepath/regions/uk/contributions.py +717 -0
- glidepath/regions/uk/data/age_rules.toml +78 -0
- glidepath/regions/uk/data/assumptions_default.toml +170 -0
- glidepath/regions/uk/data/returns_history.toml +150 -0
- glidepath/regions/uk/data/tax_year_2026_27.toml +98 -0
- glidepath/regions/uk/extension.py +479 -0
- glidepath/regions/uk/loader.py +704 -0
- glidepath/regions/uk/region.py +160 -0
- glidepath/regions/uk/schema.py +563 -0
- glidepath/regions/uk/state_pension.py +129 -0
- glidepath/regions/uk/tax.py +466 -0
- glidepath/regions/uk/wrappers.py +283 -0
- glidepath/regions/uk/years.py +92 -0
- glidepath-0.2.0.dist-info/METADATA +189 -0
- glidepath-0.2.0.dist-info/RECORD +93 -0
- glidepath-0.2.0.dist-info/WHEEL +4 -0
- glidepath-0.2.0.dist-info/entry_points.txt +3 -0
- glidepath-0.2.0.dist-info/licenses/LICENSE +21 -0
- glidepath-0.2.0.dist-info/licenses/LICENSE-DATA +28 -0
glidepath/app/charts.py
ADDED
|
@@ -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
|
+
)
|