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
@@ -0,0 +1,278 @@
1
+ """Wrapper entity and the wrapper-rules boundary (roadmap 3.1; planning §4.2, §5.1).
2
+
3
+ A :class:`Wrapper` is one tax-advantaged (or, later, taxable) account a
4
+ person holds. Wrapper *kinds* are opaque region-defined ids
5
+ (``"uk.workplace_dc"``, ``"uk.sipp"``, ``"uk.isa"``): the core never
6
+ enumerates account types — the region's :class:`WrapperRuleset` maps a
7
+ kind to its rules (limits, relief mechanics, access gates, and the tax
8
+ treatment of money going in, growing, and coming out), so nothing
9
+ region-specific leaks into the core model (planning §4.2).
10
+
11
+ The tax-treatment vocabulary here is deliberately generic — relieved vs
12
+ taxed on the way in, tax-free vs taxable growth, and tax-free, taxable,
13
+ or partially tax-free on the way out — so any region can describe an
14
+ EET pension or a TEE savings account without the core naming either.
15
+ """
16
+
17
+ from dataclasses import dataclass
18
+ from decimal import Decimal
19
+ from enum import Enum, auto
20
+ from typing import TYPE_CHECKING, NewType, Protocol
21
+
22
+ from glidepath.core.money import Money
23
+
24
+ if TYPE_CHECKING:
25
+ from datetime import date
26
+
27
+ from glidepath.core.contributions import ContributionSchedule
28
+ from glidepath.core.entities import EntityId
29
+ from glidepath.core.investments import AssetAllocation, FeeSchedule
30
+ from glidepath.core.money import Rate
31
+ from glidepath.core.periods import Period
32
+ from glidepath.core.provenance import Fact
33
+
34
+ WrapperKindId = NewType("WrapperKindId", str)
35
+ """Opaque region-defined wrapper kind id (e.g. ``"uk.sipp"``).
36
+
37
+ The core never interprets it; the region's :class:`WrapperRuleset` does
38
+ (planning §4.2) — the same pattern as ``TaxResidencyId``.
39
+ """
40
+
41
+ _ZERO = Money(Decimal(0))
42
+ _ONE = Decimal(1)
43
+
44
+
45
+ class ContributionTaxTreatment(Enum):
46
+ """How money going *into* a wrapper is treated (planning §4.2)."""
47
+
48
+ TAX_RELIEVED = auto()
49
+ """Contributions attract tax relief (e.g. a pension: EET in)."""
50
+
51
+ FROM_TAXED_INCOME = auto()
52
+ """Contributions are paid from post-tax income with no relief."""
53
+
54
+
55
+ class GrowthTaxTreatment(Enum):
56
+ """How growth *inside* a wrapper is treated (planning §4.2)."""
57
+
58
+ TAX_FREE = auto()
59
+ TAXABLE = auto()
60
+ """Growth is taxable as it arises (e.g. a bare account, roadmap 9.2)."""
61
+
62
+
63
+ class WithdrawalTaxTreatment(Enum):
64
+ """How money coming *out of* a wrapper is treated (planning §4.2)."""
65
+
66
+ TAX_FREE = auto()
67
+ TAXABLE_INCOME = auto()
68
+ PARTIALLY_TAX_FREE = auto()
69
+ """A ``tax_free_fraction`` is free; the remainder is taxable income."""
70
+
71
+
72
+ class ReliefMechanic(Enum):
73
+ """How contribution tax relief is delivered (planning §5.1).
74
+
75
+ ``RELIEF_AT_SOURCE``: contributions leave taxed pay and the provider
76
+ reclaims basic-rate relief; higher rates arrive via assessment.
77
+ ``NET_PAY``: contributions leave pay before tax, so full marginal
78
+ relief is immediate. Which mechanics a wrapper kind may use is the
79
+ region's call (:meth:`WrapperRuleset.permitted_relief_mechanics`);
80
+ the mechanics themselves land with ``ContributionSchedule``
81
+ (roadmap 3.2).
82
+ """
83
+
84
+ RELIEF_AT_SOURCE = auto()
85
+ NET_PAY = auto()
86
+
87
+
88
+ @dataclass(frozen=True, slots=True)
89
+ class WrapperTaxTreatment:
90
+ """One wrapper kind's in/during/out tax treatment (planning §4.2).
91
+
92
+ ``tax_free_fraction`` is required exactly when withdrawals are
93
+ ``PARTIALLY_TAX_FREE`` and must be strictly between 0 and 1 — a
94
+ fraction of 0 or 1 is just ``TAXABLE_INCOME`` or ``TAX_FREE`` and
95
+ must be stated as such.
96
+ """
97
+
98
+ contributions: ContributionTaxTreatment
99
+ growth: GrowthTaxTreatment
100
+ withdrawals: WithdrawalTaxTreatment
101
+ tax_free_fraction: Rate | None = None
102
+
103
+ def __post_init__(self) -> None:
104
+ """Require the fraction exactly when the treatment uses one."""
105
+ partial = self.withdrawals is WithdrawalTaxTreatment.PARTIALLY_TAX_FREE
106
+ if partial and self.tax_free_fraction is None:
107
+ msg = "PARTIALLY_TAX_FREE withdrawals require a tax_free_fraction"
108
+ raise ValueError(msg)
109
+ if not partial and self.tax_free_fraction is not None:
110
+ msg = f"{self.withdrawals.name} withdrawals do not take a tax_free_fraction"
111
+ raise ValueError(msg)
112
+ if partial and self.tax_free_fraction is not None:
113
+ fraction = self.tax_free_fraction.value
114
+ if not 0 < fraction < _ONE:
115
+ msg = "tax_free_fraction must be strictly between 0 and 1"
116
+ raise ValueError(msg)
117
+
118
+
119
+ @dataclass(frozen=True, slots=True)
120
+ class ContributionCap:
121
+ """One annual limit a contribution counts against (roadmap 9.2).
122
+
123
+ ``group`` is an opaque region-defined allowance id: contributions
124
+ to every kind naming the same group share that group's annual
125
+ budget (e.g. the UK's overall ISA allowance across ISA and LISA),
126
+ and one kind may name several groups (a UK LISA consumes its own
127
+ sub-allowance *and* the overall allowance).
128
+ """
129
+
130
+ group: str
131
+ limit: Money
132
+
133
+ def __post_init__(self) -> None:
134
+ """Reject unnamed groups and negative limits."""
135
+ if not self.group:
136
+ msg = "ContributionCap.group must not be empty"
137
+ raise ValueError(msg)
138
+ if self.limit < _ZERO:
139
+ msg = "ContributionCap.limit must be non-negative"
140
+ raise ValueError(msg)
141
+
142
+
143
+ @dataclass(frozen=True, slots=True)
144
+ class ContributionTerms:
145
+ """One kind's contribution terms for one person and period (§4.2).
146
+
147
+ ``caps`` are the annual allowances the contribution counts against
148
+ (empty: uncapped here — any cross-wrapper measure, like a pension
149
+ annual allowance, is the region's contribution-checking concern).
150
+ ``bonus_rate`` is a government bonus added on top of the member's
151
+ contribution (e.g. the UK LISA's 25%) — distinct from tax relief:
152
+ it never extends tax bands and never counts against the caps.
153
+ ``window`` is the exact date span in which contributions are
154
+ permitted at all (§4.1 eligibility windows — e.g. LISA
155
+ contributions run from 18 to the eve of the 50th birthday);
156
+ ``None`` means unrestricted. The engine intersects it with the
157
+ period *and* the run window and pro-rates scheduled amounts by
158
+ whole months — a date span, not a pre-computed fraction, because
159
+ only the engine knows the run window it must intersect with.
160
+ """
161
+
162
+ caps: tuple[ContributionCap, ...] = ()
163
+ bonus_rate: Rate | None = None
164
+ window: Period | None = None
165
+
166
+
167
+ @dataclass(frozen=True, slots=True)
168
+ class Wrapper:
169
+ """One account a person holds, of an opaque region-defined kind.
170
+
171
+ Balances are user-stated facts (planning §5.1). For pension kinds,
172
+ ``balance`` is the *uncrystallised* value and ``crystallised_balance``
173
+ holds funds already designated to drawdown — making an
174
+ already-in-drawdown user modellable (no fresh tax-free cash on
175
+ crystallised funds, planning §5.1); non-pension kinds leave it
176
+ ``None``. ``contributions`` is the wrapper's planned contribution
177
+ schedule, if any (roadmap 3.2). ``allocation`` is the wrapper's own
178
+ asset split; ``None`` means the person's glide path supplies it each
179
+ period (roadmap 3.5). ``fees`` is the wrapper's fee schedule;
180
+ ``None`` means the shipped fee assumptions apply (planning §7,
181
+ roadmap 3.4).
182
+ """
183
+
184
+ id: EntityId
185
+ kind: WrapperKindId
186
+ balance: Fact[Money]
187
+ crystallised_balance: Fact[Money] | None = None
188
+ contributions: ContributionSchedule | None = None
189
+ allocation: AssetAllocation | None = None
190
+ fees: FeeSchedule | None = None
191
+
192
+ def __post_init__(self) -> None:
193
+ """Reject negative balances."""
194
+ if self.balance.value < _ZERO:
195
+ msg = "Wrapper.balance must be non-negative"
196
+ raise ValueError(msg)
197
+ if (
198
+ self.crystallised_balance is not None
199
+ and self.crystallised_balance.value < _ZERO
200
+ ):
201
+ msg = "Wrapper.crystallised_balance must be non-negative"
202
+ raise ValueError(msg)
203
+
204
+
205
+ class WrapperRuleset(Protocol):
206
+ """Region-supplied wrapper rules (planning §4.2).
207
+
208
+ Maps an opaque :data:`WrapperKindId` to its rules: contribution
209
+ limits, permitted relief mechanics, access gates, and in/during/out
210
+ tax treatment. Every period-based query resolves the region's data
211
+ for that period, so a query outside data coverage fails loudly
212
+ rather than answering from the wrong year. An unknown kind is an
213
+ error, never a default.
214
+ """
215
+
216
+ def tax_treatment(self, kind: WrapperKindId, period: Period) -> WrapperTaxTreatment:
217
+ """The in/during/out tax treatment of ``kind`` during ``period``."""
218
+ ...
219
+
220
+ def contribution_terms(
221
+ self, kind: WrapperKindId, date_of_birth: date, period: Period
222
+ ) -> ContributionTerms:
223
+ """The contribution terms of ``kind`` for one person and period.
224
+
225
+ Caps are per person per year, shared across wrappers through
226
+ their allowance groups (:class:`ContributionCap`); the bonus
227
+ rate and contribution window are likewise the region's call
228
+ (roadmap 9.2). Cross-wrapper measures like a pension annual
229
+ allowance stay the region's contribution-checking concern
230
+ (roadmap 3.3).
231
+ """
232
+ ...
233
+
234
+ def permitted_relief_mechanics(
235
+ self, kind: WrapperKindId
236
+ ) -> frozenset[ReliefMechanic]:
237
+ """The relief mechanics ``kind`` may operate (empty: no relief)."""
238
+ ...
239
+
240
+ def bears_default_fees(self, kind: WrapperKindId) -> bool:
241
+ """Whether ``kind`` bears the shipped default fee assumptions.
242
+
243
+ The ``fees.platform``/``fees.fund`` defaults describe
244
+ platform-administered investment accounts; a kind whose real
245
+ accounts price no such charges (e.g. a bare cash savings
246
+ account) answers ``False`` and, absent an explicit
247
+ :class:`~glidepath.core.investments.FeeSchedule` on the
248
+ wrapper, is charged no fees at all. A wrapper's own stated
249
+ schedule always applies regardless of this answer.
250
+ """
251
+ ...
252
+
253
+ def lump_sum_allowance(self, period: Period) -> Money | None:
254
+ """The lifetime cap on tax-free cash from pension kinds (§5.2).
255
+
256
+ A per-person cap on the cumulative tax-free elements paid from
257
+ partially-tax-free (pension) wrappers — the UK's lump sum
258
+ allowance (planning §6). ``None`` means the region has no such
259
+ cap. The engine tracks usage across the run, seeded from the
260
+ person's ``lsa_used`` fact (roadmap 5.2); this query only
261
+ supplies the period's cap figure.
262
+ """
263
+ ...
264
+
265
+ def is_access_open(
266
+ self, kind: WrapperKindId, date_of_birth: date, period: Period
267
+ ) -> bool:
268
+ """Whether *new* access to ``kind`` may open in ``period``.
269
+
270
+ Follows the §4.1 access-gate convention: open only if any access
271
+ age is attained on or before the period's first day. This gates
272
+ new access only — e.g. crystallising pension funds; funds the
273
+ person has already accessed (a wrapper's crystallised balance)
274
+ are never re-gated by a later rise in the access age (planning
275
+ §5.1) — withdrawals from those are the decumulation logic's
276
+ concern (§5.2).
277
+ """
278
+ ...
@@ -0,0 +1,6 @@
1
+ """PySide6 desktop shell (roadmap 8.1; planning §4.7).
2
+
3
+ Thin by policy: widgets bind view models from ``glidepath.app`` and
4
+ forward user actions back. No domain logic, no formatting, no copy —
5
+ those live in the app layer so a future web shell can reuse them.
6
+ """
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file