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/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."
|