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.
- glidepath/__init__.py +3 -0
- glidepath/app/__init__.py +364 -0
- glidepath/app/backtest.py +281 -0
- glidepath/app/charts.py +759 -0
- glidepath/app/copy.py +174 -0
- glidepath/app/display.py +148 -0
- glidepath/app/drawdown.py +436 -0
- glidepath/app/example.py +66 -0
- glidepath/app/exports.py +487 -0
- glidepath/app/files.py +249 -0
- glidepath/app/firstrun.py +114 -0
- glidepath/app/forms.py +1750 -0
- glidepath/app/inspector.py +506 -0
- glidepath/app/labels.py +66 -0
- glidepath/app/montecarlo.py +399 -0
- glidepath/app/plan.py +354 -0
- glidepath/app/retirement.py +446 -0
- glidepath/app/scenarios.py +831 -0
- glidepath/app/shell.py +185 -0
- glidepath/app/tables.py +138 -0
- glidepath/core/__init__.py +390 -0
- glidepath/core/annuities.py +240 -0
- glidepath/core/backtest.py +514 -0
- glidepath/core/comparison.py +278 -0
- glidepath/core/config.py +82 -0
- glidepath/core/contributions.py +337 -0
- glidepath/core/engine.py +2811 -0
- glidepath/core/entities.py +264 -0
- glidepath/core/glide.py +289 -0
- glidepath/core/investments.py +175 -0
- glidepath/core/money.py +107 -0
- glidepath/core/montecarlo.py +609 -0
- glidepath/core/pensions.py +298 -0
- glidepath/core/periods.py +367 -0
- glidepath/core/provenance.py +271 -0
- glidepath/core/randomness.py +128 -0
- glidepath/core/region.py +46 -0
- glidepath/core/reporting.py +231 -0
- glidepath/core/results.py +504 -0
- glidepath/core/retirement.py +291 -0
- glidepath/core/returns.py +312 -0
- glidepath/core/scenarios.py +579 -0
- glidepath/core/state_pension.py +264 -0
- glidepath/core/tax.py +139 -0
- glidepath/core/withdrawals.py +461 -0
- glidepath/core/wrappers.py +278 -0
- glidepath/gui/__init__.py +6 -0
- glidepath/gui/assets/icon_128.png +0 -0
- glidepath/gui/assets/icon_16.png +0 -0
- glidepath/gui/assets/icon_24.png +0 -0
- glidepath/gui/assets/icon_256.png +0 -0
- glidepath/gui/assets/icon_32.png +0 -0
- glidepath/gui/assets/icon_48.png +0 -0
- glidepath/gui/assets/icon_64.png +0 -0
- glidepath/gui/assets/wordmark.png +0 -0
- glidepath/gui/charts.py +829 -0
- glidepath/gui/forms.py +359 -0
- glidepath/gui/inspector.py +186 -0
- glidepath/gui/main.py +51 -0
- glidepath/gui/scenarios.py +402 -0
- glidepath/gui/style.py +376 -0
- glidepath/gui/tableview.py +67 -0
- glidepath/gui/widgets.py +989 -0
- glidepath/persistence/__init__.py +48 -0
- glidepath/persistence/assumptions.py +112 -0
- glidepath/persistence/decode.py +747 -0
- glidepath/persistence/document.py +101 -0
- glidepath/persistence/encode.py +433 -0
- glidepath/persistence/migrations.py +158 -0
- glidepath/persistence/values.py +298 -0
- glidepath/py.typed +0 -0
- glidepath/regions/__init__.py +7 -0
- glidepath/regions/uk/__init__.py +189 -0
- glidepath/regions/uk/ages.py +156 -0
- glidepath/regions/uk/contributions.py +717 -0
- glidepath/regions/uk/data/age_rules.toml +78 -0
- glidepath/regions/uk/data/assumptions_default.toml +170 -0
- glidepath/regions/uk/data/returns_history.toml +150 -0
- glidepath/regions/uk/data/tax_year_2026_27.toml +98 -0
- glidepath/regions/uk/extension.py +479 -0
- glidepath/regions/uk/loader.py +704 -0
- glidepath/regions/uk/region.py +160 -0
- glidepath/regions/uk/schema.py +563 -0
- glidepath/regions/uk/state_pension.py +129 -0
- glidepath/regions/uk/tax.py +466 -0
- glidepath/regions/uk/wrappers.py +283 -0
- glidepath/regions/uk/years.py +92 -0
- glidepath-0.2.0.dist-info/METADATA +189 -0
- glidepath-0.2.0.dist-info/RECORD +93 -0
- glidepath-0.2.0.dist-info/WHEEL +4 -0
- glidepath-0.2.0.dist-info/entry_points.txt +3 -0
- glidepath-0.2.0.dist-info/licenses/LICENSE +21 -0
- 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
|