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,271 @@
|
|
|
1
|
+
"""Facts, decisions, assumptions, and provenance (planning §1, §5.1).
|
|
2
|
+
|
|
3
|
+
Every number in a plan is exactly one of three kinds:
|
|
4
|
+
|
|
5
|
+
- a :class:`Fact` the user stated (DOB, balances, accrued DB entitlement),
|
|
6
|
+
- a :class:`Decision` the user chose (retirement age, contributions) — the
|
|
7
|
+
only scenario-overridable plan fields (planning §4.3),
|
|
8
|
+
- an :class:`Assumption` the app defaulted or estimated (returns,
|
|
9
|
+
inflation, longevity) — always overridable, always carrying its source.
|
|
10
|
+
|
|
11
|
+
The engine may not read a tunable number any way other than through an
|
|
12
|
+
:class:`AssumptionSet`; per-run reads are recorded by an
|
|
13
|
+
:class:`AssumptionReadRecorder` without mutating the frozen set, so
|
|
14
|
+
``ProjectionResult.provenance`` can list every assumption actually used.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from collections.abc import Mapping
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from decimal import Decimal
|
|
20
|
+
from enum import Enum, StrEnum, auto
|
|
21
|
+
from typing import TYPE_CHECKING, Any
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from collections.abc import Iterable
|
|
25
|
+
from datetime import date, datetime
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Provenance(Enum):
|
|
29
|
+
"""Where a value came from (planning §5.1)."""
|
|
30
|
+
|
|
31
|
+
USER_FACT = auto()
|
|
32
|
+
DEFAULT_ASSUMPTION = auto()
|
|
33
|
+
USER_OVERRIDE = auto()
|
|
34
|
+
SCENARIO_OVERRIDE = auto()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _require_tz_aware(moment: datetime, field_name: str) -> None:
|
|
38
|
+
"""Reject naive datetimes (repo rule: datetimes are always tz-aware)."""
|
|
39
|
+
if moment.tzinfo is None or moment.tzinfo.utcoffset(moment) is None:
|
|
40
|
+
msg = f"{field_name} must be timezone-aware"
|
|
41
|
+
raise ValueError(msg)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True, slots=True)
|
|
45
|
+
class Fact[T]:
|
|
46
|
+
"""A value the user stated (planning §5.1).
|
|
47
|
+
|
|
48
|
+
Facts are never scenario-overridable (planning §4.3): a different
|
|
49
|
+
balance is a different plan.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
value: T
|
|
53
|
+
as_of: date
|
|
54
|
+
recorded_on: datetime
|
|
55
|
+
note: str | None = None
|
|
56
|
+
|
|
57
|
+
def __post_init__(self) -> None:
|
|
58
|
+
"""Reject naive timestamps."""
|
|
59
|
+
_require_tz_aware(self.recorded_on, "Fact.recorded_on")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True, slots=True)
|
|
63
|
+
class Decision[T]:
|
|
64
|
+
"""A user choice — neither a fact about the world nor an estimate.
|
|
65
|
+
|
|
66
|
+
Decision variables are exactly the scenario what-if whitelist
|
|
67
|
+
(planning §4.3, §5.1).
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
value: T
|
|
71
|
+
recorded_on: datetime
|
|
72
|
+
note: str | None = None
|
|
73
|
+
|
|
74
|
+
def __post_init__(self) -> None:
|
|
75
|
+
"""Reject naive timestamps."""
|
|
76
|
+
_require_tz_aware(self.recorded_on, "Decision.recorded_on")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class AssumptionKey(StrEnum):
|
|
80
|
+
"""Stable dotted ids for every tunable number (planning §5.1, §7).
|
|
81
|
+
|
|
82
|
+
Values are persisted in user files and targeted by scenario overrides,
|
|
83
|
+
so they must never change meaning; retire a key by adding a new one.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
INFLATION_CPI = "inflation.cpi"
|
|
87
|
+
EARNINGS_GROWTH_REAL = "earnings.growth.real"
|
|
88
|
+
RETURNS_EQUITY_REAL = "returns.equity.real"
|
|
89
|
+
RETURNS_BONDS_REAL = "returns.bonds.real"
|
|
90
|
+
RETURNS_CASH_REAL = "returns.cash.real"
|
|
91
|
+
VOLATILITY_EQUITY = "volatility.equity"
|
|
92
|
+
VOLATILITY_BONDS = "volatility.bonds"
|
|
93
|
+
VOLATILITY_CASH = "volatility.cash"
|
|
94
|
+
CORRELATION_EQUITY_BONDS = "correlation.equity_bonds"
|
|
95
|
+
CORRELATION_EQUITY_CASH = "correlation.equity_cash"
|
|
96
|
+
CORRELATION_BONDS_CASH = "correlation.bonds_cash"
|
|
97
|
+
FEES_PLATFORM = "fees.platform"
|
|
98
|
+
FEES_FUND = "fees.fund"
|
|
99
|
+
YIELD_EQUITY = "yield.equity"
|
|
100
|
+
YIELD_BONDS = "yield.bonds"
|
|
101
|
+
YIELD_CASH = "yield.cash"
|
|
102
|
+
HORIZON_PLANNING_AGE = "horizon.planning_age"
|
|
103
|
+
GLIDEPATH_DEFAULT_SHAPE = "glidepath.default_shape"
|
|
104
|
+
POLICY_STATE_PENSION_UPRATING = "policy.state_pension.uprating"
|
|
105
|
+
POLICY_TAX_FUTURE_YEARS = "policy.tax.future_years"
|
|
106
|
+
ANNUITY_LEVEL_SINGLE_65 = "annuity.level.single.65"
|
|
107
|
+
ANNUITY_ESCALATING3_SINGLE_65 = "annuity.escalating3.single.65"
|
|
108
|
+
ANNUITY_INFLATION_LINKED_SINGLE_65 = "annuity.inflation_linked.single.65"
|
|
109
|
+
ANNUITY_AGE_ADJUSTMENT = "annuity.age_adjustment"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass(frozen=True, slots=True)
|
|
113
|
+
class Assumption[T]:
|
|
114
|
+
"""A value the app defaulted or estimated. Always overridable.
|
|
115
|
+
|
|
116
|
+
Carries the shipped default alongside the effective value, plus the
|
|
117
|
+
source its default is based on, so the UI can always answer "which of
|
|
118
|
+
these numbers did I state, and which did you assume?" (planning §1).
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
key: AssumptionKey
|
|
122
|
+
value: T
|
|
123
|
+
default_value: T
|
|
124
|
+
provenance: Provenance
|
|
125
|
+
source: str
|
|
126
|
+
recorded_on: datetime
|
|
127
|
+
description: str
|
|
128
|
+
|
|
129
|
+
def __post_init__(self) -> None:
|
|
130
|
+
"""Reject impossible provenance and naive timestamps."""
|
|
131
|
+
if self.provenance is Provenance.USER_FACT:
|
|
132
|
+
msg = "an Assumption cannot carry USER_FACT provenance"
|
|
133
|
+
raise ValueError(msg)
|
|
134
|
+
_require_tz_aware(self.recorded_on, "Assumption.recorded_on")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class AssumptionSet:
|
|
138
|
+
"""Immutable typed registry of assumptions, keyed by dotted id.
|
|
139
|
+
|
|
140
|
+
The engine may not read a tunable number any other way (planning
|
|
141
|
+
§5.1): step functions receive only ``Plan`` + ``AssumptionSet`` +
|
|
142
|
+
``Region`` + ``RunConfig``.
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
__slots__ = ("_entries",)
|
|
146
|
+
|
|
147
|
+
def __init__(self, assumptions: Iterable[Assumption[Any]]) -> None:
|
|
148
|
+
"""Build the registry, rejecting duplicate keys."""
|
|
149
|
+
entries: dict[AssumptionKey, Assumption[Any]] = {}
|
|
150
|
+
for assumption in assumptions:
|
|
151
|
+
if assumption.key in entries:
|
|
152
|
+
msg = f"duplicate assumption key: {assumption.key!r}"
|
|
153
|
+
raise ValueError(msg)
|
|
154
|
+
entries[assumption.key] = assumption
|
|
155
|
+
self._entries = entries
|
|
156
|
+
|
|
157
|
+
def get(self, key: AssumptionKey) -> Assumption[Any]:
|
|
158
|
+
"""Return the assumption registered for ``key``.
|
|
159
|
+
|
|
160
|
+
Raises:
|
|
161
|
+
KeyError: If no assumption is registered for ``key``.
|
|
162
|
+
"""
|
|
163
|
+
try:
|
|
164
|
+
return self._entries[key]
|
|
165
|
+
except KeyError:
|
|
166
|
+
msg = f"no assumption registered for key {key!r}"
|
|
167
|
+
raise KeyError(msg) from None
|
|
168
|
+
|
|
169
|
+
def __contains__(self, key: AssumptionKey) -> bool:
|
|
170
|
+
"""Whether an assumption is registered for ``key``."""
|
|
171
|
+
return key in self._entries
|
|
172
|
+
|
|
173
|
+
@property
|
|
174
|
+
def keys(self) -> frozenset[AssumptionKey]:
|
|
175
|
+
"""The keys with a registered assumption."""
|
|
176
|
+
return frozenset(self._entries)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class AssumptionReadRecorder:
|
|
180
|
+
"""Mutable per-run recorder of assumption reads (planning §5.1).
|
|
181
|
+
|
|
182
|
+
``run()`` creates one per run and returns its contents as part of the
|
|
183
|
+
result; the frozen :class:`AssumptionSet` itself is never mutated.
|
|
184
|
+
"""
|
|
185
|
+
|
|
186
|
+
__slots__ = ("_keys_in_order",)
|
|
187
|
+
|
|
188
|
+
def __init__(self) -> None:
|
|
189
|
+
"""Start with no reads recorded."""
|
|
190
|
+
self._keys_in_order: list[AssumptionKey] = []
|
|
191
|
+
|
|
192
|
+
def record(self, key: AssumptionKey) -> None:
|
|
193
|
+
"""Note that ``key`` was read (first read wins; later reads dedup)."""
|
|
194
|
+
if key not in self._keys_in_order:
|
|
195
|
+
self._keys_in_order.append(key)
|
|
196
|
+
|
|
197
|
+
@property
|
|
198
|
+
def keys_read(self) -> tuple[AssumptionKey, ...]:
|
|
199
|
+
"""Keys read so far, deduplicated, in first-read order."""
|
|
200
|
+
return tuple(self._keys_in_order)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def decimal_assumption_value(assumption: Assumption[Any]) -> Decimal:
|
|
204
|
+
"""The assumption's value as an exact ``Decimal`` rate or factor.
|
|
205
|
+
|
|
206
|
+
The engine reads tunable numbers only through assumptions (planning
|
|
207
|
+
§5.1); a value of the wrong shape is a configuration error and must
|
|
208
|
+
fail loudly, never coerce — ``Decimal`` end-to-end is the §4.6 rule
|
|
209
|
+
(a bool is rejected explicitly: it is an ``int`` subtype but never
|
|
210
|
+
a number here).
|
|
211
|
+
|
|
212
|
+
Raises:
|
|
213
|
+
TypeError: If the value is not a ``Decimal``.
|
|
214
|
+
"""
|
|
215
|
+
value = assumption.value
|
|
216
|
+
if not isinstance(value, Decimal):
|
|
217
|
+
msg = (
|
|
218
|
+
f"assumption {assumption.key!r} must hold a Decimal value,"
|
|
219
|
+
f" got {type(value).__name__}"
|
|
220
|
+
)
|
|
221
|
+
raise TypeError(msg)
|
|
222
|
+
return value
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def int_assumption_value(assumption: Assumption[Any]) -> int:
|
|
226
|
+
"""The assumption's value as a whole number (e.g. a planning age).
|
|
227
|
+
|
|
228
|
+
Raises:
|
|
229
|
+
TypeError: If the value is not an ``int`` (``bool`` rejected).
|
|
230
|
+
"""
|
|
231
|
+
value = assumption.value
|
|
232
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
233
|
+
msg = (
|
|
234
|
+
f"assumption {assumption.key!r} must hold an integer value,"
|
|
235
|
+
f" got {type(value).__name__}"
|
|
236
|
+
)
|
|
237
|
+
raise TypeError(msg)
|
|
238
|
+
return value
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def mapping_assumption_value(assumption: Assumption[Any]) -> Mapping[str, Any]:
|
|
242
|
+
"""The assumption's value as a structured table (e.g. a glide shape).
|
|
243
|
+
|
|
244
|
+
Raises:
|
|
245
|
+
TypeError: If the value is not a mapping.
|
|
246
|
+
"""
|
|
247
|
+
value = assumption.value
|
|
248
|
+
if not isinstance(value, Mapping):
|
|
249
|
+
msg = (
|
|
250
|
+
f"assumption {assumption.key!r} must hold a table value,"
|
|
251
|
+
f" got {type(value).__name__}"
|
|
252
|
+
)
|
|
253
|
+
raise TypeError(msg)
|
|
254
|
+
return value
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@dataclass(frozen=True, slots=True)
|
|
258
|
+
class TrackedAssumptions:
|
|
259
|
+
"""Read-through view pairing an :class:`AssumptionSet` with a recorder.
|
|
260
|
+
|
|
261
|
+
The engine reads assumptions through this view so every read lands in
|
|
262
|
+
the run's provenance record with no engine-side bookkeeping.
|
|
263
|
+
"""
|
|
264
|
+
|
|
265
|
+
assumptions: AssumptionSet
|
|
266
|
+
recorder: AssumptionReadRecorder
|
|
267
|
+
|
|
268
|
+
def get(self, key: AssumptionKey) -> Assumption[Any]:
|
|
269
|
+
"""Record the read, then return the assumption."""
|
|
270
|
+
self.recorder.record(key)
|
|
271
|
+
return self.assumptions.get(key)
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Seeded randomness for Monte Carlo runs (roadmap 7.1; planning §4.6).
|
|
2
|
+
|
|
3
|
+
Randomness enters the engine only through the :class:`RandomSource`
|
|
4
|
+
protocol, wrapping :class:`random.Random` seeded from the run's
|
|
5
|
+
``RunConfig.seed`` — never module-level ``random`` (planning §4.6).
|
|
6
|
+
Monte Carlo path *i* uses a substream whose seed is derived from
|
|
7
|
+
``(seed, i)`` by :func:`derive_seed`, an explicit fixed-width digest:
|
|
8
|
+
``random.Random((seed, i))`` is a ``TypeError`` on our pinned Python
|
|
9
|
+
(3.11+ restricts seed types), so the derivation cannot be delegated to
|
|
10
|
+
the seed argument. Because the derivation is a pure function, paths are
|
|
11
|
+
order-independent and individually re-runnable — "re-run path 4711"
|
|
12
|
+
needs only the manifest's seed and the path index.
|
|
13
|
+
|
|
14
|
+
Reproducibility is manifest-level (§4.6): identical manifest →
|
|
15
|
+
identical output, on any runtime. Python guarantees that only
|
|
16
|
+
``random.Random.random()`` reproduces its sequence across versions;
|
|
17
|
+
the float distribution methods (``gauss`` et al.) go through libm,
|
|
18
|
+
whose last-bit rounding varies across platforms and versions. Draws
|
|
19
|
+
therefore cross the float→``Decimal`` boundary at the uniform step —
|
|
20
|
+
each ``random()`` output converts exactly — and the normal transform
|
|
21
|
+
runs in ``Decimal`` (Marsaglia's polar method; ``Decimal.ln()`` and
|
|
22
|
+
``Decimal.sqrt()`` are correctly rounded by specification), so the
|
|
23
|
+
same seed yields bit-identical draws on every platform this checkout
|
|
24
|
+
runs on (e.g. the shared Windows/WSL setup).
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
import random
|
|
28
|
+
from decimal import Decimal
|
|
29
|
+
from hashlib import blake2b
|
|
30
|
+
from typing import Protocol
|
|
31
|
+
|
|
32
|
+
_DIGEST_SIZE = 16
|
|
33
|
+
"""Substream seeds are 128-bit digests — fixed width, planning §4.6."""
|
|
34
|
+
|
|
35
|
+
_ZERO = Decimal(0)
|
|
36
|
+
_ONE = Decimal(1)
|
|
37
|
+
_TWO = Decimal(2)
|
|
38
|
+
_MINUS_TWO = Decimal(-2)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class RandomSource(Protocol):
|
|
42
|
+
"""A seeded stream of random draws (planning §4.6).
|
|
43
|
+
|
|
44
|
+
The engine and return models take their randomness only through
|
|
45
|
+
this protocol, so a run's randomness is exactly determined by the
|
|
46
|
+
seeds injected into it.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def standard_normals(self, count: int, /) -> tuple[Decimal, ...]:
|
|
50
|
+
"""Draw ``count`` independent standard-normal values in order."""
|
|
51
|
+
...
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def derive_seed(root_seed: int, *parts: int | str) -> int:
|
|
55
|
+
"""Derive a substream seed from a root seed and identifying parts.
|
|
56
|
+
|
|
57
|
+
The explicit derivation function of planning §4.6: a fixed-width
|
|
58
|
+
(128-bit) BLAKE2b digest over the root seed and each part, returned
|
|
59
|
+
as an integer for :class:`random.Random`. Each part is tagged with
|
|
60
|
+
its type and length-prefixed inside the digest, so distinct part
|
|
61
|
+
sequences can never collide — neither by concatenation nor by an
|
|
62
|
+
integer shadowing its string spelling (``1`` vs ``"1"``). The Monte
|
|
63
|
+
Carlo path runner (roadmap 7.3) derives path *i*'s stream as
|
|
64
|
+
``derive_seed(seed, i)``; the stochastic return model further
|
|
65
|
+
scopes draws per period.
|
|
66
|
+
"""
|
|
67
|
+
hasher = blake2b(digest_size=_DIGEST_SIZE)
|
|
68
|
+
for part in (root_seed, *parts):
|
|
69
|
+
hasher.update(b"i" if isinstance(part, int) else b"s")
|
|
70
|
+
encoded = str(part).encode("utf-8")
|
|
71
|
+
hasher.update(len(encoded).to_bytes(8, "big"))
|
|
72
|
+
hasher.update(encoded)
|
|
73
|
+
return int.from_bytes(hasher.digest(), "big")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class SeededRandomSource:
|
|
77
|
+
"""The :class:`RandomSource` wrapping ``random.Random(seed)`` (§4.6).
|
|
78
|
+
|
|
79
|
+
Stateful by design: draws consume the underlying stream in order,
|
|
80
|
+
and two sources built from the same seed produce identical
|
|
81
|
+
sequences. Statistical use only — never security (the S311 ignore
|
|
82
|
+
for this module in ``pyproject.toml``).
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
__slots__ = ("_stream",)
|
|
86
|
+
|
|
87
|
+
def __init__(self, seed: int) -> None:
|
|
88
|
+
"""Seed a private stream; no global random state is touched."""
|
|
89
|
+
self._stream = random.Random(seed)
|
|
90
|
+
|
|
91
|
+
def _uniform_signed(self) -> Decimal:
|
|
92
|
+
"""One uniform draw on [-1, 1), converted exactly to Decimal.
|
|
93
|
+
|
|
94
|
+
``random()`` is the one generator method whose sequence Python
|
|
95
|
+
guarantees stable across versions (module docstring), so it is
|
|
96
|
+
the only float this class ever consumes.
|
|
97
|
+
"""
|
|
98
|
+
return _TWO * Decimal(self._stream.random()) - _ONE
|
|
99
|
+
|
|
100
|
+
def standard_normals(self, count: int, /) -> tuple[Decimal, ...]:
|
|
101
|
+
"""Draw ``count`` standard normals via the polar method.
|
|
102
|
+
|
|
103
|
+
Marsaglia's polar method in ``Decimal``: accept a uniform pair
|
|
104
|
+
``(u, v)`` when ``s = u² + v²`` lands strictly inside the unit
|
|
105
|
+
circle (excluding zero), then scale by ``sqrt(-2 ln(s) / s)``
|
|
106
|
+
to yield two independent normals. No libm call is involved, so
|
|
107
|
+
the draws are bit-identical across platforms (module
|
|
108
|
+
docstring). An odd ``count`` discards the trailing normal of
|
|
109
|
+
the last accepted pair — deterministically, since rejection
|
|
110
|
+
sampling consumes the stream identically either way.
|
|
111
|
+
|
|
112
|
+
Raises:
|
|
113
|
+
ValueError: If ``count`` is negative.
|
|
114
|
+
"""
|
|
115
|
+
if count < 0:
|
|
116
|
+
msg = f"count must be non-negative, got {count}"
|
|
117
|
+
raise ValueError(msg)
|
|
118
|
+
draws: list[Decimal] = []
|
|
119
|
+
while len(draws) < count:
|
|
120
|
+
u = self._uniform_signed()
|
|
121
|
+
v = self._uniform_signed()
|
|
122
|
+
magnitude = u * u + v * v
|
|
123
|
+
if magnitude >= _ONE or magnitude == _ZERO:
|
|
124
|
+
continue
|
|
125
|
+
scale = (_MINUS_TWO * magnitude.ln() / magnitude).sqrt()
|
|
126
|
+
draws.append(u * scale)
|
|
127
|
+
draws.append(v * scale)
|
|
128
|
+
return tuple(draws[:count])
|
glidepath/core/region.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""The region bundle the engine runs against (roadmap 4.1; planning §4.2).
|
|
2
|
+
|
|
3
|
+
A :class:`Region` gathers one region's implementations of every core
|
|
4
|
+
boundary protocol — fiscal calendar, age rules, tax system, wrapper
|
|
5
|
+
rules, contribution rules, state pension scheme — plus a
|
|
6
|
+
content-version string identifying the data those implementations
|
|
7
|
+
answer from. The engine receives exactly
|
|
8
|
+
this bundle (planning §5.2: ``run(plan, assumptions, region, config)``)
|
|
9
|
+
and never anything region-specific; the version string lands in
|
|
10
|
+
``ProjectionResult.provenance`` so a result records which data produced
|
|
11
|
+
it (planning §4.6).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import TYPE_CHECKING
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from glidepath.core.contributions import ContributionRuleset
|
|
19
|
+
from glidepath.core.periods import AgeRules, FiscalCalendar
|
|
20
|
+
from glidepath.core.state_pension import StatePensionScheme
|
|
21
|
+
from glidepath.core.tax import TaxSystem
|
|
22
|
+
from glidepath.core.wrappers import WrapperRuleset
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True, slots=True)
|
|
26
|
+
class Region:
|
|
27
|
+
"""One region's boundary-protocol implementations, bundled.
|
|
28
|
+
|
|
29
|
+
``data_version`` identifies the region data content backing the
|
|
30
|
+
bundle (e.g. file names with their ``verified_on`` dates) — part of
|
|
31
|
+
the run manifest reproducibility is defined over (planning §4.6).
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
calendar: FiscalCalendar
|
|
35
|
+
ages: AgeRules
|
|
36
|
+
tax: TaxSystem
|
|
37
|
+
wrappers: WrapperRuleset
|
|
38
|
+
contributions: ContributionRuleset
|
|
39
|
+
state_pension: StatePensionScheme
|
|
40
|
+
data_version: str
|
|
41
|
+
|
|
42
|
+
def __post_init__(self) -> None:
|
|
43
|
+
"""Require a non-empty data version."""
|
|
44
|
+
if not self.data_version:
|
|
45
|
+
msg = "Region.data_version must not be empty"
|
|
46
|
+
raise ValueError(msg)
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""The real/nominal reporting layer (roadmap 4.4; planning §5.2).
|
|
2
|
+
|
|
3
|
+
The engine computes nominal — tax bands are nominal objects — and every
|
|
4
|
+
:class:`~glidepath.core.results.PeriodSnapshot` records the cumulative
|
|
5
|
+
CPI factor the run inflated by. This layer presents a projection in one
|
|
6
|
+
of two bases: **real (today's money), the default**, deflates by the
|
|
7
|
+
run's own CPI path; nominal presents the ledger amounts unchanged. No
|
|
8
|
+
inflation source of its own enters here — the deflators come exactly
|
|
9
|
+
from what the engine recorded, so the one-inflation-truth rule of
|
|
10
|
+
planning §5.2 holds by construction.
|
|
11
|
+
|
|
12
|
+
Two deflators per row under the real basis, matching what each amount
|
|
13
|
+
is: *flows* divide by the snapshot's ``inflation_factor`` — the
|
|
14
|
+
period-start price level the engine inflated them with — while
|
|
15
|
+
*closing balances* divide by the level at the period's modelled end,
|
|
16
|
+
``inflation_factor * (1 + cpi * year_fraction)`` (the linear
|
|
17
|
+
partial-period convention of §5.2), because a closing balance already
|
|
18
|
+
contains the period's own nominal growth. Presented totals are sums of
|
|
19
|
+
the presented per-wrapper amounts, so a report table is internally
|
|
20
|
+
consistent after rounding.
|
|
21
|
+
|
|
22
|
+
Report amounts are quantized for presentation (they are derived views,
|
|
23
|
+
not ledger writes); the snapshots remain the exact ledger record.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from decimal import Decimal
|
|
28
|
+
from enum import Enum, auto
|
|
29
|
+
from typing import TYPE_CHECKING
|
|
30
|
+
|
|
31
|
+
from glidepath.core.money import Money
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING:
|
|
34
|
+
from collections.abc import Iterable
|
|
35
|
+
|
|
36
|
+
from glidepath.core.entities import EntityId
|
|
37
|
+
from glidepath.core.glide import LifeStage
|
|
38
|
+
from glidepath.core.periods import Period
|
|
39
|
+
from glidepath.core.results import (
|
|
40
|
+
PeriodSnapshot,
|
|
41
|
+
PersonPeriodResult,
|
|
42
|
+
ProjectionResult,
|
|
43
|
+
)
|
|
44
|
+
from glidepath.core.wrappers import WrapperKindId
|
|
45
|
+
|
|
46
|
+
_ONE = Decimal(1)
|
|
47
|
+
_ZERO = Money(Decimal(0))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ReportBasis(Enum):
|
|
51
|
+
"""The money basis a report presents (planning §5.2).
|
|
52
|
+
|
|
53
|
+
``REAL`` is today's money — the default presentation; ``NOMINAL``
|
|
54
|
+
is the engine's ledger basis.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
REAL = auto()
|
|
58
|
+
NOMINAL = auto()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True, slots=True)
|
|
62
|
+
class WrapperReportBalance:
|
|
63
|
+
"""One wrapper's closing balance, in the report's basis."""
|
|
64
|
+
|
|
65
|
+
wrapper_id: EntityId
|
|
66
|
+
kind: WrapperKindId
|
|
67
|
+
closing_balance: Money
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True, slots=True)
|
|
71
|
+
class PeriodReportRow:
|
|
72
|
+
"""One person's period figures, in the report's basis.
|
|
73
|
+
|
|
74
|
+
``deflator`` is the factor divided out of the nominal flow amounts:
|
|
75
|
+
the snapshot's cumulative ``inflation_factor`` (the period-start
|
|
76
|
+
price level) under ``ReportBasis.REAL`` and 1 under
|
|
77
|
+
``ReportBasis.NOMINAL``. ``balance_deflator`` is the factor divided
|
|
78
|
+
out of the closing balances — the price level at the period's
|
|
79
|
+
modelled end, since a closing balance embeds the period's own
|
|
80
|
+
nominal growth. ``closing_balance`` is the sum of the presented
|
|
81
|
+
``wrapper_balances``, so the row stays consistent after rounding.
|
|
82
|
+
``contributions`` totals what landed in the pots (employee gross,
|
|
83
|
+
including provider relief, plus employer and any government bonus,
|
|
84
|
+
roadmap 9.2); ``withdrawals_gross`` totals the tax-free and
|
|
85
|
+
taxable draws across wrappers; ``annuity_purchases`` totals the
|
|
86
|
+
capital that left the wrappers to buy annuity income this period
|
|
87
|
+
(roadmap 5.5). ``growth_tax`` totals the portfolio-income tax
|
|
88
|
+
charged to taxable-growth wrappers, ``aa_charge`` totals the
|
|
89
|
+
annual-allowance charge the wrappers funded at close — scheme
|
|
90
|
+
pays and the cash route together (#124) — and ``banked`` is the
|
|
91
|
+
period's surplus — decumulation income and draws beyond the need,
|
|
92
|
+
or pre-retirement non-employment income beyond the planned
|
|
93
|
+
outflows — swept into one (roadmap 9.2).
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
period: Period
|
|
97
|
+
person_id: EntityId
|
|
98
|
+
age_at_period_start: int
|
|
99
|
+
stage: LifeStage
|
|
100
|
+
year_fraction: Decimal
|
|
101
|
+
deflator: Decimal
|
|
102
|
+
balance_deflator: Decimal
|
|
103
|
+
employment_income: Money
|
|
104
|
+
db_income: Money
|
|
105
|
+
db_lump_sum: Money
|
|
106
|
+
pension_lump_sum: Money
|
|
107
|
+
state_pension_income: Money
|
|
108
|
+
annuity_income: Money
|
|
109
|
+
annuity_lump_sum: Money
|
|
110
|
+
annuity_purchases: Money
|
|
111
|
+
tax_due: Money
|
|
112
|
+
spending_need: Money
|
|
113
|
+
planned_outflows: Money
|
|
114
|
+
net_withdrawn: Money
|
|
115
|
+
shortfall: Money
|
|
116
|
+
contributions: Money
|
|
117
|
+
fees: Money
|
|
118
|
+
growth: Money
|
|
119
|
+
growth_tax: Money
|
|
120
|
+
aa_charge: Money
|
|
121
|
+
banked: Money
|
|
122
|
+
withdrawals_gross: Money
|
|
123
|
+
closing_balance: Money
|
|
124
|
+
wrapper_balances: tuple[WrapperReportBalance, ...]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass(frozen=True, slots=True)
|
|
128
|
+
class ProjectionReport:
|
|
129
|
+
"""A projection presented in one money basis (roadmap 4.4).
|
|
130
|
+
|
|
131
|
+
Rows appear in period order; with a multi-person household each
|
|
132
|
+
period contributes one row per person, in plan order.
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
basis: ReportBasis
|
|
136
|
+
rows: tuple[PeriodReportRow, ...]
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def build_report(
|
|
140
|
+
result: ProjectionResult, basis: ReportBasis = ReportBasis.REAL
|
|
141
|
+
) -> ProjectionReport:
|
|
142
|
+
"""Present ``result`` in ``basis`` — real (today's money) by default.
|
|
143
|
+
|
|
144
|
+
Real amounts deflate by the run's single CPI path as the engine
|
|
145
|
+
recorded it period by period (planning §5.2): flows by the
|
|
146
|
+
period-start level, closing balances by the level at the period's
|
|
147
|
+
modelled end (see the module docstring).
|
|
148
|
+
"""
|
|
149
|
+
rows = tuple(
|
|
150
|
+
_person_row(snapshot, person, basis)
|
|
151
|
+
for snapshot in result.snapshots
|
|
152
|
+
for person in snapshot.persons
|
|
153
|
+
)
|
|
154
|
+
return ProjectionReport(basis=basis, rows=rows)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _total(amounts: Iterable[Money]) -> Money:
|
|
158
|
+
"""Sum a stream of amounts exactly."""
|
|
159
|
+
total = _ZERO
|
|
160
|
+
for amount in amounts:
|
|
161
|
+
total = total + amount
|
|
162
|
+
return total
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _person_row(
|
|
166
|
+
snapshot: PeriodSnapshot, person: PersonPeriodResult, basis: ReportBasis
|
|
167
|
+
) -> PeriodReportRow:
|
|
168
|
+
"""One report row: the person's snapshot figures deflated to ``basis``."""
|
|
169
|
+
deflator = _ONE
|
|
170
|
+
balance_deflator = _ONE
|
|
171
|
+
if basis is ReportBasis.REAL:
|
|
172
|
+
deflator = snapshot.inflation_factor
|
|
173
|
+
balance_deflator = snapshot.inflation_factor * (
|
|
174
|
+
_ONE + snapshot.returns.cpi.value * snapshot.year_fraction
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
def flow(amount: Money) -> Money:
|
|
178
|
+
"""Deflate a flow by the period-start level and quantize."""
|
|
179
|
+
return Money(amount.amount / deflator).quantized()
|
|
180
|
+
|
|
181
|
+
def balance(amount: Money) -> Money:
|
|
182
|
+
"""Deflate a closing balance by the period-end level and quantize."""
|
|
183
|
+
return Money(amount.amount / balance_deflator).quantized()
|
|
184
|
+
|
|
185
|
+
wrappers = person.wrappers
|
|
186
|
+
wrapper_balances = tuple(
|
|
187
|
+
WrapperReportBalance(
|
|
188
|
+
wrapper_id=entry.wrapper_id,
|
|
189
|
+
kind=entry.kind,
|
|
190
|
+
closing_balance=balance(entry.closing_balance),
|
|
191
|
+
)
|
|
192
|
+
for entry in wrappers
|
|
193
|
+
)
|
|
194
|
+
return PeriodReportRow(
|
|
195
|
+
period=snapshot.period,
|
|
196
|
+
person_id=person.person_id,
|
|
197
|
+
age_at_period_start=person.age_at_period_start,
|
|
198
|
+
stage=person.stage,
|
|
199
|
+
year_fraction=snapshot.year_fraction,
|
|
200
|
+
deflator=deflator,
|
|
201
|
+
balance_deflator=balance_deflator,
|
|
202
|
+
employment_income=flow(person.employment_income),
|
|
203
|
+
db_income=flow(person.db_income),
|
|
204
|
+
db_lump_sum=flow(person.db_lump_sum),
|
|
205
|
+
pension_lump_sum=flow(person.pension_lump_sum),
|
|
206
|
+
state_pension_income=flow(person.state_pension_income),
|
|
207
|
+
annuity_income=flow(person.annuity_income),
|
|
208
|
+
annuity_lump_sum=flow(person.annuity_lump_sum),
|
|
209
|
+
annuity_purchases=flow(_total(entry.annuity_purchase for entry in wrappers)),
|
|
210
|
+
tax_due=flow(person.tax.tax_due),
|
|
211
|
+
spending_need=flow(person.spending_need),
|
|
212
|
+
planned_outflows=flow(person.planned_outflows),
|
|
213
|
+
net_withdrawn=flow(person.net_withdrawn),
|
|
214
|
+
shortfall=flow(person.shortfall),
|
|
215
|
+
contributions=flow(
|
|
216
|
+
_total(
|
|
217
|
+
entry.employee_contribution
|
|
218
|
+
+ entry.employer_contribution
|
|
219
|
+
+ entry.contribution_bonus
|
|
220
|
+
for entry in wrappers
|
|
221
|
+
)
|
|
222
|
+
),
|
|
223
|
+
fees=flow(_total(entry.fee for entry in wrappers)),
|
|
224
|
+
growth=flow(_total(entry.growth for entry in wrappers)),
|
|
225
|
+
growth_tax=flow(_total(entry.growth_tax for entry in wrappers)),
|
|
226
|
+
aa_charge=flow(_total(entry.aa_charge for entry in wrappers)),
|
|
227
|
+
banked=flow(person.banked),
|
|
228
|
+
withdrawals_gross=flow(_total(entry.withdrawal_gross for entry in wrappers)),
|
|
229
|
+
closing_balance=_total(entry.closing_balance for entry in wrapper_balances),
|
|
230
|
+
wrapper_balances=wrapper_balances,
|
|
231
|
+
)
|