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/copy.py ADDED
@@ -0,0 +1,174 @@
1
+ """User-facing product copy (planning §1, §4.7).
2
+
3
+ The disclaimer is a product requirement: shown on first run and in
4
+ About, preserved in exports and the README. The wording here is the
5
+ canonical in-app copy and matches the README's Disclaimer section.
6
+ """
7
+
8
+ from typing import Final
9
+
10
+ APP_NAME: Final = "glidepath"
11
+
12
+ DISCLAIMER_TITLE: Final = "Before you start"
13
+
14
+ DISCLAIMER_BODY: Final = (
15
+ "Glidepath is a personal modelling tool for exploring retirement "
16
+ "scenarios. It is not financial advice and is not regulated; its "
17
+ "outputs depend on assumptions that will not match reality. Do not "
18
+ "make financial decisions based solely on this tool."
19
+ )
20
+
21
+ DISCLAIMER_ACCEPT_LABEL: Final = "I understand — continue"
22
+
23
+ DISCLAIMER_DECLINE_LABEL: Final = "Quit"
24
+
25
+ ABOUT_TITLE: Final = f"About {APP_NAME}"
26
+
27
+ HELP_MENU_LABEL: Final = "Help"
28
+
29
+ HELP_GUIDE_TITLE: Final = f"How to use {APP_NAME}"
30
+
31
+ DATE_PICKER_TOOLTIP: Final = "Pick a date from the calendar"
32
+
33
+ HELP_GUIDE_INTRO: Final = (
34
+ "Glidepath projects a retirement plan from facts you state, choices "
35
+ "you make, and assumptions you can always inspect and override. Each "
36
+ "tab is one part of that loop."
37
+ )
38
+
39
+ HELP_GUIDE_SECTIONS: Final[tuple[tuple[str, str], ...]] = (
40
+ (
41
+ "Start from the example",
42
+ (
43
+ "On a fresh install glidepath opens with an example plan "
44
+ "already projected, so every tab has something to show; the "
45
+ "note under the form's buttons tells you while the example is "
46
+ "on screen. Replace its values with your own facts, or press "
47
+ '"Clear the form" to start blank. Once you have saved or '
48
+ "opened a plan of your own, the last one you used reopens at "
49
+ "launch instead."
50
+ ),
51
+ ),
52
+ (
53
+ "Facts — enter what you know",
54
+ (
55
+ "The Facts tab captures what you state: your date of birth and "
56
+ "tax residency, planned retirement age, household spending, "
57
+ "your state pension record, each savings wrapper (workplace "
58
+ "pension, SIPP, ISA, LISA, general account, or cash) with its "
59
+ "balance, contributions, and optionally its own equity "
60
+ "allocation percentage — blank follows the de-risking glide "
61
+ "path, 100 models an all-equity wrapper — any defined "
62
+ "benefit pension, and "
63
+ "any planned annuity purchase — converting part of your "
64
+ "pension pot into lifetime income at an age you choose. "
65
+ "Drawdown is the default: anything you do not annuitise "
66
+ "stays invested, a fraction of 1 annuitises the whole pot, "
67
+ "and several purchases at different ages annuitise in "
68
+ "stages. "
69
+ "Add one wrapper, DB, or annuity section per item. Dates can "
70
+ "be typed or picked from the calendar assist, and the "
71
+ '"as of" dates on balances and your state pension forecast '
72
+ "default to today when left blank. Your state pension needs "
73
+ "your official DWP forecast (gov.uk/check-state-pension) — "
74
+ "the app never re-derives what DWP has already computed. "
75
+ 'Press "Save facts and project" to run the '
76
+ "projection — if anything cannot be read, the message under "
77
+ "the buttons says which field to fix and nothing is saved "
78
+ "until it parses."
79
+ ),
80
+ ),
81
+ (
82
+ "Charts — see the projection",
83
+ (
84
+ "The Charts tab draws the projection, one bar per tax year "
85
+ "labelled with the year and your age at its start: wrapper "
86
+ "balances, income composition, and tax due. An "
87
+ '"Invested as" line states the asset mix each wrapper '
88
+ "actually ran — your stated equity split, or the glide path "
89
+ "with whether it is the shipped default or your override — "
90
+ "so the modelled allocation is never a silent assumption. "
91
+ "Hover any bar, line, or band for "
92
+ "the exact figures, and switch between real (today's money) "
93
+ "and nominal presentation with the basis toggle. Switch the "
94
+ "run mode to Monte Carlo, choose the paths and seed, and "
95
+ "press Run Monte Carlo to read the success metrics and open "
96
+ "a Monte Carlo fan chart on its own tab — nested percentile "
97
+ "bands deepening in colour toward the median line, each band "
98
+ "the central share of simulated paths that closed inside it; "
99
+ "the same seed and inputs always reproduce the same result. "
100
+ "Runs execute in the background — the buttons disable while "
101
+ "one is in flight — and any change to the plan clears a held "
102
+ "result, so the charts never show a run that no longer "
103
+ "matches the plan on screen. "
104
+ "The historical backtest card replays the plan over every "
105
+ "rolling window of world market history (a global equity "
106
+ "index in sterling terms, UK gilts and cash, deflated by UK "
107
+ "inflation): press Run backtest to read the share of "
108
+ "historical starting years the plan survives, the best and "
109
+ "worst starting years, and — over the balances chart — the "
110
+ "actual balance paths those years would have produced, plus "
111
+ "any starting year you type into the card. Sequence-of-"
112
+ "returns risk that independent Monte Carlo draws cannot "
113
+ "reproduce. "
114
+ 'The "When can I retire?" card answers with the earliest '
115
+ "retirement age at which the plan sustains a target income — "
116
+ "a replacement rate you choose (66% of your employment income "
117
+ "by default) — on the selected run mode's basis: met with no "
118
+ "shortfall deterministically, or with at least your chosen "
119
+ "Monte Carlo success rate. "
120
+ 'The "How much can I draw down?" card asks the same question '
121
+ "the other way around: choose a retirement age (your planned "
122
+ "one by default) and it answers with the highest net annual "
123
+ "income, in today's money, the plan sustains from that age — "
124
+ "on the same selected basis."
125
+ ),
126
+ ),
127
+ (
128
+ "Scenarios — compare what-ifs",
129
+ (
130
+ "The Scenarios tab compares variants of your plan. Add a named "
131
+ "scenario, then give it overrides: each override changes one "
132
+ "of your decisions (like your retirement age or contribution "
133
+ "choices) or one assumption — never a stated fact. The "
134
+ "comparison table and chart show every scenario against the "
135
+ "base plan on the metric and money basis you pick."
136
+ ),
137
+ ),
138
+ (
139
+ "Stated vs assumed — check every number",
140
+ (
141
+ 'The stated-vs-assumed tab answers "which of these numbers '
142
+ 'did I state, and which did the app assume?" It lists your '
143
+ "facts, every assumption with its value, source, date, and "
144
+ "whether you overrode the default, your decisions, and the "
145
+ "plan's structure. Double-click an assumption to override its "
146
+ "value in place; the projection re-runs immediately."
147
+ ),
148
+ ),
149
+ (
150
+ "Save and reopen your plan",
151
+ (
152
+ 'File → "Save plan" writes everything — facts, decisions, '
153
+ "overrides, and scenarios — to a plan file on your computer "
154
+ '("Save plan as…" picks a new file), and "Open plan…" loads '
155
+ "one back; the last plan you used reopens on the next "
156
+ "launch. All data stays local; nothing is ever transmitted."
157
+ ),
158
+ ),
159
+ (
160
+ "Export the plan",
161
+ (
162
+ 'File → "Export cash flow (CSV)" writes the projection\'s '
163
+ "per-year table — every income, tax, contribution, fee, and "
164
+ "balance figure exactly as charted, in the money basis the "
165
+ "Charts tab has selected — for a spreadsheet to audit or "
166
+ 'extend. "Export report (PDF)" prints the whole plan: your '
167
+ "inputs with their stated-vs-assumed provenance, the "
168
+ "projection charts, Monte Carlo metrics when a run is held, "
169
+ "and the scenario comparison when scenarios exist. Both "
170
+ "exports carry the disclaimer."
171
+ ),
172
+ ),
173
+ ("Not financial advice", DISCLAIMER_BODY),
174
+ )
@@ -0,0 +1,148 @@
1
+ """Display formatting for the app layer (planning §4.7).
2
+
3
+ Decimal→display conversion lives here so shells render pre-formatted
4
+ strings and never touch domain values. The inverse direction — parsing
5
+ user-entered text back into domain values — lives in
6
+ :mod:`glidepath.app.forms`.
7
+ """
8
+
9
+ from collections.abc import Mapping
10
+ from datetime import date, datetime
11
+ from decimal import Decimal
12
+ from enum import Enum
13
+ from typing import Final
14
+
15
+ from glidepath.core import LifeStage, Money
16
+
17
+ _MAX_STRUCTURED_LENGTH = 120
18
+
19
+ _ENUM_LABELS: Final[Mapping[Enum, str]] = {
20
+ LifeStage.GO_GO: "Go-go",
21
+ LifeStage.SLOW_GO: "Slow-go",
22
+ LifeStage.NO_GO: "No-go",
23
+ }
24
+ """Enum members whose copy the generic underscore rule would mangle
25
+ ("Go go"); the retirement sub-stages read hyphenated (issue #114)."""
26
+
27
+ WRAPPER_KIND_NAMES: Final[Mapping[str, str]] = {
28
+ "uk.workplace_dc": "Workplace DC",
29
+ "uk.sipp": "SIPP",
30
+ "uk.isa": "ISA",
31
+ "uk.lisa": "Lifetime ISA",
32
+ "uk.gia": "General investment account",
33
+ "uk.cash": "Cash savings",
34
+ }
35
+ """Display names for the shipped wrapper kinds (planning §2, roadmap 9.2)."""
36
+
37
+
38
+ def format_wrapper_kind(kind: object) -> str:
39
+ """A wrapper kind id as its display name, falling back to the id."""
40
+ text = str(kind)
41
+ return WRAPPER_KIND_NAMES.get(text, text)
42
+
43
+
44
+ ASSUMPTION_NAMES: Final[Mapping[str, str]] = {
45
+ "inflation.cpi": "Inflation (CPI)",
46
+ "earnings.growth.real": "Earnings growth (above inflation)",
47
+ "returns.equity.real": "Equity return (above inflation)",
48
+ "returns.bonds.real": "Bond return (above inflation)",
49
+ "returns.cash.real": "Cash return (above inflation)",
50
+ "volatility.equity": "Equity volatility",
51
+ "volatility.bonds": "Bond volatility",
52
+ "volatility.cash": "Cash volatility",
53
+ "correlation.equity_bonds": "Equity-bond correlation",
54
+ "correlation.equity_cash": "Equity-cash correlation",
55
+ "correlation.bonds_cash": "Bond-cash correlation",
56
+ "fees.platform": "Platform fee",
57
+ "fees.fund": "Fund fee",
58
+ "yield.equity": "Equity yield",
59
+ "yield.bonds": "Bond yield",
60
+ "yield.cash": "Cash yield",
61
+ "horizon.planning_age": "Planning horizon age",
62
+ "glidepath.default_shape": "Default glide path shape",
63
+ "policy.state_pension.uprating": "State pension uprating policy",
64
+ "policy.tax.future_years": "Future tax years policy",
65
+ "annuity.level.single.65": "Annuity rate (level, single life, age 65)",
66
+ "annuity.escalating3.single.65": (
67
+ "Annuity rate (3% escalating, single life, age 65)"
68
+ ),
69
+ "annuity.inflation_linked.single.65": (
70
+ "Annuity rate (inflation-linked, single life, age 65)"
71
+ ),
72
+ "annuity.age_adjustment": "Annuity rate age adjustment",
73
+ }
74
+ """Human display names for the shipped assumption keys (roadmap 8.3).
75
+
76
+ Screens show these instead of the raw dotted ids; the id stays
77
+ available (tooltips, override targeting) because it is what scenario
78
+ overrides and persisted plans address."""
79
+
80
+
81
+ def format_assumption_key(key: object) -> str:
82
+ """An assumption key as its display name, falling back to the id."""
83
+ text = str(key)
84
+ return ASSUMPTION_NAMES.get(text, text)
85
+
86
+
87
+ def _format_mapping(value: Mapping[object, object]) -> str:
88
+ """A structured table as compact ``key=value`` pairs, truncated."""
89
+ rendered = "; ".join(f"{key}={format_value(entry)}" for key, entry in value.items())
90
+ if len(rendered) > _MAX_STRUCTURED_LENGTH:
91
+ return rendered[: _MAX_STRUCTURED_LENGTH - 1] + "…"
92
+ return rendered
93
+
94
+
95
+ def format_money(value: Money) -> str:
96
+ """A money amount as pounds and pence, e.g. ``£1,234.56``."""
97
+ amount = value.quantized().amount
98
+ if amount < 0:
99
+ return f"-£{-amount:,.2f}"
100
+ return f"£{amount:,.2f}"
101
+
102
+
103
+ def format_percent(value: Decimal) -> str:
104
+ """A fraction as a percentage to one decimal place, e.g. ``95.0%``."""
105
+ return f"{value * Decimal(100):.1f}%"
106
+
107
+
108
+ def format_share(value: Decimal) -> str:
109
+ """A fraction as a whole-reading percentage, e.g. ``80%`` or ``62.5%``.
110
+
111
+ Trailing zeros are trimmed — allocation copy reads "80% equity",
112
+ never "80.0% equity".
113
+ """
114
+ text = format(value * Decimal(100), "f")
115
+ if "." in text:
116
+ text = text.rstrip("0").rstrip(".")
117
+ return f"{text}%"
118
+
119
+
120
+ def format_date(value: date) -> str:
121
+ """A date in ISO format — the one date format the product uses."""
122
+ return value.isoformat()
123
+
124
+
125
+ def format_recorded(moment: datetime) -> str:
126
+ """A recorded-on timestamp shown as its (UTC) calendar date."""
127
+ return moment.date().isoformat()
128
+
129
+
130
+ def format_value(value: object) -> str:
131
+ """Any fact, decision, or assumption value as display text.
132
+
133
+ Covers every value type the domain model wraps: money, dates,
134
+ numbers, enum choices, policy strings, and structured tables
135
+ (rendered as compact ``key=value`` pairs, truncated when long).
136
+ """
137
+ if isinstance(value, Money):
138
+ return format_money(value)
139
+ if isinstance(value, datetime): # before date: datetime is a date subclass
140
+ return format_recorded(value)
141
+ if isinstance(value, date):
142
+ return format_date(value)
143
+ if isinstance(value, Enum):
144
+ generic = str(value.name).replace("_", " ").capitalize()
145
+ return _ENUM_LABELS.get(value, generic)
146
+ if isinstance(value, Mapping):
147
+ return _format_mapping(value)
148
+ return str(value)