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/exports.py
ADDED
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
"""Exports: the cash-flow CSV and the printable plan report (roadmap 9.19).
|
|
2
|
+
|
|
3
|
+
Gets the plan out of the app (planning §1, §4.7): a per-year cash-flow
|
|
4
|
+
CSV serialising the active run's report exactly as the charts read it,
|
|
5
|
+
and a print-ready plan report — inputs with their FACTS vs ASSUMPTIONS
|
|
6
|
+
vs DECISIONS provenance, projection results with the three charts, and
|
|
7
|
+
the scenario comparison when scenarios exist. Every export carries the
|
|
8
|
+
§1 disclaimer. Generation is pure app-layer work over the same view
|
|
9
|
+
models the screens render; the shell contributes only the file dialog
|
|
10
|
+
and, for the report, the chart rasteriser and the PDF paint device.
|
|
11
|
+
The report's charts appear in the HTML as ``chart:<index>`` image
|
|
12
|
+
resources — :func:`chart_resource_name` names them — which the shell
|
|
13
|
+
registers against the accompanying :class:`~glidepath.app.ChartSpec`
|
|
14
|
+
list before printing.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import csv
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from html import escape
|
|
20
|
+
from io import StringIO
|
|
21
|
+
from typing import TYPE_CHECKING, Final
|
|
22
|
+
|
|
23
|
+
from glidepath.app.charts import (
|
|
24
|
+
basis_key,
|
|
25
|
+
basis_options,
|
|
26
|
+
build_charts_view_model,
|
|
27
|
+
wrapper_display_labels,
|
|
28
|
+
)
|
|
29
|
+
from glidepath.app.copy import APP_NAME, DISCLAIMER_BODY
|
|
30
|
+
from glidepath.app.display import format_value
|
|
31
|
+
from glidepath.app.files import PLAN_FILE_SUFFIX
|
|
32
|
+
from glidepath.app.inspector import build_inspector_view_model
|
|
33
|
+
from glidepath.app.labels import entity_names
|
|
34
|
+
from glidepath.app.scenarios import BASE_RUN_LABEL, build_scenarios_view_model
|
|
35
|
+
from glidepath.core import ReportBasis, build_report
|
|
36
|
+
|
|
37
|
+
if TYPE_CHECKING:
|
|
38
|
+
from collections.abc import Callable, Iterable
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
|
|
41
|
+
from glidepath.app.charts import ChartSpec
|
|
42
|
+
from glidepath.app.plan import PlanState
|
|
43
|
+
from glidepath.core import (
|
|
44
|
+
Money,
|
|
45
|
+
PeriodReportRow,
|
|
46
|
+
ProjectionResult,
|
|
47
|
+
RunMode,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
EXPORT_CASH_FLOW_LABEL: Final = "Export cash flow (CSV)…"
|
|
51
|
+
|
|
52
|
+
EXPORT_REPORT_LABEL: Final = "Export report (PDF)…"
|
|
53
|
+
|
|
54
|
+
EXPORT_CASH_FLOW_DIALOG_TITLE: Final = "Export cash flow"
|
|
55
|
+
|
|
56
|
+
EXPORT_REPORT_DIALOG_TITLE: Final = "Export report"
|
|
57
|
+
|
|
58
|
+
CASH_FLOW_FILE_SUFFIX: Final = ".csv"
|
|
59
|
+
|
|
60
|
+
CASH_FLOW_FILE_FILTER: Final = "Cash-flow table (*.csv)"
|
|
61
|
+
|
|
62
|
+
REPORT_FILE_SUFFIX: Final = ".pdf"
|
|
63
|
+
|
|
64
|
+
REPORT_FILE_FILTER: Final = "Plan report (*.pdf)"
|
|
65
|
+
|
|
66
|
+
NOTHING_TO_EXPORT_MESSAGE: Final = (
|
|
67
|
+
"Nothing to export yet — save facts on the Facts tab first."
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
REPORT_EXPORT_FAILED_PREFIX: Final = "Could not export the report: "
|
|
71
|
+
|
|
72
|
+
REPORT_NOT_WRITTEN_MESSAGE: Final = (
|
|
73
|
+
f"{REPORT_EXPORT_FAILED_PREFIX}the file could not be written."
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
UNSAVED_PLAN_NAME: Final = "Unsaved plan"
|
|
77
|
+
|
|
78
|
+
_CASH_FLOW_EXPORT_FAILED_PREFIX: Final = "Could not export the cash flow: "
|
|
79
|
+
|
|
80
|
+
_RESULTS_HEADING: Final = "Projection results"
|
|
81
|
+
|
|
82
|
+
_INPUTS_HEADING: Final = "Inputs and provenance"
|
|
83
|
+
|
|
84
|
+
_MONTE_CARLO_HEADING: Final = "Monte Carlo"
|
|
85
|
+
|
|
86
|
+
_METRIC_COLUMNS: Final = ("Metric", "Value")
|
|
87
|
+
|
|
88
|
+
_DETERMINISTIC_RUN_TEXT: Final = "Deterministic"
|
|
89
|
+
|
|
90
|
+
_CHART_RESOURCE_PREFIX: Final = "chart:"
|
|
91
|
+
|
|
92
|
+
_REPORT_CHART_WIDTH: Final = 640
|
|
93
|
+
"""The report's chart display width in document pixels.
|
|
94
|
+
|
|
95
|
+
Comfortably inside an A4 page at the 96 dpi the shell prints at, so a
|
|
96
|
+
chart never overflows into the margin.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
_FLOW_COLUMNS: Final[tuple[tuple[str, Callable[[PeriodReportRow], Money]], ...]] = (
|
|
100
|
+
("Employment income", lambda row: row.employment_income),
|
|
101
|
+
("DB income", lambda row: row.db_income),
|
|
102
|
+
("DB lump sum", lambda row: row.db_lump_sum),
|
|
103
|
+
("Pension lump sum", lambda row: row.pension_lump_sum),
|
|
104
|
+
("State pension income", lambda row: row.state_pension_income),
|
|
105
|
+
("Annuity income", lambda row: row.annuity_income),
|
|
106
|
+
("Annuity lump sum", lambda row: row.annuity_lump_sum),
|
|
107
|
+
("Annuity purchases", lambda row: row.annuity_purchases),
|
|
108
|
+
("Tax due", lambda row: row.tax_due),
|
|
109
|
+
("Spending need", lambda row: row.spending_need),
|
|
110
|
+
("Planned outflows", lambda row: row.planned_outflows),
|
|
111
|
+
("Net withdrawn", lambda row: row.net_withdrawn),
|
|
112
|
+
("Shortfall", lambda row: row.shortfall),
|
|
113
|
+
("Contributions", lambda row: row.contributions),
|
|
114
|
+
("Fees", lambda row: row.fees),
|
|
115
|
+
("Growth", lambda row: row.growth),
|
|
116
|
+
("Growth tax", lambda row: row.growth_tax),
|
|
117
|
+
("AA charge", lambda row: row.aa_charge),
|
|
118
|
+
("Banked", lambda row: row.banked),
|
|
119
|
+
("Withdrawals (gross)", lambda row: row.withdrawals_gross),
|
|
120
|
+
("Closing balance", lambda row: row.closing_balance),
|
|
121
|
+
)
|
|
122
|
+
"""The CSV's money columns, in the report row's field order.
|
|
123
|
+
|
|
124
|
+
Each amount serialises as the report model's exact quantized decimal
|
|
125
|
+
(``1234.56``), so the file round-trips the on-screen numbers without a
|
|
126
|
+
formatting layer in between (the 9.19 acceptance criterion).
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass(frozen=True)
|
|
131
|
+
class ExportOutcome:
|
|
132
|
+
"""Whether an export wrote the file, and the status line saying so."""
|
|
133
|
+
|
|
134
|
+
saved: bool
|
|
135
|
+
message: str
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@dataclass(frozen=True)
|
|
139
|
+
class ReportRequest:
|
|
140
|
+
"""One plan report's presentation choices, as the shell holds them.
|
|
141
|
+
|
|
142
|
+
``basis`` and ``mode`` are the charts screen's selections and drive
|
|
143
|
+
the report's charts and metrics; ``backtest_year`` is the backtest
|
|
144
|
+
card's starting-year picker, so a trajectory on screen is a
|
|
145
|
+
trajectory in the report; ``comparison_basis`` and
|
|
146
|
+
``comparison_metric_key`` are the scenarios screen's and drive the
|
|
147
|
+
comparison table.
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
plan_name: str
|
|
151
|
+
basis: ReportBasis
|
|
152
|
+
mode: RunMode
|
|
153
|
+
comparison_basis: ReportBasis
|
|
154
|
+
comparison_metric_key: str
|
|
155
|
+
backtest_year: str = ""
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@dataclass(frozen=True)
|
|
159
|
+
class PlanReport:
|
|
160
|
+
"""A print-ready plan report: the document and its chart resources.
|
|
161
|
+
|
|
162
|
+
``html`` references each chart as an image resource named by
|
|
163
|
+
:func:`chart_resource_name`; the shell renders ``charts`` over
|
|
164
|
+
``categories`` and registers the images under those names before
|
|
165
|
+
printing.
|
|
166
|
+
"""
|
|
167
|
+
|
|
168
|
+
html: str
|
|
169
|
+
charts: tuple[ChartSpec, ...]
|
|
170
|
+
categories: tuple[str, ...]
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def plan_display_name(path: Path | None) -> str:
|
|
174
|
+
"""The plan's display name: the file's name, or the unsaved copy."""
|
|
175
|
+
if path is None:
|
|
176
|
+
return UNSAVED_PLAN_NAME
|
|
177
|
+
return path.name.removesuffix(PLAN_FILE_SUFFIX)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def chart_resource_name(index: int) -> str:
|
|
181
|
+
"""The resource name the report's HTML gives its ``index``-th chart."""
|
|
182
|
+
return f"{_CHART_RESOURCE_PREFIX}{index}"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _basis_label(basis: ReportBasis) -> str:
|
|
186
|
+
"""The display label of ``basis``, as the basis toggles show it."""
|
|
187
|
+
key = basis_key(basis)
|
|
188
|
+
return next(option.label for option in basis_options() if option.key == key)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _run_basis_text(result: ProjectionResult) -> str:
|
|
192
|
+
"""The run's basis line: deterministic, or Monte Carlo with its seed."""
|
|
193
|
+
seed = result.provenance.seed
|
|
194
|
+
if seed is None:
|
|
195
|
+
return _DETERMINISTIC_RUN_TEXT
|
|
196
|
+
return f"Monte Carlo, seed {seed}"
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def cash_flow_csv(
|
|
200
|
+
state: PlanState, *, basis: ReportBasis, plan_name: str
|
|
201
|
+
) -> str | None:
|
|
202
|
+
"""The active run's per-year cash-flow table as CSV text (9.19).
|
|
203
|
+
|
|
204
|
+
A header block names the plan, the run basis, the scenario (the
|
|
205
|
+
active run is always the base plan), the money basis, and the §1
|
|
206
|
+
disclaimer; the table then carries one row per person per period
|
|
207
|
+
with every amount as the report model's exact decimal, so the file
|
|
208
|
+
round-trips the on-screen numbers. ``None`` without a projection.
|
|
209
|
+
"""
|
|
210
|
+
if state.result is None:
|
|
211
|
+
return None
|
|
212
|
+
report = build_report(state.result, basis)
|
|
213
|
+
names = entity_names(state.household)
|
|
214
|
+
wrappers = wrapper_display_labels(report.rows)
|
|
215
|
+
buffer = StringIO()
|
|
216
|
+
writer = csv.writer(buffer)
|
|
217
|
+
writer.writerow(("Plan", plan_name))
|
|
218
|
+
writer.writerow(("Run", _run_basis_text(state.result)))
|
|
219
|
+
writer.writerow(("Scenario", BASE_RUN_LABEL))
|
|
220
|
+
writer.writerow(("Money basis", _basis_label(basis)))
|
|
221
|
+
writer.writerow(("Disclaimer", DISCLAIMER_BODY))
|
|
222
|
+
writer.writerow(())
|
|
223
|
+
writer.writerow(
|
|
224
|
+
(
|
|
225
|
+
"Period start",
|
|
226
|
+
"Period end",
|
|
227
|
+
"Person",
|
|
228
|
+
"Age at period start",
|
|
229
|
+
"Life stage",
|
|
230
|
+
"Year fraction",
|
|
231
|
+
*(label for label, _amount in _FLOW_COLUMNS),
|
|
232
|
+
*(f"Closing balance — {label}" for label in wrappers.values()),
|
|
233
|
+
)
|
|
234
|
+
)
|
|
235
|
+
for row in report.rows:
|
|
236
|
+
balances = {
|
|
237
|
+
entry.wrapper_id: entry.closing_balance for entry in row.wrapper_balances
|
|
238
|
+
}
|
|
239
|
+
writer.writerow(
|
|
240
|
+
(
|
|
241
|
+
row.period.start.isoformat(),
|
|
242
|
+
row.period.end.isoformat(),
|
|
243
|
+
names.get(str(row.person_id), str(row.person_id)),
|
|
244
|
+
str(row.age_at_period_start),
|
|
245
|
+
format_value(row.stage),
|
|
246
|
+
str(row.year_fraction),
|
|
247
|
+
*(str(amount(row).amount) for _label, amount in _FLOW_COLUMNS),
|
|
248
|
+
*(
|
|
249
|
+
str(balances[wrapper_id].amount) if wrapper_id in balances else ""
|
|
250
|
+
for wrapper_id in wrappers
|
|
251
|
+
),
|
|
252
|
+
)
|
|
253
|
+
)
|
|
254
|
+
return buffer.getvalue()
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def export_cash_flow_csv(
|
|
258
|
+
state: PlanState, path: Path, *, basis: ReportBasis, plan_name: str
|
|
259
|
+
) -> ExportOutcome:
|
|
260
|
+
"""Write the cash-flow CSV to ``path``, reporting the outcome.
|
|
261
|
+
|
|
262
|
+
Follows the save transition's rule (§4.7): every failure folds
|
|
263
|
+
into a status message, never an exception at a shell.
|
|
264
|
+
"""
|
|
265
|
+
text = cash_flow_csv(state, basis=basis, plan_name=plan_name)
|
|
266
|
+
if text is None:
|
|
267
|
+
return ExportOutcome(saved=False, message=NOTHING_TO_EXPORT_MESSAGE)
|
|
268
|
+
try:
|
|
269
|
+
path.write_text(text, encoding="utf-8", newline="")
|
|
270
|
+
except OSError as exc:
|
|
271
|
+
return ExportOutcome(
|
|
272
|
+
saved=False, message=f"{_CASH_FLOW_EXPORT_FAILED_PREFIX}{exc}"
|
|
273
|
+
)
|
|
274
|
+
return ExportOutcome(saved=True, message=f"Cash flow exported to {path}.")
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def report_exported_message(path: Path) -> str:
|
|
278
|
+
"""The status line for a report the shell finished writing."""
|
|
279
|
+
return f"Report exported to {path}."
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _cell(text: str) -> str:
|
|
283
|
+
"""One escaped table cell; multi-line values keep their lines."""
|
|
284
|
+
return escape(text).replace("\n", "<br>")
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _html_table(columns: tuple[str, ...], rows: Iterable[tuple[str, ...]]) -> str:
|
|
288
|
+
"""Column headers and rows as an escaped rich-text table."""
|
|
289
|
+
header = "".join(f"<th>{escape(column)}</th>" for column in columns)
|
|
290
|
+
body = "".join(
|
|
291
|
+
"<tr>" + "".join(f"<td>{_cell(cell)}</td>" for cell in row) + "</tr>"
|
|
292
|
+
for row in rows
|
|
293
|
+
)
|
|
294
|
+
return (
|
|
295
|
+
'<table border="1" cellspacing="0" cellpadding="4">'
|
|
296
|
+
f"<tr>{header}</tr>{body}</table>"
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _inputs_sections(state: PlanState) -> list[str]:
|
|
301
|
+
"""The report's inputs: facts, assumptions, decisions, structure (§1)."""
|
|
302
|
+
inspector = build_inspector_view_model(state)
|
|
303
|
+
parts = [f"<h2>{escape(_INPUTS_HEADING)}</h2>"]
|
|
304
|
+
parts.append(f"<h3>{escape(inspector.facts_heading)}</h3>")
|
|
305
|
+
parts.append(
|
|
306
|
+
_html_table(
|
|
307
|
+
inspector.facts_columns,
|
|
308
|
+
(
|
|
309
|
+
(row.label, row.value, row.as_of, row.recorded)
|
|
310
|
+
for row in inspector.facts
|
|
311
|
+
),
|
|
312
|
+
)
|
|
313
|
+
)
|
|
314
|
+
if inspector.roll_forwards:
|
|
315
|
+
parts.append(f"<h3>{escape(inspector.roll_forwards_heading)}</h3>")
|
|
316
|
+
parts.append(
|
|
317
|
+
_html_table(
|
|
318
|
+
inspector.roll_forwards_columns,
|
|
319
|
+
(
|
|
320
|
+
(row.label, row.stated, row.as_of, row.months, row.opening)
|
|
321
|
+
for row in inspector.roll_forwards
|
|
322
|
+
),
|
|
323
|
+
)
|
|
324
|
+
)
|
|
325
|
+
# The screen's value/default columns truncate long tables; the
|
|
326
|
+
# report is the audit surface, so it prints the complete forms.
|
|
327
|
+
parts.append(f"<h3>{escape(inspector.assumptions_heading)}</h3>")
|
|
328
|
+
parts.append(
|
|
329
|
+
_html_table(
|
|
330
|
+
inspector.assumptions_columns,
|
|
331
|
+
(
|
|
332
|
+
(
|
|
333
|
+
row.label,
|
|
334
|
+
row.edit_text if row.structured else row.value,
|
|
335
|
+
row.default_edit_text,
|
|
336
|
+
row.status,
|
|
337
|
+
row.usage,
|
|
338
|
+
row.source,
|
|
339
|
+
row.recorded,
|
|
340
|
+
)
|
|
341
|
+
for row in inspector.assumptions
|
|
342
|
+
),
|
|
343
|
+
)
|
|
344
|
+
)
|
|
345
|
+
parts.append(f"<h3>{escape(inspector.decisions_heading)}</h3>")
|
|
346
|
+
parts.append(
|
|
347
|
+
_html_table(
|
|
348
|
+
inspector.decisions_columns,
|
|
349
|
+
((row.label, row.value, row.recorded) for row in inspector.decisions),
|
|
350
|
+
)
|
|
351
|
+
)
|
|
352
|
+
if inspector.structure:
|
|
353
|
+
parts.append(f"<h3>{escape(inspector.structure_heading)}</h3>")
|
|
354
|
+
parts.append(
|
|
355
|
+
_html_table(
|
|
356
|
+
inspector.structure_columns,
|
|
357
|
+
((row.entity, row.setting, row.value) for row in inspector.structure),
|
|
358
|
+
)
|
|
359
|
+
)
|
|
360
|
+
return parts
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _results_sections(state: PlanState, request: ReportRequest) -> list[str]:
|
|
364
|
+
"""The report's results: charts, Monte Carlo, backtest, retirement."""
|
|
365
|
+
charts = build_charts_view_model(
|
|
366
|
+
state, request.basis, request.mode, backtest_year=request.backtest_year
|
|
367
|
+
)
|
|
368
|
+
parts = [
|
|
369
|
+
f"<h2>{escape(_RESULTS_HEADING)}</h2>",
|
|
370
|
+
f"<p>Money basis: {escape(_basis_label(request.basis))}</p>",
|
|
371
|
+
]
|
|
372
|
+
if charts.allocation_note:
|
|
373
|
+
parts.append(f"<p>{escape(charts.allocation_note)}</p>")
|
|
374
|
+
for index, chart in enumerate(charts.charts):
|
|
375
|
+
parts.append(f"<h3>{escape(chart.title)}</h3>")
|
|
376
|
+
parts.append(
|
|
377
|
+
f'<img src="{chart_resource_name(index)}" width="{_REPORT_CHART_WIDTH}">'
|
|
378
|
+
)
|
|
379
|
+
panel = charts.monte_carlo
|
|
380
|
+
if panel.metrics:
|
|
381
|
+
# The seed and path count are the run's §4.6 reproducibility
|
|
382
|
+
# manifest — without them the report's metrics are unattributable.
|
|
383
|
+
parts.append(f"<h3>{escape(_MONTE_CARLO_HEADING)}</h3>")
|
|
384
|
+
parts.append(
|
|
385
|
+
_html_table(
|
|
386
|
+
_METRIC_COLUMNS,
|
|
387
|
+
(
|
|
388
|
+
(panel.seed_label, panel.seed_value),
|
|
389
|
+
(panel.paths_label, panel.paths_value),
|
|
390
|
+
*((row.label, row.value) for row in panel.metrics),
|
|
391
|
+
),
|
|
392
|
+
)
|
|
393
|
+
)
|
|
394
|
+
backtest = charts.backtest
|
|
395
|
+
if backtest.metrics:
|
|
396
|
+
parts.append(f"<h3>{escape(backtest.heading)}</h3>")
|
|
397
|
+
parts.append(
|
|
398
|
+
_html_table(
|
|
399
|
+
_METRIC_COLUMNS,
|
|
400
|
+
tuple((row.label, row.value) for row in backtest.metrics),
|
|
401
|
+
)
|
|
402
|
+
)
|
|
403
|
+
retirement = charts.retirement
|
|
404
|
+
if retirement.answer:
|
|
405
|
+
parts.append(f"<h3>{escape(retirement.heading)}</h3>")
|
|
406
|
+
parts.append(f"<p>{escape(retirement.answer)}</p>")
|
|
407
|
+
if retirement.detail:
|
|
408
|
+
parts.append(f"<p>{escape(retirement.detail)}</p>")
|
|
409
|
+
return parts
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _comparison_sections(state: PlanState, request: ReportRequest) -> list[str]:
|
|
413
|
+
"""The scenario comparison table, when there is one to print."""
|
|
414
|
+
scenarios = build_scenarios_view_model(
|
|
415
|
+
state,
|
|
416
|
+
basis=request.comparison_basis,
|
|
417
|
+
metric_key=request.comparison_metric_key,
|
|
418
|
+
)
|
|
419
|
+
if not scenarios.comparison_rows:
|
|
420
|
+
return []
|
|
421
|
+
metric_label = next(
|
|
422
|
+
option.label
|
|
423
|
+
for option in scenarios.metric_options
|
|
424
|
+
if option.key == scenarios.selected_metric_key
|
|
425
|
+
)
|
|
426
|
+
return [
|
|
427
|
+
f"<h2>{escape(scenarios.comparison_heading)}</h2>",
|
|
428
|
+
(
|
|
429
|
+
f"<p>{escape(metric_label)}"
|
|
430
|
+
f" ({escape(_basis_label(request.comparison_basis))})</p>"
|
|
431
|
+
),
|
|
432
|
+
_html_table(scenarios.comparison_columns, scenarios.comparison_rows),
|
|
433
|
+
]
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def build_plan_report(state: PlanState, request: ReportRequest) -> PlanReport | None:
|
|
437
|
+
"""The full printable plan report, or ``None`` without a projection.
|
|
438
|
+
|
|
439
|
+
Inputs with their provenance first, then the projection results
|
|
440
|
+
with the three charts (plus Monte Carlo metrics, backtest metrics,
|
|
441
|
+
and the retirement answer when held), then the scenario comparison
|
|
442
|
+
when scenarios exist — the disclaimer leads the document (§1).
|
|
443
|
+
"""
|
|
444
|
+
if state.result is None:
|
|
445
|
+
return None
|
|
446
|
+
charts = build_charts_view_model(
|
|
447
|
+
state, request.basis, request.mode, backtest_year=request.backtest_year
|
|
448
|
+
)
|
|
449
|
+
inspector_summary = build_inspector_view_model(state)
|
|
450
|
+
parts = [
|
|
451
|
+
f"<h1>{escape(APP_NAME)} plan report — {escape(request.plan_name)}</h1>",
|
|
452
|
+
f"<p><i>{escape(DISCLAIMER_BODY)}</i></p>",
|
|
453
|
+
f"<p>{escape(inspector_summary.summary)}</p>",
|
|
454
|
+
]
|
|
455
|
+
if inspector_summary.summary_detail:
|
|
456
|
+
parts.append(f"<p>{escape(inspector_summary.summary_detail)}</p>")
|
|
457
|
+
parts.extend(_inputs_sections(state))
|
|
458
|
+
parts.extend(_results_sections(state, request))
|
|
459
|
+
parts.extend(_comparison_sections(state, request))
|
|
460
|
+
return PlanReport(
|
|
461
|
+
html="".join(parts), charts=charts.charts, categories=charts.categories
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
__all__ = [
|
|
466
|
+
"CASH_FLOW_FILE_FILTER",
|
|
467
|
+
"CASH_FLOW_FILE_SUFFIX",
|
|
468
|
+
"EXPORT_CASH_FLOW_DIALOG_TITLE",
|
|
469
|
+
"EXPORT_CASH_FLOW_LABEL",
|
|
470
|
+
"EXPORT_REPORT_DIALOG_TITLE",
|
|
471
|
+
"EXPORT_REPORT_LABEL",
|
|
472
|
+
"NOTHING_TO_EXPORT_MESSAGE",
|
|
473
|
+
"REPORT_EXPORT_FAILED_PREFIX",
|
|
474
|
+
"REPORT_FILE_FILTER",
|
|
475
|
+
"REPORT_FILE_SUFFIX",
|
|
476
|
+
"REPORT_NOT_WRITTEN_MESSAGE",
|
|
477
|
+
"UNSAVED_PLAN_NAME",
|
|
478
|
+
"ExportOutcome",
|
|
479
|
+
"PlanReport",
|
|
480
|
+
"ReportRequest",
|
|
481
|
+
"build_plan_report",
|
|
482
|
+
"cash_flow_csv",
|
|
483
|
+
"chart_resource_name",
|
|
484
|
+
"export_cash_flow_csv",
|
|
485
|
+
"plan_display_name",
|
|
486
|
+
"report_exported_message",
|
|
487
|
+
]
|