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,175 @@
|
|
|
1
|
+
"""Asset allocation, fees, and growth application (roadmap 3.4; planning §5.2).
|
|
2
|
+
|
|
3
|
+
Implements steps 6 and 7 of the §5.2 operation order for one wrapper and
|
|
4
|
+
one period:
|
|
5
|
+
|
|
6
|
+
- **Fees** (step 6): platform + fund annual percentages applied to the
|
|
7
|
+
*average* balance — the mean of the opening balance and the balance
|
|
8
|
+
after the period's flows — approximating intra-year accrual acceptably
|
|
9
|
+
at annual resolution. A fee can never take more than the account
|
|
10
|
+
holds.
|
|
11
|
+
- **Growth** (step 7): the period's asset-class returns applied to the
|
|
12
|
+
post-fee balance through the wrapper's asset allocation. Fees before
|
|
13
|
+
growth is part of the spec, so :func:`apply_fees_and_growth` performs
|
|
14
|
+
both in that order by construction.
|
|
15
|
+
|
|
16
|
+
Asset classes are the three the assumption catalogue prices (planning
|
|
17
|
+
§7: ``returns.equity.real``, ``returns.bonds.real``, ``returns.cash.real``)
|
|
18
|
+
— an economic vocabulary, not a region one. All amounts stay unquantized;
|
|
19
|
+
the ledger rounds at period close (step 8, planning §4.6).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from decimal import Decimal
|
|
24
|
+
|
|
25
|
+
from glidepath.core.money import Money, Rate
|
|
26
|
+
|
|
27
|
+
_ZERO = Decimal(0)
|
|
28
|
+
_ONE = Decimal(1)
|
|
29
|
+
_HALF = Decimal("0.5")
|
|
30
|
+
_ZERO_MONEY = Money(_ZERO)
|
|
31
|
+
_MINUS_ONE = Decimal(-1)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class AssetAllocation:
|
|
36
|
+
"""Portfolio weights over the three priced asset classes.
|
|
37
|
+
|
|
38
|
+
Weights are exact ``Decimal`` fractions that must sum to exactly 1 —
|
|
39
|
+
an allocation is a complete description of where a balance sits, not
|
|
40
|
+
a preference ranking.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
equity: Decimal
|
|
44
|
+
bonds: Decimal
|
|
45
|
+
cash: Decimal = _ZERO
|
|
46
|
+
|
|
47
|
+
def __post_init__(self) -> None:
|
|
48
|
+
"""Require weights in [0, 1] summing to exactly 1."""
|
|
49
|
+
weights = (self.equity, self.bonds, self.cash)
|
|
50
|
+
if any(not _ZERO <= weight <= _ONE for weight in weights):
|
|
51
|
+
msg = "AssetAllocation weights must lie between 0 and 1"
|
|
52
|
+
raise ValueError(msg)
|
|
53
|
+
if sum(weights) != _ONE:
|
|
54
|
+
msg = "AssetAllocation weights must sum to exactly 1"
|
|
55
|
+
raise ValueError(msg)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True, slots=True)
|
|
59
|
+
class AssetReturns:
|
|
60
|
+
"""One period's nominal return per asset class.
|
|
61
|
+
|
|
62
|
+
Rates may be negative (a loss) but never below -100%: a balance
|
|
63
|
+
cannot lose more than itself.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
equity: Rate
|
|
67
|
+
bonds: Rate
|
|
68
|
+
cash: Rate
|
|
69
|
+
|
|
70
|
+
def __post_init__(self) -> None:
|
|
71
|
+
"""Reject returns below -100%."""
|
|
72
|
+
rates = (self.equity, self.bonds, self.cash)
|
|
73
|
+
if any(rate.value < _MINUS_ONE for rate in rates):
|
|
74
|
+
msg = "AssetReturns rates must be at least -1 (a total loss)"
|
|
75
|
+
raise ValueError(msg)
|
|
76
|
+
|
|
77
|
+
def portfolio_growth_factor(self, allocation: AssetAllocation) -> Decimal:
|
|
78
|
+
"""The allocation-weighted growth multiplier for one period."""
|
|
79
|
+
return (
|
|
80
|
+
allocation.equity * self.equity.growth_factor
|
|
81
|
+
+ allocation.bonds * self.bonds.growth_factor
|
|
82
|
+
+ allocation.cash * self.cash.growth_factor
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True, slots=True)
|
|
87
|
+
class FeeSchedule:
|
|
88
|
+
"""One wrapper's annual percentage fees (planning §5.1).
|
|
89
|
+
|
|
90
|
+
``platform`` is the platform/provider charge, ``fund`` the fund OCF;
|
|
91
|
+
both are annual fractions of the balance, charged together on the
|
|
92
|
+
period's average balance (§5.2 step 6).
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
platform: Rate
|
|
96
|
+
fund: Rate
|
|
97
|
+
|
|
98
|
+
def __post_init__(self) -> None:
|
|
99
|
+
"""Require each fee rate to be a fraction in [0, 1]."""
|
|
100
|
+
rates = (self.platform, self.fund)
|
|
101
|
+
if any(not _ZERO <= rate.value <= _ONE for rate in rates):
|
|
102
|
+
msg = "FeeSchedule rates must lie between 0 and 1"
|
|
103
|
+
raise ValueError(msg)
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def total_rate(self) -> Rate:
|
|
107
|
+
"""Platform and fund combined, as one annual rate."""
|
|
108
|
+
return Rate(self.platform.value + self.fund.value)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def period_fee(
|
|
112
|
+
opening: Money,
|
|
113
|
+
after_flows: Money,
|
|
114
|
+
fees: FeeSchedule,
|
|
115
|
+
year_fraction: Decimal = _ONE,
|
|
116
|
+
) -> Money:
|
|
117
|
+
"""The period's fee: the total rate on the average balance (§5.2 step 6).
|
|
118
|
+
|
|
119
|
+
The average balance is the mean of the opening balance and the
|
|
120
|
+
balance after the period's flows (contributions and withdrawals,
|
|
121
|
+
steps 2-4). ``year_fraction`` scales the annual rate linearly for a
|
|
122
|
+
partial first/last period (the roadmap-4.6 convention of planning
|
|
123
|
+
§5.2); a whole period passes 1. The fee is capped at ``after_flows``
|
|
124
|
+
— a provider cannot charge more than the account holds.
|
|
125
|
+
|
|
126
|
+
Raises:
|
|
127
|
+
ValueError: If either balance is negative, or ``year_fraction``
|
|
128
|
+
lies outside [0, 1].
|
|
129
|
+
"""
|
|
130
|
+
if opening < _ZERO_MONEY or after_flows < _ZERO_MONEY:
|
|
131
|
+
msg = "balances must be non-negative"
|
|
132
|
+
raise ValueError(msg)
|
|
133
|
+
if not _ZERO <= year_fraction <= _ONE:
|
|
134
|
+
msg = "year_fraction must lie between 0 and 1"
|
|
135
|
+
raise ValueError(msg)
|
|
136
|
+
average = (opening + after_flows) * _HALF
|
|
137
|
+
return min(fees.total_rate.of(average) * year_fraction, after_flows)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass(frozen=True, slots=True)
|
|
141
|
+
class FeesAndGrowthOutcome:
|
|
142
|
+
"""Steps 6 and 7 of §5.2 resolved for one wrapper and one period.
|
|
143
|
+
|
|
144
|
+
``fee`` is the charge taken (step 6), ``growth`` the return earned on
|
|
145
|
+
the post-fee balance (step 7, negative in a down period), and
|
|
146
|
+
``closing`` the resulting balance — all unquantized; the ledger
|
|
147
|
+
rounds at period close (step 8).
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
fee: Money
|
|
151
|
+
growth: Money
|
|
152
|
+
closing: Money
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def apply_fees_and_growth(
|
|
156
|
+
opening: Money,
|
|
157
|
+
after_flows: Money,
|
|
158
|
+
fees: FeeSchedule,
|
|
159
|
+
allocation: AssetAllocation,
|
|
160
|
+
returns: AssetReturns,
|
|
161
|
+
) -> FeesAndGrowthOutcome:
|
|
162
|
+
"""Apply one period's fees then growth, in the §5.2 operation order.
|
|
163
|
+
|
|
164
|
+
The fee (step 6) comes off before returns apply (step 7), so growth
|
|
165
|
+
compounds only the post-fee balance — the order is enforced by
|
|
166
|
+
construction, not by caller discipline.
|
|
167
|
+
|
|
168
|
+
Raises:
|
|
169
|
+
ValueError: If either balance is negative.
|
|
170
|
+
"""
|
|
171
|
+
fee = period_fee(opening, after_flows, fees)
|
|
172
|
+
post_fee = after_flows - fee
|
|
173
|
+
factor = returns.portfolio_growth_factor(allocation)
|
|
174
|
+
growth = Money(post_fee.amount * (factor - _ONE))
|
|
175
|
+
return FeesAndGrowthOutcome(fee=fee, growth=growth, closing=post_fee + growth)
|
glidepath/core/money.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Money and Rate value types implementing the engine rounding policy.
|
|
2
|
+
|
|
3
|
+
Policy (docs/planning.md §4.6): all monetary arithmetic is exact
|
|
4
|
+
``Decimal`` — never float. Intermediate values stay unquantized; money is
|
|
5
|
+
quantized to whole pennies with ``ROUND_HALF_EVEN`` at every ledger write
|
|
6
|
+
via :meth:`Money.quantized`. Rates and factors are never quantized.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from decimal import ROUND_HALF_EVEN, Decimal
|
|
11
|
+
|
|
12
|
+
_PENNY = Decimal("0.01")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _require_finite_decimal(value: Decimal, field_name: str) -> None:
|
|
16
|
+
"""Reject non-Decimal and non-finite amounts at construction time."""
|
|
17
|
+
if not isinstance(value, Decimal):
|
|
18
|
+
msg = f"{field_name} must be Decimal, got {type(value).__name__}"
|
|
19
|
+
raise TypeError(msg)
|
|
20
|
+
if not value.is_finite():
|
|
21
|
+
msg = f"{field_name} must be finite"
|
|
22
|
+
raise ValueError(msg)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True, slots=True, order=True)
|
|
26
|
+
class Money:
|
|
27
|
+
"""An exact monetary amount in the plan's single currency.
|
|
28
|
+
|
|
29
|
+
``amount`` may carry more precision than a penny between operations;
|
|
30
|
+
ledger writes call :meth:`quantized` (planning §4.6).
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
amount: Decimal
|
|
34
|
+
|
|
35
|
+
def __post_init__(self) -> None:
|
|
36
|
+
"""Reject non-Decimal and non-finite amounts."""
|
|
37
|
+
_require_finite_decimal(self.amount, "Money.amount")
|
|
38
|
+
|
|
39
|
+
def __add__(self, other: Money) -> Money:
|
|
40
|
+
"""Return the exact (unquantized) sum."""
|
|
41
|
+
return Money(self.amount + other.amount)
|
|
42
|
+
|
|
43
|
+
def __sub__(self, other: Money) -> Money:
|
|
44
|
+
"""Return the exact (unquantized) difference."""
|
|
45
|
+
return Money(self.amount - other.amount)
|
|
46
|
+
|
|
47
|
+
def __neg__(self) -> Money:
|
|
48
|
+
"""Return the amount negated."""
|
|
49
|
+
return Money(-self.amount)
|
|
50
|
+
|
|
51
|
+
def __mul__(self, factor: Decimal) -> Money:
|
|
52
|
+
"""Scale by a ``Decimal`` factor; the result stays unquantized."""
|
|
53
|
+
return Money(self.amount * factor)
|
|
54
|
+
|
|
55
|
+
def __rmul__(self, factor: Decimal) -> Money:
|
|
56
|
+
"""Support ``Decimal * Money``."""
|
|
57
|
+
return Money(factor * self.amount)
|
|
58
|
+
|
|
59
|
+
def quantized(self) -> Money:
|
|
60
|
+
"""Round to whole pennies with banker's rounding.
|
|
61
|
+
|
|
62
|
+
This is the ledger-write rounding step of planning §4.6:
|
|
63
|
+
``ROUND_HALF_EVEN`` to two decimal places. Intermediate arithmetic
|
|
64
|
+
must stay exact; only ledger writes round. A sub-penny negative
|
|
65
|
+
residual quantizes to ``Decimal("-0.00")`` — numerically zero but
|
|
66
|
+
serialized with a minus sign — so zero is normalized to the one
|
|
67
|
+
positive representation.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
A new ``Money`` whose amount has exponent ``-2``.
|
|
71
|
+
"""
|
|
72
|
+
rounded = self.amount.quantize(_PENNY, rounding=ROUND_HALF_EVEN)
|
|
73
|
+
if rounded == 0:
|
|
74
|
+
rounded = rounded.copy_abs()
|
|
75
|
+
return Money(rounded)
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def is_penny_exact(self) -> bool:
|
|
79
|
+
"""Whether the amount is representable in whole pennies."""
|
|
80
|
+
return self.amount == self.amount.quantize(_PENNY, rounding=ROUND_HALF_EVEN)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True, slots=True, order=True)
|
|
84
|
+
class Rate:
|
|
85
|
+
"""An annual rate expressed as an exact fraction; never quantized.
|
|
86
|
+
|
|
87
|
+
``Rate(Decimal("0.05"))`` is 5% per year (planning §4.6: rates and
|
|
88
|
+
factors carry full precision end to end).
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
value: Decimal
|
|
92
|
+
|
|
93
|
+
def __post_init__(self) -> None:
|
|
94
|
+
"""Reject non-Decimal and non-finite rates."""
|
|
95
|
+
_require_finite_decimal(self.value, "Rate.value")
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def growth_factor(self) -> Decimal:
|
|
99
|
+
"""``1 + value``: the multiplier for one period's growth."""
|
|
100
|
+
return Decimal(1) + self.value
|
|
101
|
+
|
|
102
|
+
def of(self, money: Money) -> Money:
|
|
103
|
+
"""Return ``money`` scaled by this rate, e.g. one year's interest.
|
|
104
|
+
|
|
105
|
+
The result is unquantized; callers round at ledger writes.
|
|
106
|
+
"""
|
|
107
|
+
return Money(money.amount * self.value)
|