glidepath 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- glidepath/__init__.py +3 -0
- glidepath/app/__init__.py +364 -0
- glidepath/app/backtest.py +281 -0
- glidepath/app/charts.py +759 -0
- glidepath/app/copy.py +174 -0
- glidepath/app/display.py +148 -0
- glidepath/app/drawdown.py +436 -0
- glidepath/app/example.py +66 -0
- glidepath/app/exports.py +487 -0
- glidepath/app/files.py +249 -0
- glidepath/app/firstrun.py +114 -0
- glidepath/app/forms.py +1750 -0
- glidepath/app/inspector.py +506 -0
- glidepath/app/labels.py +66 -0
- glidepath/app/montecarlo.py +399 -0
- glidepath/app/plan.py +354 -0
- glidepath/app/retirement.py +446 -0
- glidepath/app/scenarios.py +831 -0
- glidepath/app/shell.py +185 -0
- glidepath/app/tables.py +138 -0
- glidepath/core/__init__.py +390 -0
- glidepath/core/annuities.py +240 -0
- glidepath/core/backtest.py +514 -0
- glidepath/core/comparison.py +278 -0
- glidepath/core/config.py +82 -0
- glidepath/core/contributions.py +337 -0
- glidepath/core/engine.py +2811 -0
- glidepath/core/entities.py +264 -0
- glidepath/core/glide.py +289 -0
- glidepath/core/investments.py +175 -0
- glidepath/core/money.py +107 -0
- glidepath/core/montecarlo.py +609 -0
- glidepath/core/pensions.py +298 -0
- glidepath/core/periods.py +367 -0
- glidepath/core/provenance.py +271 -0
- glidepath/core/randomness.py +128 -0
- glidepath/core/region.py +46 -0
- glidepath/core/reporting.py +231 -0
- glidepath/core/results.py +504 -0
- glidepath/core/retirement.py +291 -0
- glidepath/core/returns.py +312 -0
- glidepath/core/scenarios.py +579 -0
- glidepath/core/state_pension.py +264 -0
- glidepath/core/tax.py +139 -0
- glidepath/core/withdrawals.py +461 -0
- glidepath/core/wrappers.py +278 -0
- glidepath/gui/__init__.py +6 -0
- glidepath/gui/assets/icon_128.png +0 -0
- glidepath/gui/assets/icon_16.png +0 -0
- glidepath/gui/assets/icon_24.png +0 -0
- glidepath/gui/assets/icon_256.png +0 -0
- glidepath/gui/assets/icon_32.png +0 -0
- glidepath/gui/assets/icon_48.png +0 -0
- glidepath/gui/assets/icon_64.png +0 -0
- glidepath/gui/assets/wordmark.png +0 -0
- glidepath/gui/charts.py +829 -0
- glidepath/gui/forms.py +359 -0
- glidepath/gui/inspector.py +186 -0
- glidepath/gui/main.py +51 -0
- glidepath/gui/scenarios.py +402 -0
- glidepath/gui/style.py +376 -0
- glidepath/gui/tableview.py +67 -0
- glidepath/gui/widgets.py +989 -0
- glidepath/persistence/__init__.py +48 -0
- glidepath/persistence/assumptions.py +112 -0
- glidepath/persistence/decode.py +747 -0
- glidepath/persistence/document.py +101 -0
- glidepath/persistence/encode.py +433 -0
- glidepath/persistence/migrations.py +158 -0
- glidepath/persistence/values.py +298 -0
- glidepath/py.typed +0 -0
- glidepath/regions/__init__.py +7 -0
- glidepath/regions/uk/__init__.py +189 -0
- glidepath/regions/uk/ages.py +156 -0
- glidepath/regions/uk/contributions.py +717 -0
- glidepath/regions/uk/data/age_rules.toml +78 -0
- glidepath/regions/uk/data/assumptions_default.toml +170 -0
- glidepath/regions/uk/data/returns_history.toml +150 -0
- glidepath/regions/uk/data/tax_year_2026_27.toml +98 -0
- glidepath/regions/uk/extension.py +479 -0
- glidepath/regions/uk/loader.py +704 -0
- glidepath/regions/uk/region.py +160 -0
- glidepath/regions/uk/schema.py +563 -0
- glidepath/regions/uk/state_pension.py +129 -0
- glidepath/regions/uk/tax.py +466 -0
- glidepath/regions/uk/wrappers.py +283 -0
- glidepath/regions/uk/years.py +92 -0
- glidepath-0.2.0.dist-info/METADATA +189 -0
- glidepath-0.2.0.dist-info/RECORD +93 -0
- glidepath-0.2.0.dist-info/WHEEL +4 -0
- glidepath-0.2.0.dist-info/entry_points.txt +3 -0
- glidepath-0.2.0.dist-info/licenses/LICENSE +21 -0
- glidepath-0.2.0.dist-info/licenses/LICENSE-DATA +28 -0
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
"""The stated-vs-assumed inspector (roadmap 8.3; planning §1, §5.1).
|
|
2
|
+
|
|
3
|
+
Renders ``ProjectionResult.provenance`` — the engine's own record of
|
|
4
|
+
what the run rested on — as three columns: facts the user stated,
|
|
5
|
+
assumptions used (default vs overridden, with source and date), and
|
|
6
|
+
decisions in effect. The full shipped assumption catalogue follows the
|
|
7
|
+
read list so every default is overridable in place even before it is
|
|
8
|
+
read by a run. A fourth, entity-level section surfaces the plan's
|
|
9
|
+
*structural* inputs — fields that drive results but are neither
|
|
10
|
+
``Fact`` nor ``Decision`` wrapped, so they never appear as provenance
|
|
11
|
+
rows (planning §5.1, issue #70).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from collections.abc import Mapping
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import TYPE_CHECKING, Any
|
|
17
|
+
|
|
18
|
+
from glidepath.app.display import (
|
|
19
|
+
format_assumption_key,
|
|
20
|
+
format_date,
|
|
21
|
+
format_money,
|
|
22
|
+
format_recorded,
|
|
23
|
+
format_value,
|
|
24
|
+
format_wrapper_kind,
|
|
25
|
+
)
|
|
26
|
+
from glidepath.app.labels import display_label, entity_names
|
|
27
|
+
from glidepath.app.tables import table_edit_text
|
|
28
|
+
from glidepath.core import (
|
|
29
|
+
Assumption,
|
|
30
|
+
AssumptionKey,
|
|
31
|
+
Household,
|
|
32
|
+
Provenance,
|
|
33
|
+
RevaluationReference,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
if TYPE_CHECKING:
|
|
37
|
+
from decimal import Decimal
|
|
38
|
+
|
|
39
|
+
from glidepath.app.plan import PlanState
|
|
40
|
+
from glidepath.core import (
|
|
41
|
+
AnnuityPurchase,
|
|
42
|
+
AssetAllocation,
|
|
43
|
+
DBPension,
|
|
44
|
+
FactorTable,
|
|
45
|
+
FeeSchedule,
|
|
46
|
+
GlidePathConfig,
|
|
47
|
+
LifeStage,
|
|
48
|
+
Person,
|
|
49
|
+
RevaluationBasis,
|
|
50
|
+
Wrapper,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
_STATUS_LABELS: Mapping[Provenance, str] = {
|
|
54
|
+
Provenance.DEFAULT_ASSUMPTION: "Shipped default",
|
|
55
|
+
Provenance.USER_OVERRIDE: "Your override",
|
|
56
|
+
Provenance.SCENARIO_OVERRIDE: "Scenario override",
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
_RESIDENCY_NAMES: Mapping[str, str] = {
|
|
60
|
+
"uk.ruk": "UK (England, Wales, or Northern Ireland)",
|
|
61
|
+
"uk.scotland": "UK (Scotland)",
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
_USED_LABEL = "Used"
|
|
65
|
+
_UNUSED_LABEL = "Not used"
|
|
66
|
+
_NO_RUN_LABEL = "No projection yet"
|
|
67
|
+
_REGION_BUILD_LABEL = "Applied at region build"
|
|
68
|
+
|
|
69
|
+
_REGION_BUILD_KEYS = frozenset(
|
|
70
|
+
{
|
|
71
|
+
AssumptionKey.POLICY_STATE_PENSION_UPRATING,
|
|
72
|
+
AssumptionKey.POLICY_TAX_FUTURE_YEARS,
|
|
73
|
+
}
|
|
74
|
+
)
|
|
75
|
+
"""Keys read while building the region, before the run's read recorder
|
|
76
|
+
exists — their effect is identified through the region data version,
|
|
77
|
+
not the assumption read list (planning §5.1)."""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True)
|
|
81
|
+
class FactRow:
|
|
82
|
+
"""One stated fact, formatted for display."""
|
|
83
|
+
|
|
84
|
+
label: str
|
|
85
|
+
value: str
|
|
86
|
+
as_of: str
|
|
87
|
+
recorded: str
|
|
88
|
+
note: str
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True)
|
|
92
|
+
class RollForwardRow:
|
|
93
|
+
"""One stated amount rolled forward to the run start (§4.8).
|
|
94
|
+
|
|
95
|
+
A wrapper balance or state pension forecast rate: the stated fact
|
|
96
|
+
is unchanged; this row shows the estimate the engine layered on it
|
|
97
|
+
— the statement value, its date, the whole months rolled, and the
|
|
98
|
+
opening value the projection actually used.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
label: str
|
|
102
|
+
stated: str
|
|
103
|
+
as_of: str
|
|
104
|
+
months: str
|
|
105
|
+
opening: str
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(frozen=True)
|
|
109
|
+
class DecisionRow:
|
|
110
|
+
"""One decision in effect, formatted for display."""
|
|
111
|
+
|
|
112
|
+
label: str
|
|
113
|
+
value: str
|
|
114
|
+
recorded: str
|
|
115
|
+
note: str
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass(frozen=True)
|
|
119
|
+
class AssumptionRow:
|
|
120
|
+
"""One assumption with its full §1 provenance payload.
|
|
121
|
+
|
|
122
|
+
``key`` is the stable dotted id (what overrides target); ``label``
|
|
123
|
+
is its human display name and is what screens show. ``structured``
|
|
124
|
+
marks a table-valued row; its ``edit_text`` is the round-trippable
|
|
125
|
+
multi-line text form, while a scalar's is simply its value
|
|
126
|
+
(issue #71). ``value`` and ``default_value`` are display text and
|
|
127
|
+
truncate long tables; ``default_edit_text`` is the default's
|
|
128
|
+
complete form (a dash when nothing is overridden), so an export
|
|
129
|
+
can print the full figures the screen elides (9.19).
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
key: str
|
|
133
|
+
label: str
|
|
134
|
+
description: str
|
|
135
|
+
value: str
|
|
136
|
+
default_value: str
|
|
137
|
+
default_edit_text: str
|
|
138
|
+
status: str
|
|
139
|
+
usage: str
|
|
140
|
+
source: str
|
|
141
|
+
recorded: str
|
|
142
|
+
structured: bool
|
|
143
|
+
edit_text: str
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@dataclass(frozen=True)
|
|
147
|
+
class StructureRow:
|
|
148
|
+
"""One structural plan input, surfaced entity-level (§5.1, issue #70)."""
|
|
149
|
+
|
|
150
|
+
entity: str
|
|
151
|
+
setting: str
|
|
152
|
+
value: str
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@dataclass(frozen=True)
|
|
156
|
+
class InspectorViewModel:
|
|
157
|
+
"""The whole stated-vs-assumed screen (roadmap 8.3)."""
|
|
158
|
+
|
|
159
|
+
title: str
|
|
160
|
+
facts_heading: str
|
|
161
|
+
facts_columns: tuple[str, ...]
|
|
162
|
+
facts: tuple[FactRow, ...]
|
|
163
|
+
roll_forwards_heading: str
|
|
164
|
+
roll_forwards_columns: tuple[str, ...]
|
|
165
|
+
roll_forwards: tuple[RollForwardRow, ...]
|
|
166
|
+
assumptions_heading: str
|
|
167
|
+
assumptions_columns: tuple[str, ...]
|
|
168
|
+
assumptions: tuple[AssumptionRow, ...]
|
|
169
|
+
decisions_heading: str
|
|
170
|
+
decisions_columns: tuple[str, ...]
|
|
171
|
+
decisions: tuple[DecisionRow, ...]
|
|
172
|
+
structure_heading: str
|
|
173
|
+
structure_columns: tuple[str, ...]
|
|
174
|
+
structure: tuple[StructureRow, ...]
|
|
175
|
+
summary: str
|
|
176
|
+
summary_detail: str
|
|
177
|
+
override_title: str
|
|
178
|
+
override_prompt: str
|
|
179
|
+
table_override_prompt: str
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _assumption_row(assumption: Assumption[Any], usage: str) -> AssumptionRow:
|
|
183
|
+
"""One assumption as its display row.
|
|
184
|
+
|
|
185
|
+
The default column repeats the value verbatim whenever nothing is
|
|
186
|
+
overridden (the status column already says "Shipped default"), so
|
|
187
|
+
it renders as a dash unless the two differ — compared on the
|
|
188
|
+
underlying values, since long table values format to a truncated
|
|
189
|
+
text that can collide.
|
|
190
|
+
"""
|
|
191
|
+
structured = isinstance(assumption.value, Mapping)
|
|
192
|
+
value_text = format_value(assumption.value)
|
|
193
|
+
overridden = assumption.value != assumption.default_value
|
|
194
|
+
default_edit_text = "—"
|
|
195
|
+
if overridden:
|
|
196
|
+
default_edit_text = (
|
|
197
|
+
table_edit_text(assumption.default_value)
|
|
198
|
+
if isinstance(assumption.default_value, Mapping)
|
|
199
|
+
else format_value(assumption.default_value)
|
|
200
|
+
)
|
|
201
|
+
return AssumptionRow(
|
|
202
|
+
key=str(assumption.key.value),
|
|
203
|
+
label=format_assumption_key(assumption.key.value),
|
|
204
|
+
description=assumption.description,
|
|
205
|
+
value=value_text,
|
|
206
|
+
default_value=format_value(assumption.default_value) if overridden else "—",
|
|
207
|
+
default_edit_text=default_edit_text,
|
|
208
|
+
status=_STATUS_LABELS[assumption.provenance],
|
|
209
|
+
usage=usage,
|
|
210
|
+
source=assumption.source,
|
|
211
|
+
recorded=format_recorded(assumption.recorded_on),
|
|
212
|
+
structured=structured,
|
|
213
|
+
edit_text=table_edit_text(assumption.value) if structured else value_text,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _glide_path_text(config: GlidePathConfig | None) -> str:
|
|
218
|
+
"""A person's glide-path source as display text, knots included."""
|
|
219
|
+
if config is None:
|
|
220
|
+
return "Default glide path shape assumption"
|
|
221
|
+
knots = "; ".join(
|
|
222
|
+
f"{point.years_to_retirement}y out: {_allocation_text(point.allocation)}"
|
|
223
|
+
for point in config.points
|
|
224
|
+
)
|
|
225
|
+
return f"Custom — {knots}"
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _allocation_text(allocation: AssetAllocation | None) -> str:
|
|
229
|
+
"""A wrapper's asset-allocation source as display text."""
|
|
230
|
+
if allocation is None:
|
|
231
|
+
return "From the glide path"
|
|
232
|
+
return (
|
|
233
|
+
f"Equity {allocation.equity} / bonds {allocation.bonds}"
|
|
234
|
+
f" / cash {allocation.cash}"
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _escalation_text(escalation: AssumptionKey | None) -> str:
|
|
239
|
+
"""A contribution schedule's escalation as display text."""
|
|
240
|
+
if escalation is None:
|
|
241
|
+
return "None (fixed amounts)"
|
|
242
|
+
if escalation is AssumptionKey.EARNINGS_GROWTH_REAL:
|
|
243
|
+
return "Grows with earnings"
|
|
244
|
+
return f"Grows with the {format_assumption_key(escalation.value)} assumption"
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _fees_text(fees: FeeSchedule | None) -> str:
|
|
248
|
+
"""A wrapper's fee source as display text."""
|
|
249
|
+
if fees is None:
|
|
250
|
+
return "Shipped platform and fund fee assumptions"
|
|
251
|
+
return f"Platform {fees.platform.value} / fund {fees.fund.value}"
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _revaluation_text(basis: RevaluationBasis) -> str:
|
|
255
|
+
"""A DB scheme's revaluation basis as display text."""
|
|
256
|
+
if basis.fixed_rate is not None: # FIXED, by the RevaluationBasis invariant
|
|
257
|
+
return f"Fixed {basis.fixed_rate.value} per year"
|
|
258
|
+
if basis.reference is RevaluationReference.CPI:
|
|
259
|
+
if basis.cap is None:
|
|
260
|
+
return "CPI"
|
|
261
|
+
return f"CPI, capped at {basis.cap.value}"
|
|
262
|
+
return "None (frozen in nominal terms)"
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _factors_text(table: FactorTable) -> str:
|
|
266
|
+
"""A DB scheme's early/late factor table as display text."""
|
|
267
|
+
if not table.factors:
|
|
268
|
+
return "None (taken at the normal pension age)"
|
|
269
|
+
return "; ".join(
|
|
270
|
+
f"{age}: {factor}" for age, factor in sorted(table.factors.items())
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _stage_multipliers_text(multipliers: Mapping[LifeStage, Decimal] | None) -> str:
|
|
275
|
+
"""A spending plan's life-stage multipliers as display text."""
|
|
276
|
+
if not multipliers:
|
|
277
|
+
return "None (flat spending)"
|
|
278
|
+
return "; ".join(
|
|
279
|
+
f"{format_value(stage)}: {multiplier}"
|
|
280
|
+
for stage, multiplier in sorted(
|
|
281
|
+
multipliers.items(), key=lambda item: item[0].value
|
|
282
|
+
)
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _person_structure(person: Person, name: str) -> list[StructureRow]:
|
|
287
|
+
"""One person's structural inputs as display rows (issue #70)."""
|
|
288
|
+
residency = str(person.tax_residency)
|
|
289
|
+
return [
|
|
290
|
+
StructureRow(name, "Tax residency", _RESIDENCY_NAMES.get(residency, residency)),
|
|
291
|
+
StructureRow(name, "Glide path", _glide_path_text(person.glide_path)),
|
|
292
|
+
]
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _wrapper_structure(wrapper: Wrapper, name: str) -> list[StructureRow]:
|
|
296
|
+
"""One wrapper's structural inputs as display rows (issue #70)."""
|
|
297
|
+
rows = [StructureRow(name, "Wrapper kind", format_wrapper_kind(wrapper.kind))]
|
|
298
|
+
schedule = wrapper.contributions
|
|
299
|
+
if schedule is not None:
|
|
300
|
+
relief = (
|
|
301
|
+
format_value(schedule.relief_mechanic)
|
|
302
|
+
if schedule.relief_mechanic is not None
|
|
303
|
+
else "No tax relief"
|
|
304
|
+
)
|
|
305
|
+
rows.append(StructureRow(name, "Contribution relief", relief))
|
|
306
|
+
rows.append(
|
|
307
|
+
StructureRow(
|
|
308
|
+
name, "Contribution escalation", _escalation_text(schedule.escalation)
|
|
309
|
+
)
|
|
310
|
+
)
|
|
311
|
+
rows.append(
|
|
312
|
+
StructureRow(name, "Asset allocation", _allocation_text(wrapper.allocation))
|
|
313
|
+
)
|
|
314
|
+
rows.append(StructureRow(name, "Fees", _fees_text(wrapper.fees)))
|
|
315
|
+
return rows
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _db_structure(pension: DBPension, name: str) -> list[StructureRow]:
|
|
319
|
+
"""One DB pension's scheme structure as display rows (issue #70).
|
|
320
|
+
|
|
321
|
+
Membership is structural — active accrual's rate and salary are
|
|
322
|
+
``Fact``-wrapped and appear as provenance rows instead (9.6).
|
|
323
|
+
"""
|
|
324
|
+
membership = (
|
|
325
|
+
"Deferred" if pension.active_membership is None else "Active (accruing)"
|
|
326
|
+
)
|
|
327
|
+
return [
|
|
328
|
+
StructureRow(name, "Membership", membership),
|
|
329
|
+
StructureRow(name, "Statement date", format_date(pension.statement_date)),
|
|
330
|
+
StructureRow(name, "Revaluation", _revaluation_text(pension.revaluation_basis)),
|
|
331
|
+
StructureRow(
|
|
332
|
+
name, "Early/late factors", _factors_text(pension.early_late_factors)
|
|
333
|
+
),
|
|
334
|
+
]
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _annuity_structure(purchase: AnnuityPurchase, name: str) -> list[StructureRow]:
|
|
338
|
+
"""One annuity purchase's structural choices as display rows (issue #70)."""
|
|
339
|
+
return [
|
|
340
|
+
StructureRow(name, "Annuity type", format_value(purchase.annuity_type)),
|
|
341
|
+
StructureRow(name, "Annuity basis", format_value(purchase.basis)),
|
|
342
|
+
]
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _structure_rows(household: Household | None) -> tuple[StructureRow, ...]:
|
|
346
|
+
"""Structural plan inputs, entity-level (planning §5.1, issue #70).
|
|
347
|
+
|
|
348
|
+
These fields drive results but are neither ``Fact`` nor
|
|
349
|
+
``Decision`` wrapped, so they never appear as provenance rows —
|
|
350
|
+
the persisted plan itself is their record, and this section is
|
|
351
|
+
where the UI shows it.
|
|
352
|
+
"""
|
|
353
|
+
if household is None:
|
|
354
|
+
return ()
|
|
355
|
+
names = entity_names(household)
|
|
356
|
+
rows: list[StructureRow] = []
|
|
357
|
+
for person in household.persons:
|
|
358
|
+
rows.extend(_person_structure(person, names[str(person.id)]))
|
|
359
|
+
for wrapper in person.wrappers:
|
|
360
|
+
rows.extend(_wrapper_structure(wrapper, names[str(wrapper.id)]))
|
|
361
|
+
for pension in person.db_pensions:
|
|
362
|
+
rows.extend(_db_structure(pension, names[str(pension.id)]))
|
|
363
|
+
for purchase in person.annuity_purchases:
|
|
364
|
+
rows.extend(_annuity_structure(purchase, names[str(purchase.id)]))
|
|
365
|
+
if household.spending is not None:
|
|
366
|
+
rows.append(
|
|
367
|
+
StructureRow(
|
|
368
|
+
"Household",
|
|
369
|
+
"Spending stages",
|
|
370
|
+
_stage_multipliers_text(household.spending.stage_multipliers),
|
|
371
|
+
)
|
|
372
|
+
)
|
|
373
|
+
for outflow in household.planned_outflows:
|
|
374
|
+
person_id, age = outflow.at_age_of
|
|
375
|
+
rows.append(
|
|
376
|
+
StructureRow(outflow.label, "Due", f"{names[str(person_id)]} at age {age}")
|
|
377
|
+
)
|
|
378
|
+
return tuple(rows)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _assumption_rows(state: PlanState) -> tuple[AssumptionRow, ...]:
|
|
382
|
+
"""Read assumptions in first-read order, then the rest of the catalogue."""
|
|
383
|
+
read = state.result.provenance.assumptions if state.result is not None else ()
|
|
384
|
+
read_keys = {assumption.key for assumption in read}
|
|
385
|
+
has_result = state.result is not None
|
|
386
|
+
|
|
387
|
+
def unread_usage(key: AssumptionKey) -> str:
|
|
388
|
+
if not has_result:
|
|
389
|
+
return _NO_RUN_LABEL
|
|
390
|
+
if key in _REGION_BUILD_KEYS:
|
|
391
|
+
return _REGION_BUILD_LABEL
|
|
392
|
+
return _UNUSED_LABEL
|
|
393
|
+
|
|
394
|
+
rows = [_assumption_row(assumption, _USED_LABEL) for assumption in read]
|
|
395
|
+
rows.extend(
|
|
396
|
+
_assumption_row(state.assumptions.get(key), unread_usage(key))
|
|
397
|
+
for key in sorted(state.assumptions.keys - read_keys, key=str)
|
|
398
|
+
)
|
|
399
|
+
return tuple(rows)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _summary_lines(state: PlanState) -> tuple[str, str]:
|
|
403
|
+
"""The human summary line plus the full run-manifest detail.
|
|
404
|
+
|
|
405
|
+
The manifest string (data-file digests, policy parameters, seed) is
|
|
406
|
+
load-bearing §4.6 provenance but not reading matter, so the summary
|
|
407
|
+
stays human and the exact manifest rides along as ``summary_detail``
|
|
408
|
+
for the shell to reveal on demand (a tooltip in the Qt shell).
|
|
409
|
+
"""
|
|
410
|
+
if state.run_error is not None:
|
|
411
|
+
return f"The projection failed: {state.run_error}", ""
|
|
412
|
+
if state.result is None:
|
|
413
|
+
return (
|
|
414
|
+
"No projection yet: enter your facts and save them. The shipped "
|
|
415
|
+
"default assumptions below apply until you override them."
|
|
416
|
+
), ""
|
|
417
|
+
seed = state.result.provenance.seed
|
|
418
|
+
seed_text = "none (deterministic)" if seed is None else str(seed)
|
|
419
|
+
run_kind = "deterministic run" if seed is None else f"Monte Carlo run, seed {seed}"
|
|
420
|
+
years = len(state.result.snapshots)
|
|
421
|
+
start = format_date(state.result.config.today)
|
|
422
|
+
summary = (
|
|
423
|
+
f"Projected across {years} tax years from {start} ({run_kind}) — the "
|
|
424
|
+
"first and last may be partial. Hover for the run manifest: the "
|
|
425
|
+
"exact rule data and policies behind these numbers."
|
|
426
|
+
)
|
|
427
|
+
version = state.result.provenance.region_data_version
|
|
428
|
+
return summary, f"Run manifest — {version}; seed: {seed_text}"
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def build_inspector_view_model(state: PlanState) -> InspectorViewModel:
|
|
432
|
+
"""Assemble the stated-vs-assumed screen from the session state.
|
|
433
|
+
|
|
434
|
+
Facts and decisions come straight from
|
|
435
|
+
``ProjectionResult.provenance`` (roadmap 8.3 acceptance); the
|
|
436
|
+
assumptions column carries value, source, date recorded, and
|
|
437
|
+
default-vs-overridden status for every row (planning §1).
|
|
438
|
+
"""
|
|
439
|
+
names = entity_names(state.household)
|
|
440
|
+
provenance = state.result.provenance if state.result is not None else None
|
|
441
|
+
facts = tuple(
|
|
442
|
+
FactRow(
|
|
443
|
+
label=display_label(labelled.label, names),
|
|
444
|
+
value=format_value(labelled.fact.value),
|
|
445
|
+
as_of=format_date(labelled.fact.as_of),
|
|
446
|
+
recorded=format_recorded(labelled.fact.recorded_on),
|
|
447
|
+
note=labelled.fact.note or "",
|
|
448
|
+
)
|
|
449
|
+
for labelled in (provenance.facts if provenance is not None else ())
|
|
450
|
+
)
|
|
451
|
+
decisions = tuple(
|
|
452
|
+
DecisionRow(
|
|
453
|
+
label=display_label(labelled.label, names),
|
|
454
|
+
value=format_value(labelled.decision.value),
|
|
455
|
+
recorded=format_recorded(labelled.decision.recorded_on),
|
|
456
|
+
note=labelled.decision.note or "",
|
|
457
|
+
)
|
|
458
|
+
for labelled in (provenance.decisions if provenance is not None else ())
|
|
459
|
+
)
|
|
460
|
+
roll_forwards = tuple(
|
|
461
|
+
RollForwardRow(
|
|
462
|
+
label=display_label(entry.label, names),
|
|
463
|
+
stated=format_money(entry.stated),
|
|
464
|
+
as_of=format_date(entry.as_of),
|
|
465
|
+
months=str(entry.months),
|
|
466
|
+
opening=format_money(entry.opening),
|
|
467
|
+
)
|
|
468
|
+
for entry in (
|
|
469
|
+
provenance.balance_roll_forwards if provenance is not None else ()
|
|
470
|
+
)
|
|
471
|
+
)
|
|
472
|
+
summary, summary_detail = _summary_lines(state)
|
|
473
|
+
return InspectorViewModel(
|
|
474
|
+
title="Stated vs assumed",
|
|
475
|
+
facts_heading="Facts you stated",
|
|
476
|
+
facts_columns=("Fact", "Value", "As of", "Recorded"),
|
|
477
|
+
facts=facts,
|
|
478
|
+
roll_forwards_heading="Stated amounts rolled forward to today",
|
|
479
|
+
roll_forwards_columns=("Fact", "Stated", "As of", "Months", "Value today"),
|
|
480
|
+
roll_forwards=roll_forwards,
|
|
481
|
+
assumptions_heading="Assumptions used",
|
|
482
|
+
assumptions_columns=(
|
|
483
|
+
"Assumption",
|
|
484
|
+
"Value",
|
|
485
|
+
"Default",
|
|
486
|
+
"Status",
|
|
487
|
+
"Used",
|
|
488
|
+
"Source",
|
|
489
|
+
"Recorded",
|
|
490
|
+
),
|
|
491
|
+
assumptions=_assumption_rows(state),
|
|
492
|
+
decisions_heading="Your choices in effect",
|
|
493
|
+
decisions_columns=("Choice", "Value", "Recorded"),
|
|
494
|
+
decisions=decisions,
|
|
495
|
+
structure_heading="Plan structure",
|
|
496
|
+
structure_columns=("Entity", "Setting", "Value"),
|
|
497
|
+
structure=_structure_rows(state.household),
|
|
498
|
+
summary=summary,
|
|
499
|
+
summary_detail=summary_detail,
|
|
500
|
+
override_title="Override assumption",
|
|
501
|
+
override_prompt="New value (blank restores the shipped default):",
|
|
502
|
+
table_override_prompt=(
|
|
503
|
+
"New value — one 'key = value' line per figure, dotted keys for"
|
|
504
|
+
" nested tables (blank restores the shipped default):"
|
|
505
|
+
),
|
|
506
|
+
)
|
glidepath/app/labels.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Shared display text for provenance labels and entity ids (§4.7).
|
|
2
|
+
|
|
3
|
+
The provenance grammar (``person[<id>].field.path``, see
|
|
4
|
+
:func:`~glidepath.core.collect_plan_decisions`) addresses entities by
|
|
5
|
+
stable id. These helpers turn those labels into copy — ids named for
|
|
6
|
+
humans — for every screen that shows labelled facts, decisions, or
|
|
7
|
+
scenario overrides (the inspector and the scenario manager).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from glidepath.app.display import format_wrapper_kind
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from collections.abc import Mapping
|
|
17
|
+
|
|
18
|
+
from glidepath.core import Household
|
|
19
|
+
|
|
20
|
+
_LABEL_PATTERN = re.compile(r"^(?P<kind>[a-z_]+)\[(?P<id>[^\]]+)\]\.(?P<path>.+)$")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def capitalised(text: str) -> str:
|
|
24
|
+
"""The text with just its first letter upper-cased."""
|
|
25
|
+
return text[:1].upper() + text[1:]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def pretty_path(path: str) -> str:
|
|
29
|
+
"""A dotted field path as display text."""
|
|
30
|
+
return " / ".join(segment.replace("_", " ") for segment in path.split("."))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def entity_names(household: Household | None) -> dict[str, str]:
|
|
34
|
+
"""Friendly names for the entity ids provenance labels address."""
|
|
35
|
+
if household is None:
|
|
36
|
+
return {}
|
|
37
|
+
names: dict[str, str] = {}
|
|
38
|
+
for person in household.persons:
|
|
39
|
+
names[str(person.id)] = "You"
|
|
40
|
+
for number, wrapper in enumerate(person.wrappers, start=1):
|
|
41
|
+
kind = format_wrapper_kind(wrapper.kind)
|
|
42
|
+
names[str(wrapper.id)] = f"Wrapper {number} ({kind})"
|
|
43
|
+
for number, pension in enumerate(person.db_pensions, start=1):
|
|
44
|
+
names[str(pension.id)] = f"DB pension {number}"
|
|
45
|
+
for number, purchase in enumerate(person.annuity_purchases, start=1):
|
|
46
|
+
names[str(purchase.id)] = f"Annuity purchase {number}"
|
|
47
|
+
for outflow in household.planned_outflows:
|
|
48
|
+
names[str(outflow.id)] = outflow.label
|
|
49
|
+
return names
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def display_label(label: str, names: Mapping[str, str]) -> str:
|
|
53
|
+
"""A provenance label as display text, entity ids named for humans."""
|
|
54
|
+
match = _LABEL_PATTERN.match(label)
|
|
55
|
+
if match is None:
|
|
56
|
+
return capitalised(pretty_path(label))
|
|
57
|
+
name = names.get(match["id"], capitalised(match["kind"].replace("_", " ")))
|
|
58
|
+
return f"{name} — {pretty_path(match['path'])}"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
__all__ = [
|
|
62
|
+
"capitalised",
|
|
63
|
+
"display_label",
|
|
64
|
+
"entity_names",
|
|
65
|
+
"pretty_path",
|
|
66
|
+
]
|