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,436 @@
1
+ """The "How much can I draw down?" card: transition and panel (9.25).
2
+
3
+ The Phase 9.25 core solver
4
+ (:func:`~glidepath.core.sustainable_income_at_age`) surfaced per
5
+ planning §4.7: a card on the charts screen asking for a retirement age
6
+ (defaulting to the plan's stated retirement-age decision) and
7
+ answering with the highest net annual income, in today's money, the
8
+ plan sustains when retiring at that age — the drawdown dual of the
9
+ "When can I retire?" card (roadmap 9.14). The search runs the plan
10
+ once per probed spending level — an explicit user action like the
11
+ Monte Carlo run (roadmap 9.13), far too slow for every keystroke —
12
+ and its basis follows the screen's run mode: the deterministic
13
+ projection, or "at least N% Monte Carlo success" over the panel's
14
+ seed and path count once a Monte Carlo basis is selected. The
15
+ transition re-anchors the whole state through
16
+ :func:`~glidepath.app.plan.replanned_state`, so a held answer can
17
+ never go stale against a changed plan.
18
+ """
19
+
20
+ from dataclasses import dataclass, replace
21
+ from decimal import Decimal
22
+ from typing import TYPE_CHECKING, Any, Final
23
+
24
+ from glidepath.app.display import format_money, format_percent
25
+ from glidepath.app.montecarlo import (
26
+ MAX_PATHS,
27
+ MONTE_CARLO_PATHS_MESSAGE,
28
+ MONTE_CARLO_SEED_MESSAGE,
29
+ path_pool,
30
+ )
31
+ from glidepath.app.plan import PlanState, region_for, replanned_state
32
+ from glidepath.app.retirement import parsed_percent
33
+ from glidepath.core import (
34
+ AssumptionKey,
35
+ Money,
36
+ RunConfig,
37
+ RunMode,
38
+ SustainableIncomeSearch,
39
+ age_on,
40
+ int_assumption_value,
41
+ sustainable_income_at_age,
42
+ )
43
+
44
+ if TYPE_CHECKING:
45
+ from datetime import date
46
+
47
+ from glidepath.core import AssumptionSet, Household
48
+
49
+ _ONE = Decimal(1)
50
+ _TWO = Decimal(2)
51
+
52
+ DRAWDOWN_HEADING: Final = "How much can I draw down?"
53
+
54
+ DRAWDOWN_AGE_LABEL: Final = "Retirement age"
55
+
56
+ DRAWDOWN_SUCCESS_LABEL: Final = "Success target (%)"
57
+
58
+ DEFAULT_DRAWDOWN_SUCCESS_VALUE: Final = "90"
59
+
60
+ FIND_DRAWDOWN_LABEL: Final = "Find sustainable income"
61
+
62
+ NO_DRAWDOWN_MESSAGE: Final = (
63
+ "No answer yet — choose a retirement age, then press Find sustainable income."
64
+ )
65
+
66
+ DRAWDOWN_NO_PLAN_MESSAGE: Final = (
67
+ "Save facts on the Facts tab before asking how much you can draw down."
68
+ )
69
+
70
+ DRAWDOWN_AGE_MESSAGE: Final = (
71
+ "The retirement age needs a whole number from the current age"
72
+ " to one below the planning horizon age."
73
+ )
74
+
75
+ DRAWDOWN_SUCCESS_MESSAGE: Final = (
76
+ "The success target needs a whole number between 1 and 100."
77
+ )
78
+
79
+ DRAWDOWN_HORIZON_MESSAGE: Final = (
80
+ "No retirement age to test — the planning horizon age is not"
81
+ " beyond the current age."
82
+ )
83
+
84
+ DRAWDOWN_SEARCH_MAXIMUM: Final = Money(Decimal(1_000_000))
85
+ """The income search's upper bracket: £1,000,000 of net annual income.
86
+
87
+ Generous beyond any plan a personal modelling tool holds, so the
88
+ bracket never clips a real answer; the cost of the headroom is only
89
+ the scan-plus-bisection's logarithmic probe count
90
+ (:func:`search_probe_bound`).
91
+ """
92
+
93
+ MAX_DRAWDOWN_PATH_RUNS: Final = 20_000
94
+ """The most Monte Carlo path-projections one search may add up to.
95
+
96
+ The 9.13 per-run path cap bounds one Monte Carlo run; a search
97
+ multiplies its path count by the probe bound, so it carries the same
98
+ aggregate budget as the retirement-age search (roadmap 9.14).
99
+ """
100
+
101
+ DRAWDOWN_BUDGET_MESSAGE: Final = (
102
+ "Lower the path count — this search would project more than"
103
+ f" {MAX_DRAWDOWN_PATH_RUNS:,} Monte Carlo paths across its probed"
104
+ " spending levels."
105
+ )
106
+
107
+ DRAWDOWN_FAILED_PREFIX: Final = "The sustainable-income search failed: "
108
+
109
+ DRAWDOWN_RUNNING_MESSAGE: Final = "Searching for the sustainable income…"
110
+
111
+ DRAWDOWN_STALE_MESSAGE: Final = (
112
+ "Sustainable-income answer discarded — the plan changed while it ran."
113
+ )
114
+
115
+ DRAWDOWN_ANSWER_PREFIX: Final = "Sustainable income: "
116
+
117
+ DRAWDOWN_DETERMINISTIC_BASIS: Final = (
118
+ "Basis: deterministic projection, no unmet need in any period."
119
+ )
120
+
121
+
122
+ def search_probe_bound(search: SustainableIncomeSearch) -> int:
123
+ """The most spending levels one income search can probe.
124
+
125
+ One probe at the maximum, one per interior scan point and one at
126
+ the zero floor, then the bisection's halvings of one scan step
127
+ down to the tolerance — what the Monte Carlo budget check
128
+ multiplies by the path count, mirroring the candidate-age count of
129
+ the retirement-age search (roadmap 9.14).
130
+ """
131
+ probes = 1 + search.scan_steps
132
+ step = search.maximum.amount / Decimal(search.scan_steps)
133
+ while step > search.tolerance.amount:
134
+ step = step / _TWO
135
+ probes += 1
136
+ return probes
137
+
138
+
139
+ @dataclass(frozen=True)
140
+ class DrawdownRequest:
141
+ """One card submission, as the shell captured it (planning §4.7).
142
+
143
+ ``mode`` is the charts screen's run-mode selection — the answer's
144
+ basis. ``seed_text``, ``paths_text``, and ``success_text`` are read
145
+ only under the Monte Carlo mode: the seed and path count come from
146
+ the Monte Carlo panel's own controls, so the bands and the answer
147
+ always describe the same runs.
148
+ """
149
+
150
+ mode: RunMode
151
+ age_text: str
152
+ seed_text: str = ""
153
+ paths_text: str = ""
154
+ success_text: str = ""
155
+
156
+
157
+ @dataclass(frozen=True)
158
+ class DrawdownAnswer:
159
+ """One solved answer with the inputs that produced it (§4.6).
160
+
161
+ ``income`` is the highest sustainable net annual income in today's
162
+ money, or ``None`` when not even zero spending survives the plan's
163
+ outflows. The rest is the manifest side: the retirement age the
164
+ answer assumed, the searched bracket's upper bound, and the basis
165
+ — ``seed``, ``paths``, and ``target_success_rate`` are carried
166
+ only for a Monte Carlo basis.
167
+ """
168
+
169
+ income: Money | None
170
+ age: int
171
+ maximum: Money
172
+ mode: RunMode
173
+ seed: int | None = None
174
+ paths: int | None = None
175
+ target_success_rate: Decimal | None = None
176
+
177
+
178
+ @dataclass(frozen=True)
179
+ class DrawdownPanelViewModel:
180
+ """The "How much can I draw down?" card (roadmap 9.25; §4.7).
181
+
182
+ ``age_value`` echoes the held answer's actual age, or the plan's
183
+ stated retirement-age decision before any run — blank without a
184
+ plan. ``success_value`` echoes likewise or the default.
185
+ ``success_visible`` shows the success-target control only under
186
+ the Monte Carlo mode. ``answer`` is the headline; ``detail`` names
187
+ the age, the searched bracket, and the basis; ``message`` carries
188
+ the no-run or failure copy — blank whenever ``answer`` is
189
+ populated, and vice versa.
190
+ """
191
+
192
+ heading: str
193
+ age_label: str
194
+ age_value: str
195
+ success_label: str
196
+ success_value: str
197
+ success_visible: bool
198
+ run_label: str
199
+ answer: str
200
+ detail: str
201
+ message: str
202
+
203
+
204
+ @dataclass(frozen=True)
205
+ class _SolverInputs:
206
+ """The parsed and derived inputs one search runs on."""
207
+
208
+ age: int
209
+ seed: int | None
210
+ paths: int | None
211
+ target_success_rate: Decimal | None
212
+
213
+
214
+ def state_with_drawdown(
215
+ state: PlanState, request: DrawdownRequest, *, today: date
216
+ ) -> PlanState:
217
+ """The state after solving for the sustainable income (9.25).
218
+
219
+ Anything unusable — no plan, an unparseable age or Monte Carlo
220
+ input, an engine rejection — folds into ``drawdown_error`` on the
221
+ returned state, never raising at a shell (the
222
+ :mod:`glidepath.app.plan` rule). Before the search runs the whole
223
+ state is recomputed at the same ``today`` through
224
+ :func:`~glidepath.app.plan.replanned_state` — the answer and the
225
+ projection it summarises must share one anchor date — which also
226
+ drops any held Monte Carlo result rather than leaving one against
227
+ re-anchored charts. Same inputs (and seed, under the Monte Carlo
228
+ basis) reproduce the same answer (§4.6).
229
+ """
230
+ household = state.household
231
+ if household is None:
232
+ return _with_drawdown_error(state, DRAWDOWN_NO_PLAN_MESSAGE)
233
+ try:
234
+ inputs = _solver_inputs(household, state.assumptions, request, today)
235
+ except ValueError as exc:
236
+ return _with_drawdown_error(state, str(exc))
237
+ base = replanned_state(
238
+ state.assumptions,
239
+ household,
240
+ state.scenarios,
241
+ today=today,
242
+ modified=state.modified,
243
+ )
244
+ try:
245
+ config, search = _config_and_search(inputs, request.mode, today)
246
+ total_paths = search_probe_bound(search) * (inputs.paths or 0)
247
+ with path_pool(total_paths) as parallelism:
248
+ income = sustainable_income_at_age(
249
+ household,
250
+ state.assumptions,
251
+ region_for(state.assumptions),
252
+ config,
253
+ age=inputs.age,
254
+ search=search,
255
+ parallelism=parallelism,
256
+ )
257
+ except Exception as exc:
258
+ # Broad by design, exactly as in state_with_monte_carlo: a
259
+ # process-pool failure escaping here would hold the shell's
260
+ # in-flight guard forever, so every failure folds into the
261
+ # state (§4.7).
262
+ return _with_drawdown_error(base, DRAWDOWN_FAILED_PREFIX + str(exc))
263
+ answer = DrawdownAnswer(
264
+ income=income,
265
+ age=inputs.age,
266
+ maximum=DRAWDOWN_SEARCH_MAXIMUM,
267
+ mode=request.mode,
268
+ seed=inputs.seed,
269
+ paths=inputs.paths,
270
+ target_success_rate=inputs.target_success_rate,
271
+ )
272
+ changes: dict[str, Any] = {"drawdown": answer, "drawdown_error": None}
273
+ return replace(base, **changes) if changes else base
274
+
275
+
276
+ def build_drawdown_panel(state: PlanState, mode: RunMode) -> DrawdownPanelViewModel:
277
+ """The "How much can I draw down?" card for the charts screen (9.25).
278
+
279
+ A held answer keeps showing under either run mode — its basis is
280
+ named in the detail copy, so switching the mode never mislabels
281
+ it; the mode governs only which controls the next search offers.
282
+ """
283
+ answer = state.drawdown
284
+ headline = ""
285
+ detail = ""
286
+ message = ""
287
+ if state.drawdown_error is not None:
288
+ message = state.drawdown_error
289
+ elif answer is None:
290
+ message = NO_DRAWDOWN_MESSAGE
291
+ else:
292
+ headline = _headline(answer)
293
+ detail = _detail(answer)
294
+ return DrawdownPanelViewModel(
295
+ heading=DRAWDOWN_HEADING,
296
+ age_label=DRAWDOWN_AGE_LABEL,
297
+ age_value=_age_echo(answer, state.household),
298
+ success_label=DRAWDOWN_SUCCESS_LABEL,
299
+ success_value=_success_echo(answer),
300
+ success_visible=mode is RunMode.MONTE_CARLO,
301
+ run_label=FIND_DRAWDOWN_LABEL,
302
+ answer=headline,
303
+ detail=detail,
304
+ message=message,
305
+ )
306
+
307
+
308
+ def _headline(answer: DrawdownAnswer) -> str:
309
+ """The card's one-line answer."""
310
+ if answer.income is None:
311
+ return (
312
+ f"No income is sustainable retiring at {answer.age} —"
313
+ " the plan's outflows already exhaust it."
314
+ )
315
+ return f"{DRAWDOWN_ANSWER_PREFIX}{format_money(answer.income)} a year"
316
+
317
+
318
+ def _detail(answer: DrawdownAnswer) -> str:
319
+ """The assumed age, searched bracket, and basis under the headline."""
320
+ target = (
321
+ f"Retiring at age {answer.age} — the highest net annual income"
322
+ " the plan sustains, in today's money, searched up to"
323
+ f" {format_money(answer.maximum)}."
324
+ )
325
+ basis = DRAWDOWN_DETERMINISTIC_BASIS
326
+ if answer.mode is RunMode.MONTE_CARLO and answer.target_success_rate is not None:
327
+ basis = (
328
+ f"Basis: at least {format_percent(answer.target_success_rate)}"
329
+ f" Monte Carlo success over {answer.paths} paths"
330
+ f" (seed {answer.seed})."
331
+ )
332
+ return f"{target}\n{basis}"
333
+
334
+
335
+ def _age_echo(answer: DrawdownAnswer | None, household: Household | None) -> str:
336
+ """The held answer's age, the plan's stated decision, or blank."""
337
+ if answer is not None:
338
+ return str(answer.age)
339
+ if household is not None:
340
+ return str(household.persons[0].target_retirement_age.value)
341
+ return ""
342
+
343
+
344
+ def _success_echo(answer: DrawdownAnswer | None) -> str:
345
+ """The held answer's success target as whole percent, or the default."""
346
+ if answer is None or answer.target_success_rate is None:
347
+ return DEFAULT_DRAWDOWN_SUCCESS_VALUE
348
+ return str(int(answer.target_success_rate * Decimal(100)))
349
+
350
+
351
+ def _solver_inputs(
352
+ household: Household,
353
+ assumptions: AssumptionSet,
354
+ request: DrawdownRequest,
355
+ today: date,
356
+ ) -> _SolverInputs:
357
+ """Parse a card submission into the inputs one search runs on.
358
+
359
+ Raises:
360
+ ValueError: With the user-facing message, on anything unusable
361
+ — a planning horizon the person has already reached, an
362
+ unparseable or out-of-bracket age, an unusable Monte Carlo
363
+ seed, path count, or success target, or a Monte Carlo
364
+ search whose probe bound times paths would exceed the
365
+ search budget.
366
+ """
367
+ person = household.persons[0]
368
+ minimum_age = age_on(person.date_of_birth.value, today)
369
+ planning_age = int_assumption_value(
370
+ assumptions.get(AssumptionKey.HORIZON_PLANNING_AGE)
371
+ )
372
+ maximum_age = planning_age - 1
373
+ if maximum_age < minimum_age:
374
+ raise ValueError(DRAWDOWN_HORIZON_MESSAGE)
375
+ try:
376
+ age = int(request.age_text.strip(), 10)
377
+ except ValueError:
378
+ raise ValueError(DRAWDOWN_AGE_MESSAGE) from None
379
+ if not minimum_age <= age <= maximum_age:
380
+ raise ValueError(DRAWDOWN_AGE_MESSAGE)
381
+ seed: int | None = None
382
+ paths: int | None = None
383
+ success: Decimal | None = None
384
+ if request.mode is RunMode.MONTE_CARLO:
385
+ try:
386
+ seed = int(request.seed_text.strip(), 10)
387
+ except ValueError:
388
+ raise ValueError(MONTE_CARLO_SEED_MESSAGE) from None
389
+ try:
390
+ paths = int(request.paths_text.strip(), 10)
391
+ except ValueError:
392
+ raise ValueError(MONTE_CARLO_PATHS_MESSAGE) from None
393
+ if not 1 <= paths <= MAX_PATHS:
394
+ raise ValueError(MONTE_CARLO_PATHS_MESSAGE)
395
+ probes = search_probe_bound(
396
+ SustainableIncomeSearch(maximum=DRAWDOWN_SEARCH_MAXIMUM)
397
+ )
398
+ if probes * paths > MAX_DRAWDOWN_PATH_RUNS:
399
+ raise ValueError(DRAWDOWN_BUDGET_MESSAGE)
400
+ success = parsed_percent(request.success_text, DRAWDOWN_SUCCESS_MESSAGE)
401
+ return _SolverInputs(
402
+ age=age,
403
+ seed=seed,
404
+ paths=paths,
405
+ target_success_rate=success,
406
+ )
407
+
408
+
409
+ def _config_and_search(
410
+ inputs: _SolverInputs, mode: RunMode, today: date
411
+ ) -> tuple[RunConfig, SustainableIncomeSearch]:
412
+ """The run config and search one parsed submission denotes.
413
+
414
+ Built inside the transition's exception boundary, so a search the
415
+ core rejects folds into ``drawdown_error`` like any other failure
416
+ (the :mod:`glidepath.app.plan` rule).
417
+ """
418
+ if mode is RunMode.MONTE_CARLO:
419
+ return (
420
+ RunConfig(today=today, mode=RunMode.MONTE_CARLO, seed=inputs.seed),
421
+ SustainableIncomeSearch(
422
+ maximum=DRAWDOWN_SEARCH_MAXIMUM,
423
+ paths=inputs.paths or 1,
424
+ target_success_rate=inputs.target_success_rate or _ONE,
425
+ ),
426
+ )
427
+ return (
428
+ RunConfig(today=today),
429
+ SustainableIncomeSearch(maximum=DRAWDOWN_SEARCH_MAXIMUM),
430
+ )
431
+
432
+
433
+ def _with_drawdown_error(state: PlanState, message: str) -> PlanState:
434
+ """The state carrying a search failure, any held answer dropped."""
435
+ changes: dict[str, Any] = {"drawdown": None, "drawdown_error": message}
436
+ return replace(state, **changes) if changes else state
@@ -0,0 +1,66 @@
1
+ """The example plan the facts form opens with (planning §4.9).
2
+
3
+ A first launch shows a filled form and a live projection instead of a
4
+ blank screen, so a new user sees what glidepath produces before typing
5
+ anything. The example is nothing but raw form text: it flows through
6
+ ``parse_facts_form`` exactly like a user submission, is labelled as an
7
+ example in the UI copy, and one click clears it (§4.9 — the facts
8
+ principle survives because nothing here is ever silently treated as
9
+ the user's own statement).
10
+
11
+ The persona extends the §4.5 golden scenario: a 35-year-old on a
12
+ £52,000 salary targeting retirement at 62, with a workplace DC pension
13
+ (net pay, employer-matched, growing with earnings), a stocks & shares
14
+ ISA, a mid-career state pension forecast, and a £24,000 net spending
15
+ need in today's money. The numbers are tuned so the plan holds
16
+ together — the deterministic projection meets every period's need and
17
+ a seeded Monte Carlo run succeeds far more often than not, while still
18
+ leaving visibly failing paths on the fan chart — because the launch
19
+ surface is a demonstration, and a first impression of a plan already
20
+ in ruin reads as a broken app rather than an honest warning.
21
+ """
22
+
23
+ from glidepath.app.forms import FactsFormData
24
+
25
+ _PERSON = {
26
+ "date_of_birth": "1991-06-15",
27
+ "tax_residency": "uk.ruk",
28
+ "employment_income": "52000",
29
+ "target_retirement_age": "62",
30
+ }
31
+ _SPENDING = {"annual_spending_real": "24000"}
32
+ # Example values deliberately avoid every policy-figure literal (the
33
+ # boundary guard greps for those) — e.g. the forecast is below the full
34
+ # new state pension rate, as a real mid-career forecast would be.
35
+ _STATE_PENSION = {"forecast_weekly_amount": "230.25"}
36
+ _WORKPLACE_DC = {
37
+ "kind": "uk.workplace_dc",
38
+ "balance": "48000",
39
+ "employee_contribution": "4200",
40
+ "employer_contribution": "3150",
41
+ "relief_mechanic": "net_pay",
42
+ "escalation": "earnings",
43
+ }
44
+ # The ISA saving is £400/month — deliberately neither the £4,000 LISA
45
+ # allowance nor the £5,000 savings starting-rate limit either side of it.
46
+ _ISA = {
47
+ "kind": "uk.isa",
48
+ "balance": "16500",
49
+ "employee_contribution": "4800",
50
+ }
51
+
52
+
53
+ def example_facts_form_data() -> FactsFormData:
54
+ """The example plan as raw form text (module docstring).
55
+
56
+ Guaranteed parseable: a test submits it through
57
+ ``parse_facts_form`` and projects the result, so the form the app
58
+ opens with can never show an error.
59
+ """
60
+ return FactsFormData(
61
+ person=dict(_PERSON),
62
+ spending=dict(_SPENDING),
63
+ state_pension=dict(_STATE_PENSION),
64
+ wrappers=(dict(_WORKPLACE_DC), dict(_ISA)),
65
+ db_pensions=(),
66
+ )