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,298 @@
|
|
|
1
|
+
"""Defined-benefit pension entitlements (roadmap 4.2, 9.6; planning §5.1).
|
|
2
|
+
|
|
3
|
+
Models deferred/accrued DB entitlements plus CARE-style active
|
|
4
|
+
membership (:class:`DBActiveMembership`, roadmap 9.6). Every scheme
|
|
5
|
+
parameter (accrued pension, normal pension age, revaluation basis,
|
|
6
|
+
early/late factors, commutation factor, accrual rate, pensionable
|
|
7
|
+
salary) is a user-entered fact: schemes vary too much to ship as data
|
|
8
|
+
(planning §5.1), so scheme facts drive results and nothing here is ever
|
|
9
|
+
guessed — taking benefits at an age the factor table does not cover is
|
|
10
|
+
a construction-time error, not a default.
|
|
11
|
+
|
|
12
|
+
Modelling conventions (documented in planning §5.1/§5.2):
|
|
13
|
+
|
|
14
|
+
- The scheme's one :class:`RevaluationBasis` applies both to
|
|
15
|
+
revaluation in deferment and to increases in payment; splitting the
|
|
16
|
+
two bases is a deferred extension (planning §2).
|
|
17
|
+
- Revaluation compounds per engine period from the statement date; the
|
|
18
|
+
span before the run's ``today`` is not modelled period-by-period, so
|
|
19
|
+
the engine folds it into a starting factor via
|
|
20
|
+
:func:`revaluation_factor_for_months` — whole years compound with an
|
|
21
|
+
integer exponent and the remaining whole months scale the annual
|
|
22
|
+
rate linearly, both exact ``Decimal`` arithmetic (planning §4.6).
|
|
23
|
+
- Commutation trades pension for a lump sum at the stated factor, at
|
|
24
|
+
the moment benefits start.
|
|
25
|
+
- Active accrual credits ``accrual_rate x pensionable_salary`` per year
|
|
26
|
+
of service on top of the revalued entitlement, service ending at the
|
|
27
|
+
earliest of the leave-and-defer age, the benefits start, and the
|
|
28
|
+
engine's retirement gate (planning §5.1); final-salary linkage and
|
|
29
|
+
member DB contributions are not modelled (planning §2).
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from dataclasses import dataclass
|
|
33
|
+
from decimal import Decimal
|
|
34
|
+
from enum import Enum, auto
|
|
35
|
+
from typing import TYPE_CHECKING
|
|
36
|
+
|
|
37
|
+
from glidepath.core.money import Money
|
|
38
|
+
from glidepath.core.periods import date_age_attained
|
|
39
|
+
|
|
40
|
+
if TYPE_CHECKING:
|
|
41
|
+
from collections.abc import Mapping
|
|
42
|
+
from datetime import date
|
|
43
|
+
|
|
44
|
+
from glidepath.core.entities import EntityId
|
|
45
|
+
from glidepath.core.money import Rate
|
|
46
|
+
from glidepath.core.provenance import Decision, Fact
|
|
47
|
+
|
|
48
|
+
_ZERO = Decimal(0)
|
|
49
|
+
_ONE = Decimal(1)
|
|
50
|
+
_ZERO_MONEY = Money(_ZERO)
|
|
51
|
+
_MONTHS_PER_YEAR = 12
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class RevaluationReference(Enum):
|
|
55
|
+
"""What a DB scheme's revaluation and increases track (planning §5.1)."""
|
|
56
|
+
|
|
57
|
+
CPI = auto()
|
|
58
|
+
"""Tracks the run's CPI path, optionally capped (e.g. CPI max 5%)."""
|
|
59
|
+
FIXED = auto()
|
|
60
|
+
"""A fixed annual rate, whatever inflation does."""
|
|
61
|
+
NONE = auto()
|
|
62
|
+
"""No revaluation: the pension is frozen in nominal terms."""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True, slots=True)
|
|
66
|
+
class RevaluationBasis:
|
|
67
|
+
"""How a DB entitlement revalues — a scheme fact (planning §5.1).
|
|
68
|
+
|
|
69
|
+
The same basis governs deferment revaluation and increases in
|
|
70
|
+
payment in v1 (module docstring).
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
reference: RevaluationReference
|
|
74
|
+
cap: Rate | None = None
|
|
75
|
+
"""Annual cap on CPI-linked revaluation (CPI reference only)."""
|
|
76
|
+
fixed_rate: Rate | None = None
|
|
77
|
+
"""The annual rate of a ``FIXED`` basis (required exactly then)."""
|
|
78
|
+
|
|
79
|
+
def __post_init__(self) -> None:
|
|
80
|
+
"""Require the fields the reference uses, and only those."""
|
|
81
|
+
requires_fixed = self.reference is RevaluationReference.FIXED
|
|
82
|
+
if (self.fixed_rate is not None) != requires_fixed:
|
|
83
|
+
msg = "RevaluationBasis.fixed_rate is required exactly for FIXED"
|
|
84
|
+
raise ValueError(msg)
|
|
85
|
+
if self.cap is not None and self.reference is not RevaluationReference.CPI:
|
|
86
|
+
msg = "RevaluationBasis.cap applies only to a CPI reference"
|
|
87
|
+
raise ValueError(msg)
|
|
88
|
+
if self.cap is not None and self.cap.value < _ZERO:
|
|
89
|
+
msg = "RevaluationBasis.cap must be non-negative"
|
|
90
|
+
raise ValueError(msg)
|
|
91
|
+
|
|
92
|
+
def annual_rate(self, cpi: Decimal) -> Decimal:
|
|
93
|
+
"""The basis's revaluation rate in a year whose CPI is ``cpi``.
|
|
94
|
+
|
|
95
|
+
CPI-linked revaluation is floored at zero (statutory revaluation
|
|
96
|
+
never reduces the entitlement) and capped when the scheme caps
|
|
97
|
+
it; a fixed rate ignores CPI entirely.
|
|
98
|
+
"""
|
|
99
|
+
if self.reference is RevaluationReference.CPI:
|
|
100
|
+
rate = max(cpi, _ZERO)
|
|
101
|
+
if self.cap is not None:
|
|
102
|
+
rate = min(rate, self.cap.value)
|
|
103
|
+
return rate
|
|
104
|
+
if self.fixed_rate is not None: # FIXED, by the __post_init__ invariant
|
|
105
|
+
return self.fixed_rate.value
|
|
106
|
+
return _ZERO
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(frozen=True, slots=True)
|
|
110
|
+
class FactorTable:
|
|
111
|
+
"""Early/late retirement factors — scheme facts, user-entered (§5.1).
|
|
112
|
+
|
|
113
|
+
Maps the whole-year age benefits are taken at to the multiplier the
|
|
114
|
+
scheme applies to the revalued pension (e.g. ``{58: Decimal("0.80")}``
|
|
115
|
+
for a 20% early-retirement reduction at 58). Taking at the normal
|
|
116
|
+
pension age needs no entry (factor 1); taking at any other age not
|
|
117
|
+
in the table is rejected at :class:`DBPension` construction — scheme
|
|
118
|
+
facts drive results, the model never guesses a factor.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
factors: Mapping[int, Decimal]
|
|
122
|
+
|
|
123
|
+
def __post_init__(self) -> None:
|
|
124
|
+
"""Require positive ages and positive factors."""
|
|
125
|
+
for age, factor in self.factors.items():
|
|
126
|
+
if age <= 0:
|
|
127
|
+
msg = "FactorTable ages must be positive"
|
|
128
|
+
raise ValueError(msg)
|
|
129
|
+
if factor <= _ZERO:
|
|
130
|
+
msg = "FactorTable factors must be positive"
|
|
131
|
+
raise ValueError(msg)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@dataclass(frozen=True, slots=True)
|
|
135
|
+
class DBActiveMembership:
|
|
136
|
+
"""CARE-style active membership of a DB scheme (planning §5.1, 9.6).
|
|
137
|
+
|
|
138
|
+
``accrual_rate`` is the fraction of pensionable salary earned as
|
|
139
|
+
annual pension per year of service (a 1/60th scheme enters e.g.
|
|
140
|
+
``0.0166667``); ``pensionable_salary`` is the annual pensionable
|
|
141
|
+
salary — often below total pay — escalated by the earnings-growth
|
|
142
|
+
assumption within the run like employment income.
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
accrual_rate: Fact[Decimal]
|
|
146
|
+
pensionable_salary: Fact[Money]
|
|
147
|
+
active_until_age: Decision[int] | None = None
|
|
148
|
+
"""The age service ends with the pension left deferred; ``None``
|
|
149
|
+
means service continues until benefits start (module docstring —
|
|
150
|
+
the engine's retirement gate applies either way)."""
|
|
151
|
+
|
|
152
|
+
def __post_init__(self) -> None:
|
|
153
|
+
"""Reject impossible scheme facts and choices."""
|
|
154
|
+
if not _ZERO < self.accrual_rate.value <= _ONE:
|
|
155
|
+
msg = "DBActiveMembership.accrual_rate must lie in (0, 1]"
|
|
156
|
+
raise ValueError(msg)
|
|
157
|
+
if self.pensionable_salary.value < _ZERO_MONEY:
|
|
158
|
+
msg = "DBActiveMembership.pensionable_salary must be non-negative"
|
|
159
|
+
raise ValueError(msg)
|
|
160
|
+
if self.active_until_age is not None and self.active_until_age.value <= 0:
|
|
161
|
+
msg = "DBActiveMembership.active_until_age must be positive"
|
|
162
|
+
raise ValueError(msg)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@dataclass(frozen=True, slots=True)
|
|
166
|
+
class DBPension:
|
|
167
|
+
"""One DB entitlement — deferred, or active via ``active_membership``.
|
|
168
|
+
|
|
169
|
+
``accrued_annual_pension`` is the annual pension at the statement
|
|
170
|
+
(or leaving) date; ``commuted_fraction`` is the user's decision to
|
|
171
|
+
trade that fraction of the pension for a lump sum of
|
|
172
|
+
``pension given up x commutation_factor`` when benefits start
|
|
173
|
+
(planning §5.1).
|
|
174
|
+
"""
|
|
175
|
+
|
|
176
|
+
id: EntityId
|
|
177
|
+
accrued_annual_pension: Fact[Money]
|
|
178
|
+
statement_date: date
|
|
179
|
+
normal_pension_age: Fact[int]
|
|
180
|
+
revaluation_basis: RevaluationBasis
|
|
181
|
+
early_late_factors: FactorTable
|
|
182
|
+
commuted_fraction: Decision[Decimal]
|
|
183
|
+
commutation_factor: Fact[Decimal] | None = None
|
|
184
|
+
"""Pounds of lump sum per pound of annual pension given up."""
|
|
185
|
+
taken_at_age: Decision[int] | None = None
|
|
186
|
+
"""The age benefits start; ``None`` means the normal pension age."""
|
|
187
|
+
active_membership: DBActiveMembership | None = None
|
|
188
|
+
"""Active CARE-style accrual; ``None`` means deferred (roadmap 9.6)."""
|
|
189
|
+
|
|
190
|
+
def __post_init__(self) -> None:
|
|
191
|
+
"""Reject inconsistent scheme facts and choices loudly.
|
|
192
|
+
|
|
193
|
+
Everything checkable without a run date is checked here, so an
|
|
194
|
+
instance that exists is projectable: the engine never has to
|
|
195
|
+
guess a factor or a commutation basis mid-run.
|
|
196
|
+
"""
|
|
197
|
+
if self.accrued_annual_pension.value < _ZERO_MONEY:
|
|
198
|
+
msg = "DBPension.accrued_annual_pension must be non-negative"
|
|
199
|
+
raise ValueError(msg)
|
|
200
|
+
if self.normal_pension_age.value <= 0:
|
|
201
|
+
msg = "DBPension.normal_pension_age must be positive"
|
|
202
|
+
raise ValueError(msg)
|
|
203
|
+
fraction = self.commuted_fraction.value
|
|
204
|
+
if not _ZERO <= fraction <= _ONE:
|
|
205
|
+
msg = "DBPension.commuted_fraction must lie between 0 and 1"
|
|
206
|
+
raise ValueError(msg)
|
|
207
|
+
if fraction > _ZERO:
|
|
208
|
+
if self.commutation_factor is None:
|
|
209
|
+
msg = "DBPension.commutation_factor is required to commute pension"
|
|
210
|
+
raise ValueError(msg)
|
|
211
|
+
if self.commutation_factor.value <= _ZERO:
|
|
212
|
+
msg = "DBPension.commutation_factor must be positive"
|
|
213
|
+
raise ValueError(msg)
|
|
214
|
+
taken = db_taken_age(self)
|
|
215
|
+
if taken <= 0:
|
|
216
|
+
msg = "DBPension.taken_at_age must be positive"
|
|
217
|
+
raise ValueError(msg)
|
|
218
|
+
if taken != self.normal_pension_age.value and taken not in (
|
|
219
|
+
self.early_late_factors.factors
|
|
220
|
+
):
|
|
221
|
+
msg = (
|
|
222
|
+
f"DBPension.early_late_factors has no factor for age {taken};"
|
|
223
|
+
" scheme facts drive results (planning §5.1)"
|
|
224
|
+
)
|
|
225
|
+
raise ValueError(msg)
|
|
226
|
+
membership = self.active_membership
|
|
227
|
+
if (
|
|
228
|
+
membership is not None
|
|
229
|
+
and membership.active_until_age is not None
|
|
230
|
+
and membership.active_until_age.value > taken
|
|
231
|
+
):
|
|
232
|
+
msg = (
|
|
233
|
+
"DBPension.active_membership.active_until_age must not"
|
|
234
|
+
" exceed the age benefits are taken (planning §5.1)"
|
|
235
|
+
)
|
|
236
|
+
raise ValueError(msg)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def db_taken_age(pension: DBPension) -> int:
|
|
240
|
+
"""The age benefits start: the taken-at decision, else the NPA."""
|
|
241
|
+
if pension.taken_at_age is not None:
|
|
242
|
+
return pension.taken_at_age.value
|
|
243
|
+
return pension.normal_pension_age.value
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def db_start_date(pension: DBPension, date_of_birth: date) -> date:
|
|
247
|
+
"""The exact date benefits start (an income entitlement, §4.1)."""
|
|
248
|
+
return date_age_attained(date_of_birth, db_taken_age(pension))
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def db_service_end_date(pension: DBPension, date_of_birth: date) -> date:
|
|
252
|
+
"""The date active service ends: leave-and-defer age, else benefits.
|
|
253
|
+
|
|
254
|
+
Meaningful only for a pension with an ``active_membership``; the
|
|
255
|
+
engine's retirement gate (planning §5.1) may stop accrual earlier
|
|
256
|
+
still. The construction-time invariant keeps this at or before
|
|
257
|
+
:func:`db_start_date`.
|
|
258
|
+
"""
|
|
259
|
+
membership = pension.active_membership
|
|
260
|
+
if membership is not None and membership.active_until_age is not None:
|
|
261
|
+
return date_age_attained(date_of_birth, membership.active_until_age.value)
|
|
262
|
+
return db_start_date(pension, date_of_birth)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def db_early_late_factor(pension: DBPension) -> Decimal:
|
|
266
|
+
"""The scheme factor applied to the revalued pension at start.
|
|
267
|
+
|
|
268
|
+
Taking at the normal pension age defaults to a factor of 1 unless
|
|
269
|
+
the table states otherwise; every other age has a table entry
|
|
270
|
+
(enforced at construction).
|
|
271
|
+
"""
|
|
272
|
+
taken = db_taken_age(pension)
|
|
273
|
+
factor = pension.early_late_factors.factors.get(taken)
|
|
274
|
+
if factor is not None:
|
|
275
|
+
return factor
|
|
276
|
+
return _ONE
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def revaluation_factor_for_months(annual_rate: Decimal, months: int) -> Decimal:
|
|
280
|
+
"""Cumulative revaluation over ``months`` whole months at one rate.
|
|
281
|
+
|
|
282
|
+
Whole years compound with an integer exponent; the remaining months
|
|
283
|
+
scale the annual rate linearly — the §4.1/§5.2 whole-month
|
|
284
|
+
convention in exact ``Decimal`` arithmetic (planning §4.6 rejects
|
|
285
|
+
fractional-exponent powers). Used for the span before the run's
|
|
286
|
+
``today``, which the engine never models period-by-period.
|
|
287
|
+
|
|
288
|
+
Raises:
|
|
289
|
+
ValueError: If ``months`` is negative.
|
|
290
|
+
"""
|
|
291
|
+
if months < 0:
|
|
292
|
+
msg = "months must be non-negative"
|
|
293
|
+
raise ValueError(msg)
|
|
294
|
+
years, remainder = divmod(months, _MONTHS_PER_YEAR)
|
|
295
|
+
factor = (_ONE + annual_rate) ** years
|
|
296
|
+
return factor * (
|
|
297
|
+
_ONE + annual_rate * Decimal(remainder) / Decimal(_MONTHS_PER_YEAR)
|
|
298
|
+
)
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
"""Periods, fiscal calendars, and the age/pro-rating conventions.
|
|
2
|
+
|
|
3
|
+
Implements planning §4.1: the engine steps annually over *periods*
|
|
4
|
+
supplied by a region's :class:`FiscalCalendar` (the core never knows what
|
|
5
|
+
"6 April" is). Age-triggered changes follow one convention, tested at
|
|
6
|
+
boundaries:
|
|
7
|
+
|
|
8
|
+
- **Access gates** are open for a period only if the age is attained on or
|
|
9
|
+
before the period's first day (:func:`is_age_attained_by_period_start`).
|
|
10
|
+
- **Income entitlements** begin at their exact date and are pro-rated by
|
|
11
|
+
whole months within their starting period (:func:`prorata_fraction`).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import calendar
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from datetime import date, timedelta
|
|
17
|
+
from decimal import Decimal
|
|
18
|
+
from typing import TYPE_CHECKING, Protocol
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from collections.abc import Iterator
|
|
22
|
+
|
|
23
|
+
_FEB_29 = (2, 29)
|
|
24
|
+
_NON_LEAP_YEAR = 2001 # any non-leap year; used to validate calendar anchors
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True, slots=True, order=True)
|
|
28
|
+
class Period:
|
|
29
|
+
"""A consecutive span of days, inclusive of both endpoints."""
|
|
30
|
+
|
|
31
|
+
start: date
|
|
32
|
+
end: date
|
|
33
|
+
|
|
34
|
+
def __post_init__(self) -> None:
|
|
35
|
+
"""Reject periods that end before they start."""
|
|
36
|
+
if self.end < self.start:
|
|
37
|
+
msg = f"Period end {self.end} precedes start {self.start}"
|
|
38
|
+
raise ValueError(msg)
|
|
39
|
+
|
|
40
|
+
def contains(self, day: date) -> bool:
|
|
41
|
+
"""Whether ``day`` falls within this period."""
|
|
42
|
+
return self.start <= day <= self.end
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class FiscalCalendar(Protocol):
|
|
46
|
+
"""Region-supplied period scheme (planning §4.1, §4.2).
|
|
47
|
+
|
|
48
|
+
The UK region implements this with tax years (6 Apr to 5 Apr); the
|
|
49
|
+
core only ever sees opaque :class:`Period` values.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def period_containing(self, day: date) -> Period:
|
|
53
|
+
"""Return the period that contains ``day``."""
|
|
54
|
+
...
|
|
55
|
+
|
|
56
|
+
def periods(self, first: date, last: date) -> Iterator[Period]:
|
|
57
|
+
"""Yield consecutive periods covering ``first`` through ``last``."""
|
|
58
|
+
...
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True, slots=True)
|
|
62
|
+
class AnnualCalendar:
|
|
63
|
+
"""Generic annual calendar: each period runs one year from an anchor.
|
|
64
|
+
|
|
65
|
+
The default anchor is 1 January. The anchor must be a day that exists
|
|
66
|
+
in every year, so 29 February is rejected.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
anchor_month: int = 1
|
|
70
|
+
anchor_day: int = 1
|
|
71
|
+
|
|
72
|
+
def __post_init__(self) -> None:
|
|
73
|
+
"""Reject anchors that do not exist in every year."""
|
|
74
|
+
try:
|
|
75
|
+
date(_NON_LEAP_YEAR, self.anchor_month, self.anchor_day)
|
|
76
|
+
except ValueError as exc:
|
|
77
|
+
anchor = f"{self.anchor_month:02d}-{self.anchor_day:02d}"
|
|
78
|
+
msg = f"anchor {anchor} must be a valid day in every year (no 29 February)"
|
|
79
|
+
raise ValueError(msg) from exc
|
|
80
|
+
|
|
81
|
+
def _anchor_in_year(self, year: int) -> date:
|
|
82
|
+
"""The period start date falling in calendar ``year``."""
|
|
83
|
+
return date(year, self.anchor_month, self.anchor_day)
|
|
84
|
+
|
|
85
|
+
def period_containing(self, day: date) -> Period:
|
|
86
|
+
"""Return the annual period that contains ``day``."""
|
|
87
|
+
start_year = day.year if day >= self._anchor_in_year(day.year) else day.year - 1
|
|
88
|
+
start = self._anchor_in_year(start_year)
|
|
89
|
+
return Period(start, self._anchor_in_year(start_year + 1) - timedelta(days=1))
|
|
90
|
+
|
|
91
|
+
def periods(self, first: date, last: date) -> Iterator[Period]:
|
|
92
|
+
"""Yield consecutive annual periods covering ``first`` to ``last``.
|
|
93
|
+
|
|
94
|
+
Raises:
|
|
95
|
+
ValueError: If ``last`` precedes ``first``.
|
|
96
|
+
"""
|
|
97
|
+
if last < first:
|
|
98
|
+
msg = f"horizon end {last} precedes start {first}"
|
|
99
|
+
raise ValueError(msg)
|
|
100
|
+
current = self.period_containing(first)
|
|
101
|
+
while current.start <= last:
|
|
102
|
+
yield current
|
|
103
|
+
current = self.period_containing(current.end + timedelta(days=1))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def birthday_in_year(date_of_birth: date, year: int) -> date:
|
|
107
|
+
"""The date the birthday falls (or is deemed to fall) in ``year``.
|
|
108
|
+
|
|
109
|
+
A 29 February birthday is deemed to fall on 1 March in non-leap years,
|
|
110
|
+
matching the UK legal convention for attaining an age.
|
|
111
|
+
"""
|
|
112
|
+
if (date_of_birth.month, date_of_birth.day) == _FEB_29 and not calendar.isleap(
|
|
113
|
+
year
|
|
114
|
+
):
|
|
115
|
+
return date(year, 3, 1)
|
|
116
|
+
return date(year, date_of_birth.month, date_of_birth.day)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def date_age_attained(date_of_birth: date, age: int) -> date:
|
|
120
|
+
"""The exact date on which the person attains ``age`` whole years.
|
|
121
|
+
|
|
122
|
+
Raises:
|
|
123
|
+
ValueError: If ``age`` is negative.
|
|
124
|
+
"""
|
|
125
|
+
if age < 0:
|
|
126
|
+
msg = "age must be non-negative"
|
|
127
|
+
raise ValueError(msg)
|
|
128
|
+
return birthday_in_year(date_of_birth, date_of_birth.year + age)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def age_on(date_of_birth: date, day: date) -> int:
|
|
132
|
+
"""Whole years of age on ``day``.
|
|
133
|
+
|
|
134
|
+
Raises:
|
|
135
|
+
ValueError: If ``day`` precedes the date of birth.
|
|
136
|
+
"""
|
|
137
|
+
if day < date_of_birth:
|
|
138
|
+
msg = f"day {day} precedes date of birth {date_of_birth}"
|
|
139
|
+
raise ValueError(msg)
|
|
140
|
+
years = day.year - date_of_birth.year
|
|
141
|
+
if day < birthday_in_year(date_of_birth, day.year):
|
|
142
|
+
years -= 1
|
|
143
|
+
return years
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def is_age_attained_by_period_start(
|
|
147
|
+
date_of_birth: date, age: int, period: Period
|
|
148
|
+
) -> bool:
|
|
149
|
+
"""The access-gate convention of planning §4.1.
|
|
150
|
+
|
|
151
|
+
A gate (NMPA, LISA access, ...) is open for a period only if ``age``
|
|
152
|
+
is attained on or before the period's first day — conservative, so the
|
|
153
|
+
model never simulates a withdrawal that would be unauthorised in
|
|
154
|
+
reality. A birthday on the period's last day unlocks the *next*
|
|
155
|
+
period, never the current one.
|
|
156
|
+
"""
|
|
157
|
+
return date_age_attained(date_of_birth, age) <= period.start
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def add_months(day: date, months: int) -> date:
|
|
161
|
+
"""Shift ``day`` by calendar months, clamping to the target month's end.
|
|
162
|
+
|
|
163
|
+
``add_months(date(2026, 1, 31), 1)`` is 28 February 2026.
|
|
164
|
+
"""
|
|
165
|
+
total = day.month - 1 + months
|
|
166
|
+
year = day.year + total // 12
|
|
167
|
+
month = total % 12 + 1
|
|
168
|
+
last_day = calendar.monthrange(year, month)[1]
|
|
169
|
+
return date(year, month, min(day.day, last_day))
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def whole_months_between(start: date, end_exclusive: date) -> int:
|
|
173
|
+
"""Complete calendar months from ``start`` up to ``end_exclusive``.
|
|
174
|
+
|
|
175
|
+
The count is the largest ``n`` such that ``add_months(start, n)`` does
|
|
176
|
+
not pass ``end_exclusive`` (day-clamped month arithmetic).
|
|
177
|
+
|
|
178
|
+
Raises:
|
|
179
|
+
ValueError: If ``end_exclusive`` precedes ``start``.
|
|
180
|
+
"""
|
|
181
|
+
if end_exclusive < start:
|
|
182
|
+
msg = f"end {end_exclusive} precedes start {start}"
|
|
183
|
+
raise ValueError(msg)
|
|
184
|
+
months = (end_exclusive.year - start.year) * 12 + (
|
|
185
|
+
end_exclusive.month - start.month
|
|
186
|
+
)
|
|
187
|
+
while months > 0 and add_months(start, months) > end_exclusive:
|
|
188
|
+
months -= 1
|
|
189
|
+
return max(months, 0)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _whole_months_in(period: Period) -> int:
|
|
193
|
+
"""Whole months in ``period``, which pro-rating divides by.
|
|
194
|
+
|
|
195
|
+
Raises:
|
|
196
|
+
ValueError: If ``period`` is shorter than one whole month.
|
|
197
|
+
"""
|
|
198
|
+
months = whole_months_between(period.start, period.end + timedelta(days=1))
|
|
199
|
+
if months == 0:
|
|
200
|
+
msg = "period is shorter than one whole month; cannot pro-rate"
|
|
201
|
+
raise ValueError(msg)
|
|
202
|
+
return months
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def prorata_fraction(entitlement_start: date, period: Period) -> Decimal:
|
|
206
|
+
"""Fraction of ``period`` an entitlement starting mid-period is paid for.
|
|
207
|
+
|
|
208
|
+
Implements the income-entitlement convention of planning §4.1: incomes
|
|
209
|
+
(state pension from SPA, DB from NPA, annuity start) begin at their
|
|
210
|
+
exact date and are pro-rated by whole months within their starting
|
|
211
|
+
period, as an exact ``Decimal`` fraction.
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
``1`` if the entitlement started on or before the period began,
|
|
215
|
+
``0`` if it starts after the period ends, otherwise
|
|
216
|
+
``whole months in payment / whole months in the period``.
|
|
217
|
+
|
|
218
|
+
Raises:
|
|
219
|
+
ValueError: If ``period`` is shorter than one whole month.
|
|
220
|
+
"""
|
|
221
|
+
if entitlement_start <= period.start:
|
|
222
|
+
return Decimal(1)
|
|
223
|
+
if entitlement_start > period.end:
|
|
224
|
+
return Decimal(0)
|
|
225
|
+
months_in_period = _whole_months_in(period)
|
|
226
|
+
end_exclusive = period.end + timedelta(days=1)
|
|
227
|
+
months_in_payment = whole_months_between(entitlement_start, end_exclusive)
|
|
228
|
+
return Decimal(months_in_payment) / Decimal(months_in_period)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def entitlement_active_fraction(
|
|
232
|
+
entitlement_start: date,
|
|
233
|
+
period: Period,
|
|
234
|
+
window_start: date,
|
|
235
|
+
window_end: date,
|
|
236
|
+
) -> Decimal:
|
|
237
|
+
"""Fraction of ``period`` an entitlement is in payment inside the window.
|
|
238
|
+
|
|
239
|
+
Combines the two §4.1/§5.2 conventions for income entitlements (DB
|
|
240
|
+
from its start date, state pension from SPA plus deferral): payment
|
|
241
|
+
runs from ``entitlement_start`` and the run models only
|
|
242
|
+
``[window_start, window_end]`` (roadmap 4.6), so the period's
|
|
243
|
+
payable share is the whole months of the overlap over the whole
|
|
244
|
+
months of the period. With the window covering the period this is
|
|
245
|
+
exactly :func:`prorata_fraction`; with the entitlement predating the
|
|
246
|
+
window it is exactly :func:`period_active_fraction`.
|
|
247
|
+
|
|
248
|
+
Returns:
|
|
249
|
+
``1`` if payment covers the whole period inside the window,
|
|
250
|
+
``0`` if the overlap is under one whole month, otherwise the
|
|
251
|
+
exact ``Decimal`` month ratio.
|
|
252
|
+
|
|
253
|
+
Raises:
|
|
254
|
+
ValueError: If ``window_end`` precedes ``window_start``, or the
|
|
255
|
+
period is shorter than one whole month.
|
|
256
|
+
"""
|
|
257
|
+
if window_end < window_start:
|
|
258
|
+
msg = f"window end {window_end} precedes window start {window_start}"
|
|
259
|
+
raise ValueError(msg)
|
|
260
|
+
payable_start = max(entitlement_start, window_start, period.start)
|
|
261
|
+
payable_end = min(window_end, period.end)
|
|
262
|
+
if payable_end < payable_start:
|
|
263
|
+
return Decimal(0)
|
|
264
|
+
if payable_start == period.start and payable_end == period.end:
|
|
265
|
+
return Decimal(1)
|
|
266
|
+
months_in_period = _whole_months_in(period)
|
|
267
|
+
end_exclusive = payable_end + timedelta(days=1)
|
|
268
|
+
months_payable = whole_months_between(payable_start, end_exclusive)
|
|
269
|
+
return Decimal(months_payable) / Decimal(months_in_period)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def service_active_fraction(
|
|
273
|
+
service_end: date,
|
|
274
|
+
period: Period,
|
|
275
|
+
window_start: date,
|
|
276
|
+
window_end: date,
|
|
277
|
+
) -> Decimal:
|
|
278
|
+
"""Fraction of ``period`` inside the window before ``service_end``.
|
|
279
|
+
|
|
280
|
+
The mirror of :func:`entitlement_active_fraction` for a span that
|
|
281
|
+
*ends* on an exact date — active DB service stopping at the
|
|
282
|
+
leave-and-defer age or the benefits start (roadmap 9.6, planning
|
|
283
|
+
§5.1). ``service_end`` is exclusive: service through the day before
|
|
284
|
+
the birthday counts, the birthday itself does not.
|
|
285
|
+
|
|
286
|
+
Returns:
|
|
287
|
+
``1`` if service covers the whole period inside the window,
|
|
288
|
+
``0`` if the overlap is under one whole month, otherwise the
|
|
289
|
+
exact ``Decimal`` month ratio.
|
|
290
|
+
|
|
291
|
+
Raises:
|
|
292
|
+
ValueError: If ``window_end`` precedes ``window_start``, or the
|
|
293
|
+
period is shorter than one whole month.
|
|
294
|
+
"""
|
|
295
|
+
if window_end < window_start:
|
|
296
|
+
msg = f"window end {window_end} precedes window start {window_start}"
|
|
297
|
+
raise ValueError(msg)
|
|
298
|
+
active_start = max(window_start, period.start)
|
|
299
|
+
active_end_exclusive = min(
|
|
300
|
+
service_end, min(window_end, period.end) + timedelta(days=1)
|
|
301
|
+
)
|
|
302
|
+
if active_end_exclusive <= active_start:
|
|
303
|
+
return Decimal(0)
|
|
304
|
+
if active_start == period.start and active_end_exclusive == period.end + timedelta(
|
|
305
|
+
days=1
|
|
306
|
+
):
|
|
307
|
+
return Decimal(1)
|
|
308
|
+
months_in_period = _whole_months_in(period)
|
|
309
|
+
months_active = whole_months_between(active_start, active_end_exclusive)
|
|
310
|
+
return Decimal(months_active) / Decimal(months_in_period)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def period_active_fraction(
|
|
314
|
+
period: Period, window_start: date, window_end: date
|
|
315
|
+
) -> Decimal:
|
|
316
|
+
"""Fraction of ``period`` inside the run window, by whole months.
|
|
317
|
+
|
|
318
|
+
Implements the partial first/last period convention (planning §5.2,
|
|
319
|
+
roadmap 4.6): the engine models only the window from ``window_start``
|
|
320
|
+
(the run's ``today``) through ``window_end`` (the horizon end), both
|
|
321
|
+
inclusive. A period partly outside that window is scaled by
|
|
322
|
+
``whole months inside the window / whole months in the period`` —
|
|
323
|
+
the same §4.1 whole-month convention as other partial years. For a
|
|
324
|
+
window open at the end, this agrees with :func:`prorata_fraction`.
|
|
325
|
+
|
|
326
|
+
Returns:
|
|
327
|
+
``1`` if the period lies wholly inside the window, ``0`` if less
|
|
328
|
+
than one whole month of it does, otherwise the exact ``Decimal``
|
|
329
|
+
month ratio.
|
|
330
|
+
|
|
331
|
+
Raises:
|
|
332
|
+
ValueError: If ``window_end`` precedes ``window_start``, or the
|
|
333
|
+
period is shorter than one whole month.
|
|
334
|
+
"""
|
|
335
|
+
if window_end < window_start:
|
|
336
|
+
msg = f"window end {window_end} precedes window start {window_start}"
|
|
337
|
+
raise ValueError(msg)
|
|
338
|
+
if window_start <= period.start and window_end >= period.end:
|
|
339
|
+
return Decimal(1)
|
|
340
|
+
active_start = max(window_start, period.start)
|
|
341
|
+
active_end_exclusive = min(window_end, period.end) + timedelta(days=1)
|
|
342
|
+
if active_end_exclusive <= active_start:
|
|
343
|
+
return Decimal(0)
|
|
344
|
+
months_in_period = _whole_months_in(period)
|
|
345
|
+
months_active = whole_months_between(active_start, active_end_exclusive)
|
|
346
|
+
return Decimal(months_active) / Decimal(months_in_period)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
class AgeRules(Protocol):
|
|
350
|
+
"""Region-supplied age rules (planning §4.1, §4.2).
|
|
351
|
+
|
|
352
|
+
Turns a date of birth into the exact dates and period gates behind
|
|
353
|
+
age-triggered changes: state pension entitlement is an *income
|
|
354
|
+
entitlement* (begins at its exact date, pro-rated via
|
|
355
|
+
:func:`prorata_fraction`) while private pension access is an
|
|
356
|
+
*access gate* (following :func:`is_age_attained_by_period_start`).
|
|
357
|
+
Wrapper-specific age gates (e.g. UK LISA ages) stay behind the
|
|
358
|
+
region's wrapper rules rather than crossing the boundary here.
|
|
359
|
+
"""
|
|
360
|
+
|
|
361
|
+
def state_pension_date(self, date_of_birth: date) -> date:
|
|
362
|
+
"""The exact date state pension entitlement begins."""
|
|
363
|
+
...
|
|
364
|
+
|
|
365
|
+
def is_pension_access_open(self, date_of_birth: date, period: Period) -> bool:
|
|
366
|
+
"""Whether new pension benefits may be accessed in ``period``."""
|
|
367
|
+
...
|