glidepath 0.2.0__tar.gz → 0.3.0__tar.gz
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-0.2.0 → glidepath-0.3.0}/PKG-INFO +34 -11
- {glidepath-0.2.0 → glidepath-0.3.0}/README.md +15 -9
- {glidepath-0.2.0 → glidepath-0.3.0}/pyproject.toml +54 -30
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/app/backtest.py +1 -1
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/app/drawdown.py +1 -1
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/app/montecarlo.py +1 -1
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/app/retirement.py +1 -1
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/core/backtest.py +5 -2
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/core/money.py +3 -1
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/core/montecarlo.py +5 -2
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/core/retirement.py +5 -2
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/gui/main.py +13 -3
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/gui/widgets.py +12 -1
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/persistence/encode.py +25 -5
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/regions/uk/extension.py +3 -1
- {glidepath-0.2.0 → glidepath-0.3.0}/LICENSE +0 -0
- {glidepath-0.2.0 → glidepath-0.3.0}/LICENSE-DATA +0 -0
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/__init__.py +0 -0
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/app/__init__.py +0 -0
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/app/charts.py +0 -0
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/app/copy.py +0 -0
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/app/display.py +0 -0
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/app/example.py +0 -0
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/app/exports.py +0 -0
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/app/files.py +0 -0
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/app/firstrun.py +0 -0
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/app/forms.py +0 -0
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/app/inspector.py +0 -0
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/app/labels.py +0 -0
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/app/plan.py +0 -0
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/app/scenarios.py +0 -0
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/app/shell.py +0 -0
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/app/tables.py +0 -0
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/core/__init__.py +0 -0
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/core/annuities.py +0 -0
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/core/comparison.py +0 -0
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/core/config.py +0 -0
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/core/contributions.py +0 -0
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/core/engine.py +0 -0
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/core/entities.py +0 -0
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/core/glide.py +0 -0
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/core/investments.py +0 -0
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/core/pensions.py +0 -0
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/core/periods.py +0 -0
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/core/provenance.py +0 -0
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/core/randomness.py +0 -0
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/core/region.py +0 -0
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/core/reporting.py +0 -0
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/core/results.py +0 -0
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/core/returns.py +0 -0
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/core/scenarios.py +0 -0
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/core/state_pension.py +0 -0
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/core/tax.py +0 -0
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/core/withdrawals.py +0 -0
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/core/wrappers.py +0 -0
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/gui/__init__.py +0 -0
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/gui/assets/icon_128.png +0 -0
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/gui/assets/icon_16.png +0 -0
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/gui/assets/icon_24.png +0 -0
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/gui/assets/icon_256.png +0 -0
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/gui/assets/icon_32.png +0 -0
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/gui/assets/icon_48.png +0 -0
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/gui/assets/icon_64.png +0 -0
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/gui/assets/wordmark.png +0 -0
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/gui/charts.py +0 -0
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/gui/forms.py +0 -0
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/gui/inspector.py +0 -0
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/gui/scenarios.py +0 -0
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/gui/style.py +0 -0
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/gui/tableview.py +0 -0
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/persistence/__init__.py +0 -0
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/persistence/assumptions.py +0 -0
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/persistence/decode.py +0 -0
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/persistence/document.py +0 -0
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/persistence/migrations.py +0 -0
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/persistence/values.py +0 -0
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/py.typed +0 -0
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/regions/__init__.py +0 -0
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/regions/uk/__init__.py +0 -0
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/regions/uk/ages.py +0 -0
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/regions/uk/contributions.py +0 -0
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/regions/uk/data/age_rules.toml +0 -0
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/regions/uk/data/assumptions_default.toml +0 -0
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/regions/uk/data/returns_history.toml +0 -0
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/regions/uk/data/tax_year_2026_27.toml +0 -0
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/regions/uk/loader.py +0 -0
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/regions/uk/region.py +0 -0
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/regions/uk/schema.py +0 -0
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/regions/uk/state_pension.py +0 -0
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/regions/uk/tax.py +0 -0
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/regions/uk/wrappers.py +0 -0
- {glidepath-0.2.0 → glidepath-0.3.0}/src/glidepath/regions/uk/years.py +0 -0
|
@@ -1,16 +1,35 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: glidepath
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Desktop retirement and investment planner (UK-first, region-extensible).
|
|
5
|
+
Keywords: retirement,pension,financial-planning,investment,uk,sipp,isa,monte-carlo
|
|
6
|
+
Author: James Williams
|
|
5
7
|
License-Expression: MIT AND CC-BY-NC-SA-4.0
|
|
6
8
|
License-File: LICENSE
|
|
7
9
|
License-File: LICENSE-DATA
|
|
8
|
-
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Win32 (MS Windows)
|
|
12
|
+
Classifier: Environment :: X11 Applications :: Qt
|
|
13
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
14
|
+
Classifier: Intended Audience :: Financial and Insurance Industry
|
|
15
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
16
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Classifier: Topic :: Office/Business :: Financial :: Investment
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Dist: pyside6==6.11.1
|
|
9
22
|
Requires-Python: >=3.14
|
|
23
|
+
Project-URL: Homepage, https://williajm.github.io/glidepath/
|
|
24
|
+
Project-URL: Repository, https://github.com/williajm/glidepath
|
|
25
|
+
Project-URL: Changelog, https://github.com/williajm/glidepath/blob/main/CHANGELOG.md
|
|
26
|
+
Project-URL: Issues, https://github.com/williajm/glidepath/issues
|
|
10
27
|
Description-Content-Type: text/markdown
|
|
11
28
|
|
|
29
|
+
<!-- Image and badge URLs are absolute so the README renders on PyPI,
|
|
30
|
+
which does not resolve repository-relative paths. -->
|
|
12
31
|
<p align="center">
|
|
13
|
-
<img src="src/glidepath/gui/assets/wordmark.png" alt="glidepath" width="420">
|
|
32
|
+
<img src="https://raw.githubusercontent.com/williajm/glidepath/main/src/glidepath/gui/assets/wordmark.png" alt="glidepath" width="420">
|
|
14
33
|
</p>
|
|
15
34
|
|
|
16
35
|
[](https://github.com/williajm/glidepath/actions/workflows/ci.yml)
|
|
@@ -20,7 +39,7 @@ Description-Content-Type: text/markdown
|
|
|
20
39
|
[](https://github.com/astral-sh/uv)
|
|
21
40
|
[](https://github.com/astral-sh/ruff)
|
|
22
41
|
[](https://mypy-lang.org/)
|
|
23
|
-
[](https://github.com/williajm/glidepath/blob/main/LICENSE)
|
|
24
43
|
|
|
25
44
|
<p align="center">
|
|
26
45
|
<a href="https://williajm.github.io/glidepath/"><b>williajm.github.io/glidepath</b></a>
|
|
@@ -33,7 +52,7 @@ stated, a **decision** you made, or an **assumption** the app defaulted
|
|
|
33
52
|
nothing is transmitted.
|
|
34
53
|
|
|
35
54
|
<p align="center">
|
|
36
|
-
<img src="docs/screenshots/charts.png"
|
|
55
|
+
<img src="https://raw.githubusercontent.com/williajm/glidepath/main/docs/screenshots/charts.png"
|
|
37
56
|
alt="The charts tab: the Monte Carlo fan chart — nested percentile bands deepening toward the median line — beside the success-rate readout and the retirement-age and backtest cards"
|
|
38
57
|
width="800">
|
|
39
58
|
</p>
|
|
@@ -79,7 +98,7 @@ a choice you make; anything estimated lives in the assumptions
|
|
|
79
98
|
inspector instead:
|
|
80
99
|
|
|
81
100
|
<p align="center">
|
|
82
|
-
<img src="docs/screenshots/facts.png"
|
|
101
|
+
<img src="https://raw.githubusercontent.com/williajm/glidepath/main/docs/screenshots/facts.png"
|
|
83
102
|
alt="The facts tab: the About you, Household spending, and State pension cards of the entry form, with the example plan's values filled in"
|
|
84
103
|
width="800">
|
|
85
104
|
</p>
|
|
@@ -89,7 +108,7 @@ choices in effect, and every assumption the run used with its value,
|
|
|
89
108
|
default/overridden status, source, and date:
|
|
90
109
|
|
|
91
110
|
<p align="center">
|
|
92
|
-
<img src="docs/screenshots/stated_vs_assumed.png"
|
|
111
|
+
<img src="https://raw.githubusercontent.com/williajm/glidepath/main/docs/screenshots/stated_vs_assumed.png"
|
|
93
112
|
alt="The stated-vs-assumed tab: tables of stated facts, choices in effect, assumptions used with sources, and the plan structure"
|
|
94
113
|
width="800">
|
|
95
114
|
</p>
|
|
@@ -154,7 +173,7 @@ fine.)
|
|
|
154
173
|
## Everyday commands
|
|
155
174
|
|
|
156
175
|
```sh
|
|
157
|
-
make check # all merge gates: ruff, format, mypy --strict, pytest (>=
|
|
176
|
+
make check # all merge gates: ruff, format, mypy --strict, pytest (>=96% cov), dep age
|
|
158
177
|
make fix # auto-fix lint issues and reformat
|
|
159
178
|
make test # tests with coverage
|
|
160
179
|
make deps # the ONLY way to add/upgrade dependencies (7-day cooldown lock)
|
|
@@ -169,9 +188,13 @@ clean `make check` locally does not quite guarantee a green pipeline. See
|
|
|
169
188
|
|
|
170
189
|
## Releases
|
|
171
190
|
|
|
172
|
-
Releases are `vX.Y.Z` tags on `main
|
|
173
|
-
|
|
174
|
-
|
|
191
|
+
Releases are `vX.Y.Z` tags on `main`. Each one is published to
|
|
192
|
+
[PyPI](https://pypi.org/project/glidepath/) as an sdist and wheel via
|
|
193
|
+
trusted publishing with PEP 740 attestations, and as a GitHub Release
|
|
194
|
+
carrying its notes from
|
|
195
|
+
[`CHANGELOG.md`](https://github.com/williajm/glidepath/blob/main/CHANGELOG.md)
|
|
196
|
+
with the same artifacts attached. There are no packaged binary builds
|
|
197
|
+
(installer/exe) yet — install from PyPI as above.
|
|
175
198
|
|
|
176
199
|
## Data licences
|
|
177
200
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
<!-- Image and badge URLs are absolute so the README renders on PyPI,
|
|
2
|
+
which does not resolve repository-relative paths. -->
|
|
1
3
|
<p align="center">
|
|
2
|
-
<img src="src/glidepath/gui/assets/wordmark.png" alt="glidepath" width="420">
|
|
4
|
+
<img src="https://raw.githubusercontent.com/williajm/glidepath/main/src/glidepath/gui/assets/wordmark.png" alt="glidepath" width="420">
|
|
3
5
|
</p>
|
|
4
6
|
|
|
5
7
|
[](https://github.com/williajm/glidepath/actions/workflows/ci.yml)
|
|
@@ -9,7 +11,7 @@
|
|
|
9
11
|
[](https://github.com/astral-sh/uv)
|
|
10
12
|
[](https://github.com/astral-sh/ruff)
|
|
11
13
|
[](https://mypy-lang.org/)
|
|
12
|
-
[](https://github.com/williajm/glidepath/blob/main/LICENSE)
|
|
13
15
|
|
|
14
16
|
<p align="center">
|
|
15
17
|
<a href="https://williajm.github.io/glidepath/"><b>williajm.github.io/glidepath</b></a>
|
|
@@ -22,7 +24,7 @@ stated, a **decision** you made, or an **assumption** the app defaulted
|
|
|
22
24
|
nothing is transmitted.
|
|
23
25
|
|
|
24
26
|
<p align="center">
|
|
25
|
-
<img src="docs/screenshots/charts.png"
|
|
27
|
+
<img src="https://raw.githubusercontent.com/williajm/glidepath/main/docs/screenshots/charts.png"
|
|
26
28
|
alt="The charts tab: the Monte Carlo fan chart — nested percentile bands deepening toward the median line — beside the success-rate readout and the retirement-age and backtest cards"
|
|
27
29
|
width="800">
|
|
28
30
|
</p>
|
|
@@ -68,7 +70,7 @@ a choice you make; anything estimated lives in the assumptions
|
|
|
68
70
|
inspector instead:
|
|
69
71
|
|
|
70
72
|
<p align="center">
|
|
71
|
-
<img src="docs/screenshots/facts.png"
|
|
73
|
+
<img src="https://raw.githubusercontent.com/williajm/glidepath/main/docs/screenshots/facts.png"
|
|
72
74
|
alt="The facts tab: the About you, Household spending, and State pension cards of the entry form, with the example plan's values filled in"
|
|
73
75
|
width="800">
|
|
74
76
|
</p>
|
|
@@ -78,7 +80,7 @@ choices in effect, and every assumption the run used with its value,
|
|
|
78
80
|
default/overridden status, source, and date:
|
|
79
81
|
|
|
80
82
|
<p align="center">
|
|
81
|
-
<img src="docs/screenshots/stated_vs_assumed.png"
|
|
83
|
+
<img src="https://raw.githubusercontent.com/williajm/glidepath/main/docs/screenshots/stated_vs_assumed.png"
|
|
82
84
|
alt="The stated-vs-assumed tab: tables of stated facts, choices in effect, assumptions used with sources, and the plan structure"
|
|
83
85
|
width="800">
|
|
84
86
|
</p>
|
|
@@ -143,7 +145,7 @@ fine.)
|
|
|
143
145
|
## Everyday commands
|
|
144
146
|
|
|
145
147
|
```sh
|
|
146
|
-
make check # all merge gates: ruff, format, mypy --strict, pytest (>=
|
|
148
|
+
make check # all merge gates: ruff, format, mypy --strict, pytest (>=96% cov), dep age
|
|
147
149
|
make fix # auto-fix lint issues and reformat
|
|
148
150
|
make test # tests with coverage
|
|
149
151
|
make deps # the ONLY way to add/upgrade dependencies (7-day cooldown lock)
|
|
@@ -158,9 +160,13 @@ clean `make check` locally does not quite guarantee a green pipeline. See
|
|
|
158
160
|
|
|
159
161
|
## Releases
|
|
160
162
|
|
|
161
|
-
Releases are `vX.Y.Z` tags on `main
|
|
162
|
-
|
|
163
|
-
|
|
163
|
+
Releases are `vX.Y.Z` tags on `main`. Each one is published to
|
|
164
|
+
[PyPI](https://pypi.org/project/glidepath/) as an sdist and wheel via
|
|
165
|
+
trusted publishing with PEP 740 attestations, and as a GitHub Release
|
|
166
|
+
carrying its notes from
|
|
167
|
+
[`CHANGELOG.md`](https://github.com/williajm/glidepath/blob/main/CHANGELOG.md)
|
|
168
|
+
with the same artifacts attached. There are no packaged binary builds
|
|
169
|
+
(installer/exe) yet — install from PyPI as above.
|
|
164
170
|
|
|
165
171
|
## Data licences
|
|
166
172
|
|
|
@@ -1,17 +1,55 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "glidepath"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.0"
|
|
4
4
|
description = "Desktop retirement and investment planner (UK-first, region-extensible)."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
# MIT code plus one CC BY-NC-SA 4.0 data file (returns_history.toml —
|
|
7
7
|
# scope and attribution in LICENSE-DATA).
|
|
8
8
|
license = "MIT AND CC-BY-NC-SA-4.0"
|
|
9
9
|
license-files = ["LICENSE", "LICENSE-DATA"]
|
|
10
|
+
authors = [{ name = "James Williams" }]
|
|
10
11
|
requires-python = ">=3.14"
|
|
12
|
+
keywords = [
|
|
13
|
+
"retirement",
|
|
14
|
+
"pension",
|
|
15
|
+
"financial-planning",
|
|
16
|
+
"investment",
|
|
17
|
+
"uk",
|
|
18
|
+
"sipp",
|
|
19
|
+
"isa",
|
|
20
|
+
"monte-carlo",
|
|
21
|
+
]
|
|
22
|
+
# No License :: classifier — PEP 639: the SPDX expression above is the
|
|
23
|
+
# license metadata, and mixing it with license classifiers is deprecated.
|
|
24
|
+
classifiers = [
|
|
25
|
+
"Development Status :: 4 - Beta",
|
|
26
|
+
"Environment :: Win32 (MS Windows)",
|
|
27
|
+
"Environment :: X11 Applications :: Qt",
|
|
28
|
+
"Intended Audience :: End Users/Desktop",
|
|
29
|
+
"Intended Audience :: Financial and Insurance Industry",
|
|
30
|
+
# The platforms the suite actually runs on (ci.yml); no macOS claim
|
|
31
|
+
# until it is tested there.
|
|
32
|
+
"Operating System :: Microsoft :: Windows",
|
|
33
|
+
"Operating System :: POSIX :: Linux",
|
|
34
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
35
|
+
"Programming Language :: Python :: 3.14",
|
|
36
|
+
"Topic :: Office/Business :: Financial :: Investment",
|
|
37
|
+
"Typing :: Typed",
|
|
38
|
+
]
|
|
39
|
+
# glidepath is an application, not a library: the runtime dependency is
|
|
40
|
+
# pinned exactly so `uv tool install glidepath` installs the same PySide6
|
|
41
|
+
# the release was tested against (uv.lock does not apply to end users).
|
|
42
|
+
# Bump the pin only via `make deps`.
|
|
11
43
|
dependencies = [
|
|
12
|
-
"pyside6
|
|
44
|
+
"pyside6==6.11.1",
|
|
13
45
|
]
|
|
14
46
|
|
|
47
|
+
[project.urls]
|
|
48
|
+
Homepage = "https://williajm.github.io/glidepath/"
|
|
49
|
+
Repository = "https://github.com/williajm/glidepath"
|
|
50
|
+
Changelog = "https://github.com/williajm/glidepath/blob/main/CHANGELOG.md"
|
|
51
|
+
Issues = "https://github.com/williajm/glidepath/issues"
|
|
52
|
+
|
|
15
53
|
[project.gui-scripts]
|
|
16
54
|
glidepath = "glidepath.gui.main:main"
|
|
17
55
|
|
|
@@ -35,7 +73,7 @@ build-backend = "uv_build"
|
|
|
35
73
|
[tool.uv]
|
|
36
74
|
# Supply-chain cooldown: resolution ignores anything published to PyPI after
|
|
37
75
|
# this timestamp (now minus 7 days). Updated ONLY by `make deps`.
|
|
38
|
-
exclude-newer = "2026-
|
|
76
|
+
exclude-newer = "2026-08-01T17:09:10Z"
|
|
39
77
|
|
|
40
78
|
# PyPI is the ONLY permitted index (supply-chain policy; see CLAUDE.md).
|
|
41
79
|
[[tool.uv.index]]
|
|
@@ -61,34 +99,16 @@ convention = "google"
|
|
|
61
99
|
[tool.ruff.lint.mccabe]
|
|
62
100
|
max-complexity = 10
|
|
63
101
|
|
|
102
|
+
# BLE001 (app/ slow-run transitions) and PLR0913 (§5.2 runner signatures)
|
|
103
|
+
# are granted inline at the specific lines that earned them, not file-wide,
|
|
104
|
+
# so new violations elsewhere in those modules still fail lint.
|
|
64
105
|
[tool.ruff.lint.per-file-ignores]
|
|
65
106
|
"src/glidepath/core/randomness.py" = [
|
|
66
107
|
"S311", # Seeded Monte Carlo simulation draws, never security material (planning §4.6).
|
|
67
108
|
]
|
|
68
|
-
"src/glidepath/core/montecarlo.py" = [
|
|
69
|
-
"PLR0913", # run_paths/sustainable_income take the §5.2 engine quartet plus keyword-only run knobs; bundling would obscure the run(plan, assumptions, region, config) shape.
|
|
70
|
-
]
|
|
71
|
-
"src/glidepath/core/backtest.py" = [
|
|
72
|
-
"PLR0913", # run_windows and its chunk worker mirror the §5.2 runner signature (see core/montecarlo.py).
|
|
73
|
-
]
|
|
74
109
|
"scripts/build_returns_history.py" = [
|
|
75
110
|
"S314", # xml.etree parses the JST workbook the developer supplies locally, never untrusted input.
|
|
76
111
|
]
|
|
77
|
-
"src/glidepath/core/retirement.py" = [
|
|
78
|
-
"PLR0913", # earliest_retirement_age mirrors the §5.2 runner signature (see core/montecarlo.py).
|
|
79
|
-
]
|
|
80
|
-
"src/glidepath/app/montecarlo.py" = [
|
|
81
|
-
"BLE001", # The slow-run transition must fold ANY failure into state (§4.7): an exception escaping past the shell's worker thread holds the in-flight guard forever.
|
|
82
|
-
]
|
|
83
|
-
"src/glidepath/app/retirement.py" = [
|
|
84
|
-
"BLE001", # Same in-flight-guard rationale as app/montecarlo.py.
|
|
85
|
-
]
|
|
86
|
-
"src/glidepath/app/drawdown.py" = [
|
|
87
|
-
"BLE001", # Same in-flight-guard rationale as app/montecarlo.py.
|
|
88
|
-
]
|
|
89
|
-
"src/glidepath/app/backtest.py" = [
|
|
90
|
-
"BLE001", # Same in-flight-guard rationale as app/montecarlo.py.
|
|
91
|
-
]
|
|
92
112
|
"tests/**" = [
|
|
93
113
|
"S101", # pytest tests use bare assert by design.
|
|
94
114
|
"PLR2004", # Literal expected values in assertions are idiomatic in tests.
|
|
@@ -103,12 +123,15 @@ max-complexity = 10
|
|
|
103
123
|
# --- mypy -------------------------------------------------------------------
|
|
104
124
|
[tool.mypy]
|
|
105
125
|
strict = true
|
|
106
|
-
|
|
126
|
+
# Not implied by strict: flags code mypy can prove unreachable — high-yield
|
|
127
|
+
# in Decimal/None-narrowing-heavy code.
|
|
128
|
+
warn_unreachable = true
|
|
107
129
|
python_version = "3.14"
|
|
108
130
|
files = ["src", "tests", "scripts"]
|
|
109
131
|
# Lets the tests' top-level script imports (e.g. `import check_dep_age`)
|
|
110
|
-
#
|
|
111
|
-
|
|
132
|
+
# and the shared test factories (`import factories`) resolve — mirrors
|
|
133
|
+
# pytest's pythonpath below.
|
|
134
|
+
mypy_path = ["scripts", "tests"]
|
|
112
135
|
|
|
113
136
|
# --- pytest / coverage ------------------------------------------------------
|
|
114
137
|
[tool.pytest.ini_options]
|
|
@@ -122,9 +145,10 @@ addopts = [
|
|
|
122
145
|
"--cov-report=xml",
|
|
123
146
|
]
|
|
124
147
|
testpaths = ["tests"]
|
|
125
|
-
# scripts/ is
|
|
126
|
-
# gates behind `make deps`/`make bump`/releases)
|
|
127
|
-
|
|
148
|
+
# Neither scripts/ nor tests/ is a package; tests import the operational
|
|
149
|
+
# scripts (the gates behind `make deps`/`make bump`/releases) and the
|
|
150
|
+
# shared test-data factories (tests/factories.py) as top-level modules.
|
|
151
|
+
pythonpath = ["scripts", "tests"]
|
|
128
152
|
# Fail a stuck test fast instead of hanging CI for the runner limit: the
|
|
129
153
|
# GUI suite waits on worker pools for up to 60s, so the ceiling sits
|
|
130
154
|
# safely above that while still cutting a wedged run short.
|
|
@@ -132,7 +132,7 @@ def state_with_backtest(state: PlanState, *, today: date) -> PlanState:
|
|
|
132
132
|
config,
|
|
133
133
|
series=series,
|
|
134
134
|
)
|
|
135
|
-
except Exception as exc:
|
|
135
|
+
except Exception as exc: # noqa: BLE001
|
|
136
136
|
# Broad by design, mirroring the Monte Carlo transition: an
|
|
137
137
|
# escape past the shell's worker thread would leave the
|
|
138
138
|
# in-flight guard held (buttons disabled, spinner running)
|
|
@@ -254,7 +254,7 @@ def state_with_drawdown(
|
|
|
254
254
|
search=search,
|
|
255
255
|
parallelism=parallelism,
|
|
256
256
|
)
|
|
257
|
-
except Exception as exc:
|
|
257
|
+
except Exception as exc: # noqa: BLE001
|
|
258
258
|
# Broad by design, exactly as in state_with_monte_carlo: a
|
|
259
259
|
# process-pool failure escaping here would hold the shell's
|
|
260
260
|
# in-flight guard forever, so every failure folds into the
|
|
@@ -316,7 +316,7 @@ def state_with_monte_carlo(
|
|
|
316
316
|
paths=paths,
|
|
317
317
|
parallelism=parallelism,
|
|
318
318
|
)
|
|
319
|
-
except Exception as exc:
|
|
319
|
+
except Exception as exc: # noqa: BLE001
|
|
320
320
|
# Broad by design: beyond the engine's ValueErrors, the process
|
|
321
321
|
# pool can raise OSError at spawn, pickling TypeErrors, or a
|
|
322
322
|
# BrokenExecutor — an escape here would leave the shell's
|
|
@@ -234,7 +234,7 @@ def state_with_retirement(
|
|
|
234
234
|
search,
|
|
235
235
|
parallelism=parallelism,
|
|
236
236
|
)
|
|
237
|
-
except Exception as exc:
|
|
237
|
+
except Exception as exc: # noqa: BLE001
|
|
238
238
|
# Broad by design, exactly as in state_with_monte_carlo: a
|
|
239
239
|
# process-pool failure escaping here would hold the shell's
|
|
240
240
|
# in-flight guard forever, so every failure folds into the
|
|
@@ -392,7 +392,9 @@ class BacktestResult:
|
|
|
392
392
|
)
|
|
393
393
|
|
|
394
394
|
|
|
395
|
-
|
|
395
|
+
# The §5.2 engine quartet plus keyword-only run knobs; bundling would
|
|
396
|
+
# obscure the run(plan, assumptions, region, config) shape.
|
|
397
|
+
def run_windows( # noqa: PLR0913
|
|
396
398
|
plan: Household,
|
|
397
399
|
assumptions: AssumptionSet,
|
|
398
400
|
region: Region,
|
|
@@ -477,7 +479,8 @@ def run_windows(
|
|
|
477
479
|
)
|
|
478
480
|
|
|
479
481
|
|
|
480
|
-
|
|
482
|
+
# Mirrors the run_windows signature above.
|
|
483
|
+
def _run_window_range( # noqa: PLR0913
|
|
481
484
|
plan: Household,
|
|
482
485
|
assumptions: AssumptionSet,
|
|
483
486
|
region: Region,
|
|
@@ -15,7 +15,9 @@ _PENNY = Decimal("0.01")
|
|
|
15
15
|
def _require_finite_decimal(value: Decimal, field_name: str) -> None:
|
|
16
16
|
"""Reject non-Decimal and non-finite amounts at construction time."""
|
|
17
17
|
if not isinstance(value, Decimal):
|
|
18
|
-
|
|
18
|
+
# Reachable from untyped callers (decoded plan JSON, region TOML)
|
|
19
|
+
# even though the annotation lets mypy prove the branch dead.
|
|
20
|
+
msg = f"{field_name} must be Decimal, got {type(value).__name__}" # type: ignore[unreachable]
|
|
19
21
|
raise TypeError(msg)
|
|
20
22
|
if not value.is_finite():
|
|
21
23
|
msg = f"{field_name} must be finite"
|
|
@@ -242,7 +242,9 @@ class PathParallelism:
|
|
|
242
242
|
raise ValueError(msg)
|
|
243
243
|
|
|
244
244
|
|
|
245
|
-
|
|
245
|
+
# The §5.2 engine quartet plus keyword-only run knobs; bundling would
|
|
246
|
+
# obscure the run(plan, assumptions, region, config) shape.
|
|
247
|
+
def run_paths( # noqa: PLR0913
|
|
246
248
|
plan: Household,
|
|
247
249
|
assumptions: AssumptionSet,
|
|
248
250
|
region: Region,
|
|
@@ -392,7 +394,8 @@ class SustainableIncomeSearch:
|
|
|
392
394
|
raise ValueError(msg)
|
|
393
395
|
|
|
394
396
|
|
|
395
|
-
|
|
397
|
+
# Mirrors the run_paths signature above.
|
|
398
|
+
def sustainable_income( # noqa: PLR0913
|
|
396
399
|
plan: Household,
|
|
397
400
|
assumptions: AssumptionSet,
|
|
398
401
|
region: Region,
|
|
@@ -113,7 +113,9 @@ class RetirementAgeSearch:
|
|
|
113
113
|
raise ValueError(msg)
|
|
114
114
|
|
|
115
115
|
|
|
116
|
-
|
|
116
|
+
# The §5.2 engine quartet plus keyword-only run knobs; bundling would
|
|
117
|
+
# obscure the run(plan, assumptions, region, config) shape.
|
|
118
|
+
def earliest_retirement_age( # noqa: PLR0913
|
|
117
119
|
plan: Household,
|
|
118
120
|
assumptions: AssumptionSet,
|
|
119
121
|
region: Region,
|
|
@@ -180,7 +182,8 @@ def earliest_retirement_age(
|
|
|
180
182
|
return None
|
|
181
183
|
|
|
182
184
|
|
|
183
|
-
|
|
185
|
+
# Mirrors the earliest_retirement_age signature above.
|
|
186
|
+
def sustainable_income_at_age( # noqa: PLR0913
|
|
184
187
|
plan: Household,
|
|
185
188
|
assumptions: AssumptionSet,
|
|
186
189
|
region: Region,
|
|
@@ -4,7 +4,7 @@ The disclaimer must be accepted before the shell opens; declining
|
|
|
4
4
|
exits without recording anything, so the next launch asks again.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
-
import
|
|
7
|
+
import logging
|
|
8
8
|
import sys
|
|
9
9
|
from datetime import UTC, datetime
|
|
10
10
|
|
|
@@ -20,6 +20,8 @@ from glidepath.app import (
|
|
|
20
20
|
from glidepath.gui import widgets
|
|
21
21
|
from glidepath.gui.style import apply_theme
|
|
22
22
|
|
|
23
|
+
_logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
23
25
|
|
|
24
26
|
def run(argv: list[str] | None = None) -> int:
|
|
25
27
|
"""Run the desktop shell; returns the process exit code."""
|
|
@@ -34,9 +36,17 @@ def run(argv: list[str] | None = None) -> int:
|
|
|
34
36
|
return 0
|
|
35
37
|
# The acknowledgement file only skips future prompts; an unwritable
|
|
36
38
|
# config directory must not kill the session the user just accepted
|
|
37
|
-
# (worst case the next launch asks again)
|
|
38
|
-
|
|
39
|
+
# (worst case the next launch asks again) — but it must leave a
|
|
40
|
+
# diagnostic trail for "it keeps asking me" reports.
|
|
41
|
+
try:
|
|
39
42
|
record_disclaimer_acknowledged(state_path, datetime.now(tz=UTC).date())
|
|
43
|
+
except OSError:
|
|
44
|
+
_logger.warning(
|
|
45
|
+
"Could not record the disclaimer acknowledgement at %s; "
|
|
46
|
+
"the next launch will ask again.",
|
|
47
|
+
state_path,
|
|
48
|
+
exc_info=True,
|
|
49
|
+
)
|
|
40
50
|
window = widgets.MainWindow(view_model, settings_path=state_path)
|
|
41
51
|
# Reopen the last saved or opened plan; if it fails to load, the
|
|
42
52
|
# launch example stays on screen and the status bar explains.
|
|
@@ -6,6 +6,7 @@ render view models and forward raw user input back.
|
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
import contextlib
|
|
9
|
+
import logging
|
|
9
10
|
from datetime import UTC, date, datetime
|
|
10
11
|
from pathlib import Path
|
|
11
12
|
from typing import TYPE_CHECKING
|
|
@@ -105,6 +106,8 @@ from glidepath.gui.style import wordmark_pixmap
|
|
|
105
106
|
if TYPE_CHECKING:
|
|
106
107
|
from collections.abc import Callable
|
|
107
108
|
|
|
109
|
+
_logger = logging.getLogger(__name__)
|
|
110
|
+
|
|
108
111
|
_REPORT_DPI = 96
|
|
109
112
|
"""The PDF paint device's resolution.
|
|
110
113
|
|
|
@@ -582,8 +585,16 @@ class MainWindow(QMainWindow):
|
|
|
582
585
|
"""
|
|
583
586
|
if self._settings_path is None:
|
|
584
587
|
return
|
|
585
|
-
|
|
588
|
+
try:
|
|
586
589
|
record_last_plan_path(self._settings_path, path)
|
|
590
|
+
except OSError:
|
|
591
|
+
_logger.warning(
|
|
592
|
+
"Could not remember the plan path %s in %s; the next "
|
|
593
|
+
"launch will start on the example plan.",
|
|
594
|
+
path,
|
|
595
|
+
self._settings_path,
|
|
596
|
+
exc_info=True,
|
|
597
|
+
)
|
|
587
598
|
|
|
588
599
|
def _load_example(self) -> None:
|
|
589
600
|
"""Open with the example plan on screen and projected (§4.9).
|
|
@@ -18,6 +18,7 @@ ids) is rejected here too.
|
|
|
18
18
|
|
|
19
19
|
import contextlib
|
|
20
20
|
import json
|
|
21
|
+
import os
|
|
21
22
|
from typing import TYPE_CHECKING
|
|
22
23
|
|
|
23
24
|
from glidepath.core import (
|
|
@@ -87,23 +88,42 @@ def save_plan(document: PlanDocument, path: Path) -> None:
|
|
|
87
88
|
|
|
88
89
|
Serialization completes before any file is opened, so a document
|
|
89
90
|
that cannot be encoded never touches the disk; the text is then
|
|
90
|
-
written to a sibling temporary file
|
|
91
|
-
|
|
92
|
-
full, power loss) can never truncate the last saved
|
|
93
|
-
|
|
94
|
-
|
|
91
|
+
written to a sibling temporary file, flushed to stable storage
|
|
92
|
+
with ``fsync``, and only then moved over ``path`` — so a mid-write
|
|
93
|
+
failure (disk full, power loss) can never truncate the last saved
|
|
94
|
+
plan, and the rename never publishes data still sitting in the OS
|
|
95
|
+
write cache. ``newline=""`` disables platform newline translation
|
|
96
|
+
so the file carries LF endings on every platform (planning §4.5).
|
|
95
97
|
"""
|
|
96
98
|
text = dumps_plan(document)
|
|
97
99
|
temp = path.with_name(path.name + ".tmp")
|
|
98
100
|
try:
|
|
99
101
|
with temp.open("w", encoding="utf-8", newline="") as handle:
|
|
100
102
|
handle.write(text)
|
|
103
|
+
handle.flush()
|
|
104
|
+
os.fsync(handle.fileno())
|
|
101
105
|
temp.replace(path)
|
|
106
|
+
_fsync_directory(path.parent)
|
|
102
107
|
finally:
|
|
103
108
|
with contextlib.suppress(OSError):
|
|
104
109
|
temp.unlink(missing_ok=True)
|
|
105
110
|
|
|
106
111
|
|
|
112
|
+
def _fsync_directory(directory: Path) -> None:
|
|
113
|
+
"""Flush the directory entry so the rename itself survives power loss.
|
|
114
|
+
|
|
115
|
+
POSIX only: directories cannot be opened for fsync on Windows,
|
|
116
|
+
where the ``replace`` above is journalled by NTFS instead.
|
|
117
|
+
"""
|
|
118
|
+
if os.name != "posix":
|
|
119
|
+
return
|
|
120
|
+
descriptor = os.open(directory, os.O_RDONLY)
|
|
121
|
+
try:
|
|
122
|
+
os.fsync(descriptor)
|
|
123
|
+
finally:
|
|
124
|
+
os.close(descriptor)
|
|
125
|
+
|
|
126
|
+
|
|
107
127
|
def _document_payload(document: PlanDocument) -> dict[str, object]:
|
|
108
128
|
"""The whole document as a JSON-ready payload."""
|
|
109
129
|
return {
|
|
@@ -189,7 +189,9 @@ class FutureYearsPolicy:
|
|
|
189
189
|
Scottish band uppers (module docstring).
|
|
190
190
|
"""
|
|
191
191
|
if not isinstance(self.mode, FutureYearsMode):
|
|
192
|
-
|
|
192
|
+
# Reachable from raw TOML data even though the annotation
|
|
193
|
+
# lets mypy prove the branch dead.
|
|
194
|
+
type_name = type(self.mode).__name__ # type: ignore[unreachable]
|
|
193
195
|
_fail(_CONTEXT, f"mode must be a FutureYearsMode member, got {type_name}")
|
|
194
196
|
needs_until = self.mode is FutureYearsMode.FROZEN_THEN_CPI_INDEXED
|
|
195
197
|
if needs_until and self.frozen_until_start_year is None:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|