glidepath 0.2.1__tar.gz → 0.4.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.1 → glidepath-0.4.0}/PKG-INFO +29 -13
- {glidepath-0.2.1 → glidepath-0.4.0}/README.md +24 -10
- {glidepath-0.2.1 → glidepath-0.4.0}/pyproject.toml +25 -31
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/app/__init__.py +16 -0
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/app/backtest.py +1 -1
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/app/charts.py +48 -32
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/app/display.py +12 -3
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/app/drawdown.py +76 -17
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/app/example.py +7 -3
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/app/exports.py +1 -1
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/app/forms.py +530 -119
- glidepath-0.4.0/src/glidepath/app/labels.py +116 -0
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/app/montecarlo.py +1 -1
- glidepath-0.4.0/src/glidepath/app/outlook.py +595 -0
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/app/retirement.py +103 -21
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/app/scenarios.py +126 -8
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/core/__init__.py +10 -3
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/core/annuities.py +29 -2
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/core/backtest.py +5 -2
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/core/engine.py +1457 -617
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/core/entities.py +30 -17
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/core/money.py +3 -1
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/core/montecarlo.py +65 -8
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/core/pensions.py +28 -11
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/core/provenance.py +2 -0
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/core/results.py +89 -47
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/core/retirement.py +112 -44
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/core/scenarios.py +95 -6
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/core/tax.py +55 -5
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/core/withdrawals.py +40 -23
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/core/wrappers.py +26 -3
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/gui/charts.py +115 -51
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/gui/forms.py +159 -18
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/gui/main.py +13 -3
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/gui/widgets.py +26 -3
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/persistence/decode.py +38 -1
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/persistence/document.py +19 -1
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/persistence/encode.py +43 -5
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/persistence/migrations.py +102 -1
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/persistence/values.py +12 -0
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/regions/uk/__init__.py +6 -0
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/regions/uk/ages.py +15 -0
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/regions/uk/data/age_rules.toml +12 -3
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/regions/uk/data/assumptions_default.toml +14 -2
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/regions/uk/data/returns_history.toml +1 -1
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/regions/uk/data/tax_year_2026_27.toml +17 -4
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/regions/uk/extension.py +43 -7
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/regions/uk/loader.py +44 -0
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/regions/uk/schema.py +67 -2
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/regions/uk/tax.py +148 -2
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/regions/uk/wrappers.py +12 -0
- glidepath-0.2.1/src/glidepath/app/labels.py +0 -66
- {glidepath-0.2.1 → glidepath-0.4.0}/LICENSE +0 -0
- {glidepath-0.2.1 → glidepath-0.4.0}/LICENSE-DATA +0 -0
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/__init__.py +0 -0
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/app/copy.py +0 -0
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/app/files.py +0 -0
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/app/firstrun.py +0 -0
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/app/inspector.py +0 -0
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/app/plan.py +0 -0
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/app/shell.py +0 -0
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/app/tables.py +0 -0
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/core/comparison.py +0 -0
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/core/config.py +0 -0
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/core/contributions.py +0 -0
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/core/glide.py +0 -0
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/core/investments.py +0 -0
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/core/periods.py +0 -0
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/core/randomness.py +0 -0
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/core/region.py +0 -0
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/core/reporting.py +0 -0
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/core/returns.py +0 -0
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/core/state_pension.py +0 -0
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/gui/__init__.py +0 -0
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/gui/assets/icon_128.png +0 -0
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/gui/assets/icon_16.png +0 -0
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/gui/assets/icon_24.png +0 -0
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/gui/assets/icon_256.png +0 -0
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/gui/assets/icon_32.png +0 -0
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/gui/assets/icon_48.png +0 -0
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/gui/assets/icon_64.png +0 -0
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/gui/assets/wordmark.png +0 -0
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/gui/inspector.py +0 -0
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/gui/scenarios.py +0 -0
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/gui/style.py +0 -0
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/gui/tableview.py +0 -0
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/persistence/__init__.py +0 -0
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/persistence/assumptions.py +0 -0
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/py.typed +0 -0
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/regions/__init__.py +0 -0
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/regions/uk/contributions.py +0 -0
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/regions/uk/region.py +0 -0
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/regions/uk/state_pension.py +0 -0
- {glidepath-0.2.1 → glidepath-0.4.0}/src/glidepath/regions/uk/years.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: glidepath
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Desktop retirement and investment planner (UK-first, region-extensible).
|
|
5
5
|
Keywords: retirement,pension,financial-planning,investment,uk,sipp,isa,monte-carlo
|
|
6
6
|
Author: James Williams
|
|
@@ -12,11 +12,13 @@ Classifier: Environment :: Win32 (MS Windows)
|
|
|
12
12
|
Classifier: Environment :: X11 Applications :: Qt
|
|
13
13
|
Classifier: Intended Audience :: End Users/Desktop
|
|
14
14
|
Classifier: Intended Audience :: Financial and Insurance Industry
|
|
15
|
-
Classifier: Operating System ::
|
|
15
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
16
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
16
17
|
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
18
|
Classifier: Programming Language :: Python :: 3.14
|
|
18
19
|
Classifier: Topic :: Office/Business :: Financial :: Investment
|
|
19
|
-
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Dist: pyside6==6.11.1
|
|
20
22
|
Requires-Python: >=3.14
|
|
21
23
|
Project-URL: Homepage, https://williajm.github.io/glidepath/
|
|
22
24
|
Project-URL: Repository, https://github.com/williajm/glidepath
|
|
@@ -24,8 +26,10 @@ Project-URL: Changelog, https://github.com/williajm/glidepath/blob/main/CHANGELO
|
|
|
24
26
|
Project-URL: Issues, https://github.com/williajm/glidepath/issues
|
|
25
27
|
Description-Content-Type: text/markdown
|
|
26
28
|
|
|
29
|
+
<!-- Image and badge URLs are absolute so the README renders on PyPI,
|
|
30
|
+
which does not resolve repository-relative paths. -->
|
|
27
31
|
<p align="center">
|
|
28
|
-
<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">
|
|
29
33
|
</p>
|
|
30
34
|
|
|
31
35
|
[](https://github.com/williajm/glidepath/actions/workflows/ci.yml)
|
|
@@ -35,7 +39,7 @@ Description-Content-Type: text/markdown
|
|
|
35
39
|
[](https://github.com/astral-sh/uv)
|
|
36
40
|
[](https://github.com/astral-sh/ruff)
|
|
37
41
|
[](https://mypy-lang.org/)
|
|
38
|
-
[](https://github.com/williajm/glidepath/blob/main/LICENSE)
|
|
39
43
|
|
|
40
44
|
<p align="center">
|
|
41
45
|
<a href="https://williajm.github.io/glidepath/"><b>williajm.github.io/glidepath</b></a>
|
|
@@ -48,14 +52,14 @@ stated, a **decision** you made, or an **assumption** the app defaulted
|
|
|
48
52
|
nothing is transmitted.
|
|
49
53
|
|
|
50
54
|
<p align="center">
|
|
51
|
-
<img src="docs/screenshots/charts.png"
|
|
55
|
+
<img src="https://raw.githubusercontent.com/williajm/glidepath/main/docs/screenshots/charts.png"
|
|
52
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"
|
|
53
57
|
width="800">
|
|
54
58
|
</p>
|
|
55
59
|
|
|
56
60
|
*(All screenshots show example data, not anyone's real finances.)*
|
|
57
61
|
|
|
58
|
-
What it models today (single
|
|
62
|
+
What it models today (UK, single or couple):
|
|
59
63
|
|
|
60
64
|
- **Wrappers** — workplace DC, SIPP, S&S ISA, LISA, GIA and cash, with
|
|
61
65
|
UK contribution relief mechanics and dividend/savings taxation.
|
|
@@ -65,6 +69,14 @@ What it models today (single person, UK):
|
|
|
65
69
|
including deferral.
|
|
66
70
|
- **Tax** — rUK and Scottish income tax from verified 2026/27 data
|
|
67
71
|
files; pension allowances (AA/taper/MPAA, lump-sum allowance).
|
|
72
|
+
- **Couples** — an optional partner, modelled end to end: one pooled
|
|
73
|
+
household decumulation drawing tax-efficiently across both partners'
|
|
74
|
+
wrappers with each person taxed individually (mixed rUK/Scottish
|
|
75
|
+
residency included), the marriage allowance claimed when eligible,
|
|
76
|
+
optional survivor modelling ("model death at age" — pensions pass as
|
|
77
|
+
beneficiary drawdown, ISAs via the additional permitted
|
|
78
|
+
subscription, DB schemes at their survivor fraction), and joint-life
|
|
79
|
+
annuities paying a 50/66/100% survivor income.
|
|
68
80
|
- **Projection** — deterministic or Monte Carlo runs from the app:
|
|
69
81
|
success rate, probability of ruin, ending-pot percentiles, and a
|
|
70
82
|
probability fan chart on its own tab, reproducible from a seed. With
|
|
@@ -94,7 +106,7 @@ a choice you make; anything estimated lives in the assumptions
|
|
|
94
106
|
inspector instead:
|
|
95
107
|
|
|
96
108
|
<p align="center">
|
|
97
|
-
<img src="docs/screenshots/facts.png"
|
|
109
|
+
<img src="https://raw.githubusercontent.com/williajm/glidepath/main/docs/screenshots/facts.png"
|
|
98
110
|
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"
|
|
99
111
|
width="800">
|
|
100
112
|
</p>
|
|
@@ -104,7 +116,7 @@ choices in effect, and every assumption the run used with its value,
|
|
|
104
116
|
default/overridden status, source, and date:
|
|
105
117
|
|
|
106
118
|
<p align="center">
|
|
107
|
-
<img src="docs/screenshots/stated_vs_assumed.png"
|
|
119
|
+
<img src="https://raw.githubusercontent.com/williajm/glidepath/main/docs/screenshots/stated_vs_assumed.png"
|
|
108
120
|
alt="The stated-vs-assumed tab: tables of stated facts, choices in effect, assumptions used with sources, and the plan structure"
|
|
109
121
|
width="800">
|
|
110
122
|
</p>
|
|
@@ -169,7 +181,7 @@ fine.)
|
|
|
169
181
|
## Everyday commands
|
|
170
182
|
|
|
171
183
|
```sh
|
|
172
|
-
make check # all merge gates: ruff, format, mypy --strict, pytest (>=
|
|
184
|
+
make check # all merge gates: ruff, format, mypy --strict, pytest (>=96% cov), dep age
|
|
173
185
|
make fix # auto-fix lint issues and reformat
|
|
174
186
|
make test # tests with coverage
|
|
175
187
|
make deps # the ONLY way to add/upgrade dependencies (7-day cooldown lock)
|
|
@@ -184,9 +196,13 @@ clean `make check` locally does not quite guarantee a green pipeline. See
|
|
|
184
196
|
|
|
185
197
|
## Releases
|
|
186
198
|
|
|
187
|
-
Releases are `vX.Y.Z` tags on `main
|
|
188
|
-
|
|
189
|
-
|
|
199
|
+
Releases are `vX.Y.Z` tags on `main`. Each one is published to
|
|
200
|
+
[PyPI](https://pypi.org/project/glidepath/) as an sdist and wheel via
|
|
201
|
+
trusted publishing with PEP 740 attestations, and as a GitHub Release
|
|
202
|
+
carrying its notes from
|
|
203
|
+
[`CHANGELOG.md`](https://github.com/williajm/glidepath/blob/main/CHANGELOG.md)
|
|
204
|
+
with the same artifacts attached. There are no packaged binary builds
|
|
205
|
+
(installer/exe) yet — install from PyPI as above.
|
|
190
206
|
|
|
191
207
|
## Data licences
|
|
192
208
|
|
|
@@ -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,14 +24,14 @@ 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>
|
|
29
31
|
|
|
30
32
|
*(All screenshots show example data, not anyone's real finances.)*
|
|
31
33
|
|
|
32
|
-
What it models today (single
|
|
34
|
+
What it models today (UK, single or couple):
|
|
33
35
|
|
|
34
36
|
- **Wrappers** — workplace DC, SIPP, S&S ISA, LISA, GIA and cash, with
|
|
35
37
|
UK contribution relief mechanics and dividend/savings taxation.
|
|
@@ -39,6 +41,14 @@ What it models today (single person, UK):
|
|
|
39
41
|
including deferral.
|
|
40
42
|
- **Tax** — rUK and Scottish income tax from verified 2026/27 data
|
|
41
43
|
files; pension allowances (AA/taper/MPAA, lump-sum allowance).
|
|
44
|
+
- **Couples** — an optional partner, modelled end to end: one pooled
|
|
45
|
+
household decumulation drawing tax-efficiently across both partners'
|
|
46
|
+
wrappers with each person taxed individually (mixed rUK/Scottish
|
|
47
|
+
residency included), the marriage allowance claimed when eligible,
|
|
48
|
+
optional survivor modelling ("model death at age" — pensions pass as
|
|
49
|
+
beneficiary drawdown, ISAs via the additional permitted
|
|
50
|
+
subscription, DB schemes at their survivor fraction), and joint-life
|
|
51
|
+
annuities paying a 50/66/100% survivor income.
|
|
42
52
|
- **Projection** — deterministic or Monte Carlo runs from the app:
|
|
43
53
|
success rate, probability of ruin, ending-pot percentiles, and a
|
|
44
54
|
probability fan chart on its own tab, reproducible from a seed. With
|
|
@@ -68,7 +78,7 @@ a choice you make; anything estimated lives in the assumptions
|
|
|
68
78
|
inspector instead:
|
|
69
79
|
|
|
70
80
|
<p align="center">
|
|
71
|
-
<img src="docs/screenshots/facts.png"
|
|
81
|
+
<img src="https://raw.githubusercontent.com/williajm/glidepath/main/docs/screenshots/facts.png"
|
|
72
82
|
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
83
|
width="800">
|
|
74
84
|
</p>
|
|
@@ -78,7 +88,7 @@ choices in effect, and every assumption the run used with its value,
|
|
|
78
88
|
default/overridden status, source, and date:
|
|
79
89
|
|
|
80
90
|
<p align="center">
|
|
81
|
-
<img src="docs/screenshots/stated_vs_assumed.png"
|
|
91
|
+
<img src="https://raw.githubusercontent.com/williajm/glidepath/main/docs/screenshots/stated_vs_assumed.png"
|
|
82
92
|
alt="The stated-vs-assumed tab: tables of stated facts, choices in effect, assumptions used with sources, and the plan structure"
|
|
83
93
|
width="800">
|
|
84
94
|
</p>
|
|
@@ -143,7 +153,7 @@ fine.)
|
|
|
143
153
|
## Everyday commands
|
|
144
154
|
|
|
145
155
|
```sh
|
|
146
|
-
make check # all merge gates: ruff, format, mypy --strict, pytest (>=
|
|
156
|
+
make check # all merge gates: ruff, format, mypy --strict, pytest (>=96% cov), dep age
|
|
147
157
|
make fix # auto-fix lint issues and reformat
|
|
148
158
|
make test # tests with coverage
|
|
149
159
|
make deps # the ONLY way to add/upgrade dependencies (7-day cooldown lock)
|
|
@@ -158,9 +168,13 @@ clean `make check` locally does not quite guarantee a green pipeline. See
|
|
|
158
168
|
|
|
159
169
|
## Releases
|
|
160
170
|
|
|
161
|
-
Releases are `vX.Y.Z` tags on `main
|
|
162
|
-
|
|
163
|
-
|
|
171
|
+
Releases are `vX.Y.Z` tags on `main`. Each one is published to
|
|
172
|
+
[PyPI](https://pypi.org/project/glidepath/) as an sdist and wheel via
|
|
173
|
+
trusted publishing with PEP 740 attestations, and as a GitHub Release
|
|
174
|
+
carrying its notes from
|
|
175
|
+
[`CHANGELOG.md`](https://github.com/williajm/glidepath/blob/main/CHANGELOG.md)
|
|
176
|
+
with the same artifacts attached. There are no packaged binary builds
|
|
177
|
+
(installer/exe) yet — install from PyPI as above.
|
|
164
178
|
|
|
165
179
|
## Data licences
|
|
166
180
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "glidepath"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.4.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 —
|
|
@@ -27,13 +27,21 @@ classifiers = [
|
|
|
27
27
|
"Environment :: X11 Applications :: Qt",
|
|
28
28
|
"Intended Audience :: End Users/Desktop",
|
|
29
29
|
"Intended Audience :: Financial and Insurance Industry",
|
|
30
|
-
|
|
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",
|
|
31
34
|
"Programming Language :: Python :: 3 :: Only",
|
|
32
35
|
"Programming Language :: Python :: 3.14",
|
|
33
36
|
"Topic :: Office/Business :: Financial :: Investment",
|
|
37
|
+
"Typing :: Typed",
|
|
34
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`.
|
|
35
43
|
dependencies = [
|
|
36
|
-
"pyside6
|
|
44
|
+
"pyside6==6.11.1",
|
|
37
45
|
]
|
|
38
46
|
|
|
39
47
|
[project.urls]
|
|
@@ -65,7 +73,7 @@ build-backend = "uv_build"
|
|
|
65
73
|
[tool.uv]
|
|
66
74
|
# Supply-chain cooldown: resolution ignores anything published to PyPI after
|
|
67
75
|
# this timestamp (now minus 7 days). Updated ONLY by `make deps`.
|
|
68
|
-
exclude-newer = "2026-
|
|
76
|
+
exclude-newer = "2026-08-01T17:09:10Z"
|
|
69
77
|
|
|
70
78
|
# PyPI is the ONLY permitted index (supply-chain policy; see CLAUDE.md).
|
|
71
79
|
[[tool.uv.index]]
|
|
@@ -91,34 +99,16 @@ convention = "google"
|
|
|
91
99
|
[tool.ruff.lint.mccabe]
|
|
92
100
|
max-complexity = 10
|
|
93
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.
|
|
94
105
|
[tool.ruff.lint.per-file-ignores]
|
|
95
106
|
"src/glidepath/core/randomness.py" = [
|
|
96
107
|
"S311", # Seeded Monte Carlo simulation draws, never security material (planning §4.6).
|
|
97
108
|
]
|
|
98
|
-
"src/glidepath/core/montecarlo.py" = [
|
|
99
|
-
"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.
|
|
100
|
-
]
|
|
101
|
-
"src/glidepath/core/backtest.py" = [
|
|
102
|
-
"PLR0913", # run_windows and its chunk worker mirror the §5.2 runner signature (see core/montecarlo.py).
|
|
103
|
-
]
|
|
104
109
|
"scripts/build_returns_history.py" = [
|
|
105
110
|
"S314", # xml.etree parses the JST workbook the developer supplies locally, never untrusted input.
|
|
106
111
|
]
|
|
107
|
-
"src/glidepath/core/retirement.py" = [
|
|
108
|
-
"PLR0913", # earliest_retirement_age mirrors the §5.2 runner signature (see core/montecarlo.py).
|
|
109
|
-
]
|
|
110
|
-
"src/glidepath/app/montecarlo.py" = [
|
|
111
|
-
"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.
|
|
112
|
-
]
|
|
113
|
-
"src/glidepath/app/retirement.py" = [
|
|
114
|
-
"BLE001", # Same in-flight-guard rationale as app/montecarlo.py.
|
|
115
|
-
]
|
|
116
|
-
"src/glidepath/app/drawdown.py" = [
|
|
117
|
-
"BLE001", # Same in-flight-guard rationale as app/montecarlo.py.
|
|
118
|
-
]
|
|
119
|
-
"src/glidepath/app/backtest.py" = [
|
|
120
|
-
"BLE001", # Same in-flight-guard rationale as app/montecarlo.py.
|
|
121
|
-
]
|
|
122
112
|
"tests/**" = [
|
|
123
113
|
"S101", # pytest tests use bare assert by design.
|
|
124
114
|
"PLR2004", # Literal expected values in assertions are idiomatic in tests.
|
|
@@ -133,12 +123,15 @@ max-complexity = 10
|
|
|
133
123
|
# --- mypy -------------------------------------------------------------------
|
|
134
124
|
[tool.mypy]
|
|
135
125
|
strict = true
|
|
136
|
-
|
|
126
|
+
# Not implied by strict: flags code mypy can prove unreachable — high-yield
|
|
127
|
+
# in Decimal/None-narrowing-heavy code.
|
|
128
|
+
warn_unreachable = true
|
|
137
129
|
python_version = "3.14"
|
|
138
130
|
files = ["src", "tests", "scripts"]
|
|
139
131
|
# Lets the tests' top-level script imports (e.g. `import check_dep_age`)
|
|
140
|
-
#
|
|
141
|
-
|
|
132
|
+
# and the shared test factories (`import factories`) resolve — mirrors
|
|
133
|
+
# pytest's pythonpath below.
|
|
134
|
+
mypy_path = ["scripts", "tests"]
|
|
142
135
|
|
|
143
136
|
# --- pytest / coverage ------------------------------------------------------
|
|
144
137
|
[tool.pytest.ini_options]
|
|
@@ -152,9 +145,10 @@ addopts = [
|
|
|
152
145
|
"--cov-report=xml",
|
|
153
146
|
]
|
|
154
147
|
testpaths = ["tests"]
|
|
155
|
-
# scripts/ is
|
|
156
|
-
# gates behind `make deps`/`make bump`/releases)
|
|
157
|
-
|
|
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"]
|
|
158
152
|
# Fail a stuck test fast instead of hanging CI for the runner limit: the
|
|
159
153
|
# GUI suite waits on worker pools for up to 60s, so the ceiling sits
|
|
160
154
|
# safely above that while still cutting a wedged run short.
|
|
@@ -105,6 +105,7 @@ from glidepath.app.firstrun import (
|
|
|
105
105
|
)
|
|
106
106
|
from glidepath.app.forms import (
|
|
107
107
|
ENTITY_ID_KEY,
|
|
108
|
+
OWNER_KEY,
|
|
108
109
|
ChoiceOption,
|
|
109
110
|
FactsFormData,
|
|
110
111
|
FactsFormResult,
|
|
@@ -112,6 +113,7 @@ from glidepath.app.forms import (
|
|
|
112
113
|
FieldKind,
|
|
113
114
|
FieldSpec,
|
|
114
115
|
FormError,
|
|
116
|
+
PersonFormData,
|
|
115
117
|
PlanEntityIds,
|
|
116
118
|
SectionSpec,
|
|
117
119
|
build_facts_form_view_model,
|
|
@@ -147,6 +149,13 @@ from glidepath.app.montecarlo import (
|
|
|
147
149
|
run_mode_key,
|
|
148
150
|
state_with_monte_carlo,
|
|
149
151
|
)
|
|
152
|
+
from glidepath.app.outlook import (
|
|
153
|
+
NO_OUTLOOK_MESSAGE,
|
|
154
|
+
OUTLOOK_HEADING,
|
|
155
|
+
OUTLOOK_NO_PLAN_MESSAGE,
|
|
156
|
+
OutlookPanelViewModel,
|
|
157
|
+
build_outlook_panel,
|
|
158
|
+
)
|
|
150
159
|
from glidepath.app.plan import (
|
|
151
160
|
OVERRIDE_SOURCE,
|
|
152
161
|
OverrideOutcome,
|
|
@@ -235,9 +244,13 @@ __all__ = [
|
|
|
235
244
|
"NO_BACKTEST_MESSAGE",
|
|
236
245
|
"NO_DRAWDOWN_MESSAGE",
|
|
237
246
|
"NO_MONTE_CARLO_MESSAGE",
|
|
247
|
+
"NO_OUTLOOK_MESSAGE",
|
|
238
248
|
"NO_PROJECTION_MESSAGE",
|
|
239
249
|
"NO_RETIREMENT_MESSAGE",
|
|
250
|
+
"OUTLOOK_HEADING",
|
|
251
|
+
"OUTLOOK_NO_PLAN_MESSAGE",
|
|
240
252
|
"OVERRIDE_SOURCE",
|
|
253
|
+
"OWNER_KEY",
|
|
241
254
|
"REPORT_EXPORT_FAILED_PREFIX",
|
|
242
255
|
"REPORT_NOT_WRITTEN_MESSAGE",
|
|
243
256
|
"RETIREMENT_NO_INCOME_MESSAGE",
|
|
@@ -284,8 +297,10 @@ __all__ = [
|
|
|
284
297
|
"LoadOutcome",
|
|
285
298
|
"MonteCarloMetric",
|
|
286
299
|
"MonteCarloPanelViewModel",
|
|
300
|
+
"OutlookPanelViewModel",
|
|
287
301
|
"OverrideOutcome",
|
|
288
302
|
"OverrideRow",
|
|
303
|
+
"PersonFormData",
|
|
289
304
|
"PlanEntityIds",
|
|
290
305
|
"PlanReport",
|
|
291
306
|
"PlanState",
|
|
@@ -310,6 +325,7 @@ __all__ = [
|
|
|
310
325
|
"build_drawdown_panel",
|
|
311
326
|
"build_facts_form_view_model",
|
|
312
327
|
"build_inspector_view_model",
|
|
328
|
+
"build_outlook_panel",
|
|
313
329
|
"build_plan_report",
|
|
314
330
|
"build_retirement_panel",
|
|
315
331
|
"build_scenarios_view_model",
|
|
@@ -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)
|
|
@@ -10,7 +10,6 @@ inflation source (planning §5.2). Amounts stay ``Decimal`` here:
|
|
|
10
10
|
converting them to plot coordinates is shell mechanics (§4.7).
|
|
11
11
|
"""
|
|
12
12
|
|
|
13
|
-
from collections import Counter
|
|
14
13
|
from dataclasses import dataclass
|
|
15
14
|
from decimal import Decimal
|
|
16
15
|
from typing import TYPE_CHECKING, Final
|
|
@@ -25,6 +24,7 @@ from glidepath.app.drawdown import (
|
|
|
25
24
|
DrawdownPanelViewModel,
|
|
26
25
|
build_drawdown_panel,
|
|
27
26
|
)
|
|
27
|
+
from glidepath.app.labels import numbered_unique
|
|
28
28
|
from glidepath.app.montecarlo import (
|
|
29
29
|
DEFAULT_RUN_MODE,
|
|
30
30
|
FAN_MEDIAN_LABEL,
|
|
@@ -32,6 +32,10 @@ from glidepath.app.montecarlo import (
|
|
|
32
32
|
MonteCarloPanelViewModel,
|
|
33
33
|
build_monte_carlo_panel,
|
|
34
34
|
)
|
|
35
|
+
from glidepath.app.outlook import (
|
|
36
|
+
OutlookPanelViewModel,
|
|
37
|
+
build_outlook_panel,
|
|
38
|
+
)
|
|
35
39
|
from glidepath.app.retirement import (
|
|
36
40
|
RetirementPanelViewModel,
|
|
37
41
|
build_retirement_panel,
|
|
@@ -54,6 +58,7 @@ if TYPE_CHECKING:
|
|
|
54
58
|
AssetAllocation,
|
|
55
59
|
BacktestResult,
|
|
56
60
|
EntityId,
|
|
61
|
+
Household,
|
|
57
62
|
MonteCarloResult,
|
|
58
63
|
Period,
|
|
59
64
|
PeriodReportRow,
|
|
@@ -208,9 +213,9 @@ class ChartsViewModel:
|
|
|
208
213
|
"""The projection charts screen (roadmap 8.4).
|
|
209
214
|
|
|
210
215
|
``categories`` labels the shared x axis — one label per projected
|
|
211
|
-
period: the period-start year with
|
|
212
|
-
start alongside (roadmap 9.11;
|
|
213
|
-
|
|
216
|
+
period: the period-start year with each person's age at period
|
|
217
|
+
start alongside (roadmap 9.11; both ages in household order for a
|
|
218
|
+
couple, e.g. ``2032 · 60/58`` — roadmap 9.31). ``message``
|
|
214
219
|
carries the empty-state copy when there is nothing to chart; it
|
|
215
220
|
is blank whenever ``charts`` is populated. ``allocation_note``
|
|
216
221
|
states the asset allocation each wrapper actually ran — stated
|
|
@@ -227,6 +232,7 @@ class ChartsViewModel:
|
|
|
227
232
|
message: str
|
|
228
233
|
allocation_note: str
|
|
229
234
|
monte_carlo: MonteCarloPanelViewModel
|
|
235
|
+
outlook: OutlookPanelViewModel
|
|
230
236
|
retirement: RetirementPanelViewModel
|
|
231
237
|
drawdown: DrawdownPanelViewModel
|
|
232
238
|
backtest: BacktestPanelViewModel
|
|
@@ -366,6 +372,7 @@ def build_charts_view_model(
|
|
|
366
372
|
monte_carlo=build_monte_carlo_panel(
|
|
367
373
|
state, mode, ending_pot_deflator=None, basis_suffix=suffix
|
|
368
374
|
),
|
|
375
|
+
outlook=build_outlook_panel(state),
|
|
369
376
|
retirement=build_retirement_panel(state, mode),
|
|
370
377
|
drawdown=build_drawdown_panel(state, mode),
|
|
371
378
|
backtest=build_backtest_panel(
|
|
@@ -379,7 +386,7 @@ def build_charts_view_model(
|
|
|
379
386
|
bands = _chart_bands(state, grouped, backtest_year)
|
|
380
387
|
final_rows = next(reversed(grouped.values()))
|
|
381
388
|
charts = [
|
|
382
|
-
_balances_chart(grouped, suffix, bands),
|
|
389
|
+
_balances_chart(grouped, suffix, bands, state.household),
|
|
383
390
|
_income_chart(grouped, suffix),
|
|
384
391
|
_tax_chart(grouped, suffix),
|
|
385
392
|
]
|
|
@@ -403,6 +410,7 @@ def build_charts_view_model(
|
|
|
403
410
|
ending_pot_deflator=final_rows[0].balance_deflator,
|
|
404
411
|
basis_suffix=suffix,
|
|
405
412
|
),
|
|
413
|
+
outlook=build_outlook_panel(state),
|
|
406
414
|
retirement=build_retirement_panel(state, mode),
|
|
407
415
|
drawdown=build_drawdown_panel(state, mode),
|
|
408
416
|
backtest=build_backtest_panel(
|
|
@@ -493,7 +501,9 @@ def _allocation_note(
|
|
|
493
501
|
household = state.household
|
|
494
502
|
if household is None:
|
|
495
503
|
return ""
|
|
496
|
-
labels = wrapper_display_labels(
|
|
504
|
+
labels = wrapper_display_labels(
|
|
505
|
+
(row for rows in grouped.values() for row in rows), household
|
|
506
|
+
)
|
|
497
507
|
glide = _glide_note(state)
|
|
498
508
|
parts = []
|
|
499
509
|
for person in household.persons:
|
|
@@ -512,17 +522,15 @@ def _allocation_note(
|
|
|
512
522
|
|
|
513
523
|
|
|
514
524
|
def _category_label(period: Period, rows: list[PeriodReportRow]) -> str:
|
|
515
|
-
"""One period's x-axis label: its start year, with the
|
|
525
|
+
"""One period's x-axis label: its start year, with the persons' ages.
|
|
516
526
|
|
|
517
527
|
``2032 · 60`` reads the horizon in ages as well as calendar years
|
|
518
|
-
(roadmap 9.11)
|
|
519
|
-
|
|
520
|
-
couples activation, 9.4).
|
|
528
|
+
(roadmap 9.11); a two-person period labels both ages in household
|
|
529
|
+
order — ``2032 · 60/58`` (roadmap 9.31).
|
|
521
530
|
"""
|
|
522
531
|
year = str(period.start.year)
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
return year
|
|
532
|
+
ages = "/".join(str(row.age_at_period_start) for row in rows)
|
|
533
|
+
return f"{year} · {ages}" if ages else year
|
|
526
534
|
|
|
527
535
|
|
|
528
536
|
def _rows_by_period(
|
|
@@ -573,29 +581,34 @@ def _chart(
|
|
|
573
581
|
)
|
|
574
582
|
|
|
575
583
|
|
|
576
|
-
def wrapper_display_labels(
|
|
584
|
+
def wrapper_display_labels(
|
|
585
|
+
rows: Iterable[PeriodReportRow], household: Household | None = None
|
|
586
|
+
) -> dict[EntityId, str]:
|
|
577
587
|
"""A display label per wrapper, in first-seen order.
|
|
578
588
|
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
589
|
+
A wrapper the user named shows its own label; the rest read their
|
|
590
|
+
kind name (entity ids are generated UUIDs, so they are never shown
|
|
591
|
+
as copy). Any name repeated across the final set — several unnamed
|
|
592
|
+
wrappers of one kind, a label colliding with a kind name, or one
|
|
593
|
+
label given twice in a hand-edited plan file — is numbered in
|
|
594
|
+
first-seen order (:func:`~glidepath.app.labels.numbered_unique`).
|
|
595
|
+
Shared with the cash-flow export (9.19), which columns its
|
|
596
|
+
balances the same way the balances chart stacks them.
|
|
584
597
|
"""
|
|
585
|
-
|
|
598
|
+
named: dict[EntityId, str] = {}
|
|
599
|
+
if household is not None:
|
|
600
|
+
named = {
|
|
601
|
+
wrapper.id: wrapper.label
|
|
602
|
+
for person in household.persons
|
|
603
|
+
for wrapper in person.wrappers
|
|
604
|
+
if wrapper.label is not None
|
|
605
|
+
}
|
|
606
|
+
bases: dict[EntityId, str] = {}
|
|
586
607
|
for row in rows:
|
|
587
608
|
for entry in row.wrapper_balances:
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
labels: dict[EntityId, str] = {}
|
|
592
|
-
for wrapper_id, kind in kinds.items():
|
|
593
|
-
if counts[kind] == 1:
|
|
594
|
-
labels[wrapper_id] = kind
|
|
595
|
-
else:
|
|
596
|
-
numbered[kind] += 1
|
|
597
|
-
labels[wrapper_id] = f"{kind} {numbered[kind]}"
|
|
598
|
-
return labels
|
|
609
|
+
base = named.get(entry.wrapper_id) or format_wrapper_kind(entry.kind)
|
|
610
|
+
bases.setdefault(entry.wrapper_id, base)
|
|
611
|
+
return numbered_unique(bases)
|
|
599
612
|
|
|
600
613
|
|
|
601
614
|
def _deflated(
|
|
@@ -716,10 +729,13 @@ def _balances_chart(
|
|
|
716
729
|
grouped: dict[Period, list[PeriodReportRow]],
|
|
717
730
|
suffix: str,
|
|
718
731
|
bands: tuple[ChartBand, ...],
|
|
732
|
+
household: Household | None,
|
|
719
733
|
) -> ChartSpec:
|
|
720
734
|
"""Closing balance per wrapper per period, stacked to the total."""
|
|
721
735
|
series = []
|
|
722
|
-
labels = wrapper_display_labels(
|
|
736
|
+
labels = wrapper_display_labels(
|
|
737
|
+
(row for rows in grouped.values() for row in rows), household
|
|
738
|
+
)
|
|
723
739
|
for wrapper_id, label in labels.items():
|
|
724
740
|
values = []
|
|
725
741
|
for rows in grouped.values():
|
|
@@ -70,6 +70,8 @@ ASSUMPTION_NAMES: Final[Mapping[str, str]] = {
|
|
|
70
70
|
"Annuity rate (inflation-linked, single life, age 65)"
|
|
71
71
|
),
|
|
72
72
|
"annuity.age_adjustment": "Annuity rate age adjustment",
|
|
73
|
+
"db.survivor_fraction": "DB survivor pension fraction",
|
|
74
|
+
"spending.survivor_multiplier": "Survivor spending multiplier",
|
|
73
75
|
}
|
|
74
76
|
"""Human display names for the shipped assumption keys (roadmap 8.3).
|
|
75
77
|
|
|
@@ -127,6 +129,13 @@ def format_recorded(moment: datetime) -> str:
|
|
|
127
129
|
return moment.date().isoformat()
|
|
128
130
|
|
|
129
131
|
|
|
132
|
+
def _format_temporal(value: date) -> str:
|
|
133
|
+
"""A datetime (a recorded-on moment, and a date subclass) or a date."""
|
|
134
|
+
if isinstance(value, datetime):
|
|
135
|
+
return format_recorded(value)
|
|
136
|
+
return format_date(value)
|
|
137
|
+
|
|
138
|
+
|
|
130
139
|
def format_value(value: object) -> str:
|
|
131
140
|
"""Any fact, decision, or assumption value as display text.
|
|
132
141
|
|
|
@@ -134,12 +143,12 @@ def format_value(value: object) -> str:
|
|
|
134
143
|
numbers, enum choices, policy strings, and structured tables
|
|
135
144
|
(rendered as compact ``key=value`` pairs, truncated when long).
|
|
136
145
|
"""
|
|
146
|
+
if isinstance(value, bool): # before any numeric type: bool is an int
|
|
147
|
+
return "Yes" if value else "No"
|
|
137
148
|
if isinstance(value, Money):
|
|
138
149
|
return format_money(value)
|
|
139
|
-
if isinstance(value, datetime): # before date: datetime is a date subclass
|
|
140
|
-
return format_recorded(value)
|
|
141
150
|
if isinstance(value, date):
|
|
142
|
-
return
|
|
151
|
+
return _format_temporal(value)
|
|
143
152
|
if isinstance(value, Enum):
|
|
144
153
|
generic = str(value.name).replace("_", " ").capitalize()
|
|
145
154
|
return _ENUM_LABELS.get(value, generic)
|