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
glidepath/app/plan.py ADDED
@@ -0,0 +1,354 @@
1
+ """Plan session state: household, assumptions, projection (§4.7, 8.2/8.3).
2
+
3
+ The shell holds one immutable :class:`PlanState` and replaces it
4
+ through the pure transitions here: capturing a household re-runs the
5
+ projection; overriding an assumption re-stamps it ``USER_OVERRIDE``
6
+ (planning §1: value, source, and date always recorded) and re-runs.
7
+ Run failures are held as messages on the state, never raised at a
8
+ shell. The scenario-editing transitions live in
9
+ :mod:`glidepath.app.scenarios` (roadmap 8.5); every transition here
10
+ keeps the state's scenario runs in step with the base plan they diff
11
+ against.
12
+ """
13
+
14
+ from dataclasses import dataclass, replace
15
+ from decimal import Decimal, InvalidOperation
16
+ from typing import TYPE_CHECKING, Any
17
+
18
+ from glidepath.app.tables import parse_table_text
19
+ from glidepath.core import (
20
+ AnnuityRateTable,
21
+ Assumption,
22
+ AssumptionKey,
23
+ AssumptionSet,
24
+ Household,
25
+ ProjectionResult,
26
+ Provenance,
27
+ RunConfig,
28
+ Scenario,
29
+ StatePensionUprating,
30
+ glide_path_from_shape,
31
+ is_scenario_valid,
32
+ run,
33
+ run_scenarios,
34
+ )
35
+ from glidepath.regions.uk import (
36
+ FutureYearsPolicy,
37
+ default_assumption_set,
38
+ future_years_extension,
39
+ uk_region,
40
+ )
41
+
42
+ if TYPE_CHECKING:
43
+ from collections.abc import Mapping
44
+ from datetime import date, datetime
45
+
46
+ from glidepath.app.drawdown import DrawdownAnswer
47
+ from glidepath.app.retirement import RetirementAnswer
48
+ from glidepath.core import BacktestResult, MonteCarloResult, Region
49
+ from glidepath.regions.uk import AssumptionValue
50
+
51
+ OVERRIDE_SOURCE = "User override (assumptions inspector)"
52
+
53
+ _UNKNOWN_KEY_MESSAGE = "unknown assumption key"
54
+ _INT_OVERRIDE_MESSAGE = "enter a whole number"
55
+ _DECIMAL_OVERRIDE_MESSAGE = "enter a plain number, e.g. 0.05"
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class PlanState:
60
+ """Everything the shell holds between user actions.
61
+
62
+ ``scenario_runs`` holds the named runs behind the comparison
63
+ report — the base first, then every *valid* scenario (an orphaned
64
+ scenario is flagged on the scenarios screen and excluded, §4.3) —
65
+ or ``None`` when there is nothing to compare. ``scenario_run_error``
66
+ carries a scenario run failure as a message, mirroring
67
+ ``run_error``. ``monte_carlo`` holds the explicit Monte Carlo run
68
+ over the same plan (roadmap 9.13), with ``monte_carlo_error``
69
+ mirroring ``run_error``; ``retirement`` holds the explicit "When
70
+ can I retire?" answer (roadmap 9.14), with ``retirement_error``
71
+ alongside; ``drawdown`` holds the explicit "How much can I draw
72
+ down?" answer (roadmap 9.25), with ``drawdown_error`` alongside;
73
+ ``backtest`` holds the explicit historical backtest
74
+ (roadmap 9.18), with ``backtest_error`` alongside. All of them
75
+ reset whenever the state is recomputed through
76
+ :func:`replanned_state`, so a held result can never go stale
77
+ against a changed plan.
78
+
79
+ ``modified`` says whether a plan-mutating transition (facts
80
+ capture, assumption override, scenario edit) has touched the state
81
+ since the last save or load — the shell's unsaved-changes signal
82
+ (issue #136). The slow-run transitions (Monte Carlo, retirement,
83
+ drawdown, backtest) carry it through unchanged: a run reads the
84
+ plan, it does not edit it.
85
+ """
86
+
87
+ assumptions: AssumptionSet
88
+ household: Household | None = None
89
+ result: ProjectionResult | None = None
90
+ run_error: str | None = None
91
+ scenarios: tuple[Scenario, ...] = ()
92
+ scenario_runs: tuple[tuple[str, ProjectionResult], ...] | None = None
93
+ scenario_run_error: str | None = None
94
+ monte_carlo: MonteCarloResult | None = None
95
+ monte_carlo_error: str | None = None
96
+ retirement: RetirementAnswer | None = None
97
+ retirement_error: str | None = None
98
+ drawdown: DrawdownAnswer | None = None
99
+ drawdown_error: str | None = None
100
+ backtest: BacktestResult | None = None
101
+ backtest_error: str | None = None
102
+ modified: bool = False
103
+
104
+
105
+ @dataclass(frozen=True)
106
+ class OverrideOutcome:
107
+ """The state after an override attempt, or why it was rejected."""
108
+
109
+ state: PlanState
110
+ error: str | None = None
111
+
112
+
113
+ def initial_plan_state() -> PlanState:
114
+ """A fresh session: shipped UK defaults, no plan yet."""
115
+ return PlanState(assumptions=default_assumption_set())
116
+
117
+
118
+ def region_for(assumptions: AssumptionSet) -> Region:
119
+ """The region bundle one run's *effective* assumption set implies.
120
+
121
+ Rebuilt per run rather than shared: the UK future-years tax
122
+ extension is derived from assumptions at build time, so a scenario
123
+ overriding those needs its own region (see
124
+ :func:`~glidepath.core.run_scenarios`).
125
+ """
126
+ return uk_region(future_years_extension(assumptions))
127
+
128
+
129
+ def _projected(
130
+ household: Household, assumptions: AssumptionSet, today: date
131
+ ) -> tuple[ProjectionResult | None, str | None]:
132
+ """Run the projection, folding any run failure into a message."""
133
+ try:
134
+ config = RunConfig(today=today)
135
+ result = run(household, assumptions, region_for(assumptions), config)
136
+ except ValueError as exc:
137
+ return None, str(exc)
138
+ return result, None
139
+
140
+
141
+ def _scenario_runs(
142
+ household: Household | None,
143
+ assumptions: AssumptionSet,
144
+ scenarios: tuple[Scenario, ...],
145
+ today: date,
146
+ ) -> tuple[tuple[tuple[str, ProjectionResult], ...] | None, str | None]:
147
+ """Run the base and every valid scenario, folding failures into a message.
148
+
149
+ Orphaned scenarios are left out — the scenarios screen flags them
150
+ (§4.3) — so one broken what-if never blocks the comparison.
151
+ """
152
+ if household is None:
153
+ return None, None
154
+ valid = tuple(
155
+ scenario
156
+ for scenario in scenarios
157
+ if is_scenario_valid(scenario, household, assumptions)
158
+ )
159
+ if not valid:
160
+ return None, None
161
+ try:
162
+ runs = run_scenarios(
163
+ household, assumptions, valid, region_for, RunConfig(today=today)
164
+ )
165
+ except ValueError as exc:
166
+ return None, str(exc)
167
+ return runs, None
168
+
169
+
170
+ def replanned_state(
171
+ assumptions: AssumptionSet,
172
+ household: Household | None,
173
+ scenarios: tuple[Scenario, ...],
174
+ *,
175
+ today: date,
176
+ modified: bool,
177
+ ) -> PlanState:
178
+ """A state recomputed from its inputs: base run plus scenario runs.
179
+
180
+ The one route every transition takes, so the projection and the
181
+ scenario comparison can never drift out of step with the inputs.
182
+ ``modified`` is the unsaved-changes flag the recomputed state
183
+ carries: True from the plan-mutating transitions, False from a
184
+ load, and the incoming state's own flag from the slow-run
185
+ transitions (issue #136).
186
+ """
187
+ if household is None:
188
+ return PlanState(
189
+ assumptions=assumptions, scenarios=scenarios, modified=modified
190
+ )
191
+ result, error = _projected(household, assumptions, today)
192
+ runs, runs_error = _scenario_runs(household, assumptions, scenarios, today)
193
+ return PlanState(
194
+ assumptions=assumptions,
195
+ household=household,
196
+ result=result,
197
+ run_error=error,
198
+ scenarios=scenarios,
199
+ scenario_runs=runs,
200
+ scenario_run_error=runs_error,
201
+ modified=modified,
202
+ )
203
+
204
+
205
+ def state_with_household(
206
+ state: PlanState, household: Household, *, today: date
207
+ ) -> PlanState:
208
+ """The state after capturing ``household`` and re-projecting."""
209
+ return replanned_state(
210
+ state.assumptions, household, state.scenarios, today=today, modified=True
211
+ )
212
+
213
+
214
+ def state_with_scenarios(
215
+ state: PlanState, scenarios: tuple[Scenario, ...], *, today: date
216
+ ) -> PlanState:
217
+ """The state after replacing the scenario list and re-running the diffs.
218
+
219
+ Scenarios never mutate the base plan (§4.3), but the base run is
220
+ recomputed alongside the scenario runs so both always share one
221
+ ``today`` — in a session left open across a date boundary, the
222
+ comparison's base and the displayed projection must not diverge.
223
+ """
224
+ return replanned_state(
225
+ state.assumptions, state.household, scenarios, today=today, modified=True
226
+ )
227
+
228
+
229
+ def state_marked_saved(state: PlanState) -> PlanState:
230
+ """The state with its unsaved-changes flag cleared (issue #136).
231
+
232
+ Shells apply this after a successful save — and after projecting
233
+ the launch example, which is shipped demo data, not user edits.
234
+ """
235
+ changes: dict[str, Any] = {"modified": False} if state.modified else {}
236
+ return replace(state, **changes) if changes else state
237
+
238
+
239
+ def _parsed_override_value(
240
+ base: Assumption[Any], raw: str
241
+ ) -> Decimal | int | dict[str, AssumptionValue]:
242
+ """The typed value ``raw`` denotes for ``base``'s value shape.
243
+
244
+ Every scalar in the shipped catalogue is a ``Decimal`` or an
245
+ ``int``; a structured table parses from ``key = value`` lines and
246
+ is vetted by its policy parser before it can reach the assumption
247
+ set (issue #71).
248
+
249
+ Raises:
250
+ ValueError: If ``raw`` does not parse as the base value's
251
+ shape, or a parsed table fails its policy parser.
252
+ """
253
+ if isinstance(base.value, Decimal):
254
+ try:
255
+ value = Decimal(raw)
256
+ except InvalidOperation:
257
+ raise ValueError(_DECIMAL_OVERRIDE_MESSAGE) from None
258
+ if not value.is_finite():
259
+ raise ValueError(_DECIMAL_OVERRIDE_MESSAGE)
260
+ return value
261
+ if isinstance(base.value, int):
262
+ try:
263
+ return int(raw, 10)
264
+ except ValueError:
265
+ raise ValueError(_INT_OVERRIDE_MESSAGE) from None
266
+ table = parse_table_text(raw)
267
+ check_table_override(base.key, table)
268
+ return table
269
+
270
+
271
+ def check_table_override(
272
+ key: AssumptionKey, table: Mapping[str, AssumptionValue]
273
+ ) -> None:
274
+ """Vet a table override through its policy parser (issue #71).
275
+
276
+ A shape check alone would accept nonsense — any mapping passes —
277
+ so the parser that consumes the table at run time is the contract:
278
+ a table it rejects never enters the state. The parsers raise
279
+ ``ValueError`` subclasses except the glide-shape builder, whose
280
+ ``KeyError``/``TypeError`` are folded into the same channel.
281
+
282
+ Raises:
283
+ ValueError: If the table fails its policy parser.
284
+ """
285
+ try:
286
+ if key is AssumptionKey.GLIDEPATH_DEFAULT_SHAPE:
287
+ glide_path_from_shape(table)
288
+ elif key is AssumptionKey.POLICY_STATE_PENSION_UPRATING:
289
+ StatePensionUprating.from_assumption_value(table)
290
+ elif key is AssumptionKey.POLICY_TAX_FUTURE_YEARS:
291
+ FutureYearsPolicy.from_assumption_value(table)
292
+ elif key is AssumptionKey.ANNUITY_AGE_ADJUSTMENT:
293
+ AnnuityRateTable.from_assumption_value(table)
294
+ except KeyError as exc:
295
+ msg = f"missing required key {exc.args[0]!r}"
296
+ raise ValueError(msg) from exc
297
+ except TypeError as exc:
298
+ raise ValueError(str(exc)) from exc
299
+
300
+
301
+ def _with_assumption(state: PlanState, assumption: Assumption[Any]) -> AssumptionSet:
302
+ """A new set with ``assumption`` in place of its key's entry."""
303
+ return AssumptionSet(
304
+ assumption if key == assumption.key else state.assumptions.get(key)
305
+ for key in state.assumptions.keys
306
+ )
307
+
308
+
309
+ def state_with_override(
310
+ state: PlanState,
311
+ key: str,
312
+ raw_value: str,
313
+ *,
314
+ recorded_on: datetime,
315
+ today: date,
316
+ ) -> OverrideOutcome:
317
+ """Override one assumption in place and re-project (roadmap 8.3).
318
+
319
+ A blank ``raw_value`` restores the shipped default. A value that
320
+ does not parse leaves the state untouched and reports why.
321
+ """
322
+ try:
323
+ assumption_key = AssumptionKey(key)
324
+ except ValueError:
325
+ return OverrideOutcome(state=state, error=_UNKNOWN_KEY_MESSAGE)
326
+ base = state.assumptions.get(assumption_key)
327
+ text = raw_value.strip()
328
+ if not text:
329
+ changed = default_assumption_set().get(assumption_key)
330
+ else:
331
+ try:
332
+ value = _parsed_override_value(base, text)
333
+ except ValueError as exc:
334
+ return OverrideOutcome(state=state, error=str(exc))
335
+ changed = replace(
336
+ base,
337
+ value=value,
338
+ provenance=Provenance.USER_OVERRIDE,
339
+ source=OVERRIDE_SOURCE,
340
+ recorded_on=recorded_on,
341
+ )
342
+ assumptions = _with_assumption(state, changed)
343
+ return OverrideOutcome(
344
+ state=replanned_state(
345
+ assumptions, state.household, state.scenarios, today=today, modified=True
346
+ )
347
+ )
348
+
349
+
350
+ def facts_saved_message(state: PlanState) -> str:
351
+ """The status line after a successful facts capture."""
352
+ if state.run_error is not None:
353
+ return f"Facts saved, but the projection failed: {state.run_error}"
354
+ return "Facts saved and projection run — see the stated-vs-assumed view."