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,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
+ ...