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