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/files.py ADDED
@@ -0,0 +1,249 @@
1
+ """Plan file save and load for shells (planning §4.5, §4.7).
2
+
3
+ The app-layer face of :mod:`glidepath.persistence`: the File menu's
4
+ copy, the session-state ↔ plan-document conversions, and save/load
5
+ transitions that fold every failure into a status message instead of
6
+ raising at a shell. The document stores the household, the scenarios,
7
+ and the user's assumption *overrides*; shipped defaults re-resolve on
8
+ every load, and a load records nothing back to disk — plans live
9
+ wherever the user chooses (§4.5).
10
+ """
11
+
12
+ from collections.abc import Mapping
13
+ from dataclasses import dataclass
14
+ from typing import TYPE_CHECKING, Final
15
+
16
+ from glidepath.app.forms import form_cannot_represent
17
+ from glidepath.app.plan import (
18
+ PlanState,
19
+ check_table_override,
20
+ region_for,
21
+ replanned_state,
22
+ )
23
+ from glidepath.core import AssumptionTarget
24
+ from glidepath.persistence import (
25
+ PersistenceError,
26
+ PlanDocument,
27
+ assumption_overrides_from,
28
+ load_plan,
29
+ resolve_assumptions,
30
+ save_plan,
31
+ )
32
+ from glidepath.regions.uk import default_assumption_set
33
+
34
+ if TYPE_CHECKING:
35
+ from datetime import date
36
+ from pathlib import Path
37
+
38
+ from glidepath.core import AssumptionKey
39
+
40
+ PLAN_REGION: Final = "uk"
41
+
42
+ PLAN_FILE_SUFFIX: Final = ".glidepath.json"
43
+
44
+ PLAN_FILE_FILTER: Final = "glidepath plan (*.glidepath.json)"
45
+
46
+ FILE_MENU_LABEL: Final = "File"
47
+
48
+ OPEN_PLAN_LABEL: Final = "Open plan…"
49
+
50
+ SAVE_PLAN_LABEL: Final = "Save plan"
51
+
52
+ SAVE_PLAN_AS_LABEL: Final = "Save plan as…"
53
+
54
+ QUIT_LABEL: Final = "Quit"
55
+
56
+ OPEN_DIALOG_TITLE: Final = "Open plan"
57
+
58
+ SAVE_DIALOG_TITLE: Final = "Save plan"
59
+
60
+ UNSAVED_CHANGES_TITLE: Final = "Unsaved changes"
61
+
62
+ UNSAVED_CHANGES_PROMPT: Final = (
63
+ "This plan has unsaved changes. Save them before closing?"
64
+ )
65
+
66
+ NOTHING_TO_SAVE_MESSAGE: Final = (
67
+ "Nothing to save yet — save facts on the Facts tab first."
68
+ )
69
+
70
+ _SAVE_FAILED_PREFIX: Final = "Could not save the plan: "
71
+
72
+ _OPEN_FAILED_PREFIX: Final = "Could not open the plan: "
73
+
74
+ _WRONG_REGION_MESSAGE: Final = "this plan's region {region!r} is not supported"
75
+
76
+ _UNREPRESENTABLE_MESSAGE: Final = (
77
+ "this plan holds {reason}, which the facts form cannot edit yet — "
78
+ "opening it would lose that data on the next save"
79
+ )
80
+
81
+ _DEFAULTS_MOVED_NOTE: Final = (
82
+ "Note: the shipped default assumptions have changed since this plan "
83
+ "was last saved — review the stated-vs-assumed view."
84
+ )
85
+
86
+
87
+ @dataclass(frozen=True)
88
+ class SaveOutcome:
89
+ """Whether a save wrote the file, and the status line saying so."""
90
+
91
+ saved: bool
92
+ message: str
93
+
94
+
95
+ @dataclass(frozen=True)
96
+ class LoadOutcome:
97
+ """The loaded session state, or the message explaining the failure.
98
+
99
+ ``message`` always carries the status line to show — success and
100
+ failure alike.
101
+ """
102
+
103
+ state: PlanState | None
104
+ message: str
105
+
106
+
107
+ def _shipped_data_version() -> str:
108
+ """The shipped region data's content version, defaults applied.
109
+
110
+ Computed from the shipped defaults rather than the session's
111
+ effective assumptions, so the recorded version moves exactly when
112
+ the shipped data (or a shipped default policy) moves — never when
113
+ the user overrides an assumption.
114
+ """
115
+ return region_for(default_assumption_set()).data_version
116
+
117
+
118
+ def document_from_state(state: PlanState) -> PlanDocument | None:
119
+ """The session as a storable plan document; ``None`` without a plan."""
120
+ if state.household is None:
121
+ return None
122
+ return PlanDocument(
123
+ region=PLAN_REGION,
124
+ assumptions_resolved_against=_shipped_data_version(),
125
+ household=state.household,
126
+ assumption_overrides=assumption_overrides_from(state.assumptions),
127
+ scenarios=state.scenarios,
128
+ )
129
+
130
+
131
+ def has_unsaved_changes(state: PlanState) -> bool:
132
+ """Whether closing now would discard plan edits (issue #136).
133
+
134
+ True only when a plan-mutating transition has touched the state
135
+ since the last save or load *and* there is a plan a save could
136
+ write — an assumption overridden before any facts exist has no
137
+ document to preserve, so a prompt would offer a save that does
138
+ nothing.
139
+ """
140
+ return state.modified and state.household is not None
141
+
142
+
143
+ def save_plan_state(state: PlanState, path: Path) -> SaveOutcome:
144
+ """Write the session's plan to ``path``, reporting the outcome."""
145
+ document = document_from_state(state)
146
+ if document is None:
147
+ return SaveOutcome(saved=False, message=NOTHING_TO_SAVE_MESSAGE)
148
+ try:
149
+ save_plan(document, path)
150
+ except (PersistenceError, OSError) as exc:
151
+ return SaveOutcome(saved=False, message=f"{_SAVE_FAILED_PREFIX}{exc}")
152
+ return SaveOutcome(saved=True, message=f"Plan saved to {path}.")
153
+
154
+
155
+ def _table_error(key: AssumptionKey, value: object) -> str | None:
156
+ """The policy parser's objection to a stored table value, if any."""
157
+ if not isinstance(value, Mapping):
158
+ return None
159
+ try:
160
+ check_table_override(key, value)
161
+ except ValueError as exc:
162
+ return f"override on {key.value!r}: {exc}"
163
+ return None
164
+
165
+
166
+ def _table_override_error(document: PlanDocument) -> str | None:
167
+ """The first stored table override its policy parser rejects.
168
+
169
+ ``resolve_assumptions`` checks only broad value shape (mapping vs
170
+ scalar); a structurally defective table would otherwise decode
171
+ cleanly and raise mid-run, past the load's failure boundary. The
172
+ same policy parsers the editors use are the contract here (issue
173
+ #71), for the base overrides and every scenario's alike.
174
+ """
175
+ for override in document.assumption_overrides:
176
+ error = _table_error(override.key, override.value)
177
+ if error is not None:
178
+ return error
179
+ for scenario in document.scenarios:
180
+ for entry in scenario.overrides:
181
+ target = entry.target
182
+ if not isinstance(target, AssumptionTarget):
183
+ continue
184
+ error = _table_error(target.key, entry.value)
185
+ if error is not None:
186
+ return f"scenario {scenario.name!r} {error}"
187
+ return None
188
+
189
+
190
+ def load_plan_state(path: Path, *, today: date) -> LoadOutcome:
191
+ """Read the plan at ``path`` into a freshly projected session state.
192
+
193
+ Assumptions re-resolve against the current shipped defaults with
194
+ the stored overrides on top (§4.5); when the shipped data moved
195
+ since the file was saved, the status message says so rather than
196
+ silently re-pricing the plan. A run failure inside an otherwise
197
+ loadable plan still loads — the state carries the run error, and
198
+ the result panes report it exactly as they do after a facts edit.
199
+ A plan the facts form cannot faithfully edit is refused outright:
200
+ opening it would silently discard the extra data on the next save.
201
+ """
202
+ try:
203
+ document = load_plan(path)
204
+ except (PersistenceError, OSError) as exc:
205
+ return LoadOutcome(state=None, message=f"{_OPEN_FAILED_PREFIX}{exc}")
206
+ if document.region != PLAN_REGION:
207
+ detail = _WRONG_REGION_MESSAGE.format(region=document.region)
208
+ return LoadOutcome(state=None, message=f"{_OPEN_FAILED_PREFIX}{detail}")
209
+ reason = form_cannot_represent(document.household)
210
+ if reason is not None:
211
+ detail = _UNREPRESENTABLE_MESSAGE.format(reason=reason)
212
+ return LoadOutcome(state=None, message=f"{_OPEN_FAILED_PREFIX}{detail}")
213
+ table_error = _table_override_error(document)
214
+ if table_error is not None:
215
+ return LoadOutcome(state=None, message=f"{_OPEN_FAILED_PREFIX}{table_error}")
216
+ try:
217
+ assumptions = resolve_assumptions(document, default_assumption_set())
218
+ except PersistenceError as exc:
219
+ return LoadOutcome(state=None, message=f"{_OPEN_FAILED_PREFIX}{exc}")
220
+ state = replanned_state(
221
+ assumptions, document.household, document.scenarios, today=today, modified=False
222
+ )
223
+ message = f"Plan loaded from {path}."
224
+ if document.assumptions_resolved_against != _shipped_data_version():
225
+ message = f"{message} {_DEFAULTS_MOVED_NOTE}"
226
+ return LoadOutcome(state=state, message=message)
227
+
228
+
229
+ __all__ = [
230
+ "FILE_MENU_LABEL",
231
+ "NOTHING_TO_SAVE_MESSAGE",
232
+ "OPEN_DIALOG_TITLE",
233
+ "OPEN_PLAN_LABEL",
234
+ "PLAN_FILE_FILTER",
235
+ "PLAN_FILE_SUFFIX",
236
+ "PLAN_REGION",
237
+ "QUIT_LABEL",
238
+ "SAVE_DIALOG_TITLE",
239
+ "SAVE_PLAN_AS_LABEL",
240
+ "SAVE_PLAN_LABEL",
241
+ "UNSAVED_CHANGES_PROMPT",
242
+ "UNSAVED_CHANGES_TITLE",
243
+ "LoadOutcome",
244
+ "SaveOutcome",
245
+ "document_from_state",
246
+ "has_unsaved_changes",
247
+ "load_plan_state",
248
+ "save_plan_state",
249
+ ]
@@ -0,0 +1,114 @@
1
+ """Local per-user settings: disclaimer state and the last plan (§1, §4.7).
2
+
3
+ A tiny local JSON file records the disclaimer acknowledgement date and
4
+ the path of the last plan file saved or opened, so the next launch can
5
+ skip the first-run screen and reopen the user's plan. Anything
6
+ unreadable — missing file, bad JSON, wrong shape, a schema version
7
+ this reader does not know — degrades to an empty state, so the
8
+ disclaimer is shown again (and no plan auto-opens) rather than
9
+ guessing. The state lives in the platform's per-user configuration
10
+ directory; shells pass ``default_state_path()`` (or a substitute) in,
11
+ so this module never touches Qt or a display.
12
+ """
13
+
14
+ import json
15
+ import os
16
+ import sys
17
+ from dataclasses import dataclass
18
+ from datetime import date
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ _STATE_SCHEMA_VERSION = 1
23
+ _SCHEMA_KEY = "schema"
24
+ _ACKNOWLEDGED_KEY = "disclaimer_acknowledged_on"
25
+ _LAST_PLAN_KEY = "last_plan_path"
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class FirstRunState:
30
+ """What the local state file says about previous runs."""
31
+
32
+ disclaimer_acknowledged_on: date | None
33
+ last_plan_path: Path | None = None
34
+
35
+
36
+ _EMPTY_STATE = FirstRunState(disclaimer_acknowledged_on=None)
37
+
38
+
39
+ def default_state_path() -> Path:
40
+ """Per-user settings file location, following platform convention."""
41
+ if sys.platform == "win32":
42
+ base = Path(os.environ.get("APPDATA") or Path.home() / "AppData" / "Roaming")
43
+ else:
44
+ base = Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config")
45
+ return base / "glidepath" / "settings.json"
46
+
47
+
48
+ def _acknowledged_from(raw: dict[str, Any]) -> date | None:
49
+ """The recorded acknowledgement date; ``None`` on any defect."""
50
+ try:
51
+ return date.fromisoformat(raw[_ACKNOWLEDGED_KEY])
52
+ except ValueError, KeyError, TypeError:
53
+ return None
54
+
55
+
56
+ def _last_plan_from(raw: dict[str, Any]) -> Path | None:
57
+ """The recorded last plan path; ``None`` on any defect."""
58
+ text = raw.get(_LAST_PLAN_KEY)
59
+ if not isinstance(text, str) or not text:
60
+ return None
61
+ return Path(text)
62
+
63
+
64
+ def load_state(path: Path) -> FirstRunState:
65
+ """Read the local state, degrading to an empty state on any defect.
66
+
67
+ Each field degrades independently: a defective acknowledgement
68
+ date never discards a readable last-plan path, and vice versa.
69
+ """
70
+ try:
71
+ raw = json.loads(path.read_text(encoding="utf-8"))
72
+ if raw[_SCHEMA_KEY] != _STATE_SCHEMA_VERSION:
73
+ return _EMPTY_STATE
74
+ except OSError, ValueError, KeyError, TypeError:
75
+ return _EMPTY_STATE
76
+ return FirstRunState(
77
+ disclaimer_acknowledged_on=_acknowledged_from(raw),
78
+ last_plan_path=_last_plan_from(raw),
79
+ )
80
+
81
+
82
+ def _write_state(path: Path, state: FirstRunState) -> None:
83
+ """Persist the whole state, omitting unset fields."""
84
+ payload: dict[str, Any] = {_SCHEMA_KEY: _STATE_SCHEMA_VERSION}
85
+ if state.disclaimer_acknowledged_on is not None:
86
+ payload[_ACKNOWLEDGED_KEY] = state.disclaimer_acknowledged_on.isoformat()
87
+ if state.last_plan_path is not None:
88
+ payload[_LAST_PLAN_KEY] = str(state.last_plan_path)
89
+ path.parent.mkdir(parents=True, exist_ok=True)
90
+ path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
91
+
92
+
93
+ def record_disclaimer_acknowledged(path: Path, acknowledged_on: date) -> None:
94
+ """Persist the acknowledgement so later runs skip the first-run screen."""
95
+ state = load_state(path)
96
+ _write_state(
97
+ path,
98
+ FirstRunState(
99
+ disclaimer_acknowledged_on=acknowledged_on,
100
+ last_plan_path=state.last_plan_path,
101
+ ),
102
+ )
103
+
104
+
105
+ def record_last_plan_path(path: Path, plan_path: Path) -> None:
106
+ """Persist the plan file just saved or opened, for the next launch."""
107
+ state = load_state(path)
108
+ _write_state(
109
+ path,
110
+ FirstRunState(
111
+ disclaimer_acknowledged_on=state.disclaimer_acknowledged_on,
112
+ last_plan_path=plan_path,
113
+ ),
114
+ )