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,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)
@@ -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)