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,291 @@
|
|
|
1
|
+
"""The retirement-question solvers (roadmap 9.14, 9.25; planning §5.2).
|
|
2
|
+
|
|
3
|
+
:func:`earliest_retirement_age` finds the earliest target retirement
|
|
4
|
+
age at which the plan sustains a target retirement income — the age
|
|
5
|
+
counterpart of :func:`~glidepath.core.montecarlo.sustainable_income`
|
|
6
|
+
(roadmap 7.3): the same probe-plan-per-candidate search over runs,
|
|
7
|
+
searching the retirement-age decision instead of the spending level.
|
|
8
|
+
Where the spending search bisects a continuous bracket, the age domain
|
|
9
|
+
is a few dozen whole years, so an ascending scan probes every
|
|
10
|
+
candidate: the returned age is exactly the earliest succeeding one
|
|
11
|
+
even when success is not monotone in age (a DB scheme's early-payment
|
|
12
|
+
factors or a dated outflow can make it dip), and every answer was
|
|
13
|
+
actually probed, never interpolated. A candidate whose retirement
|
|
14
|
+
date falls at or past the run's horizon has no retired period to test
|
|
15
|
+
the income in — it fails rather than succeeding vacuously.
|
|
16
|
+
|
|
17
|
+
Each probe replaces the (v1 single) person's retirement-age decision
|
|
18
|
+
with the candidate and the household's spending plan with the target
|
|
19
|
+
income, then runs the plan under the given config. Under a
|
|
20
|
+
deterministic config a candidate succeeds when no period's need goes
|
|
21
|
+
unmet — the same per-period ``shortfall`` ruin signal the Monte Carlo
|
|
22
|
+
metrics read (planning §5.2). Under a seeded Monte Carlo config the
|
|
23
|
+
candidate's paths run through :func:`~glidepath.core.run_paths` and
|
|
24
|
+
success means their success rate meets the search's target — "earliest
|
|
25
|
+
age with ≥ N% Monte Carlo success". Every probe reuses the same config
|
|
26
|
+
(common random numbers), so the search is reproducible from the seed
|
|
27
|
+
alone (§4.6), and probe plans never leave the search.
|
|
28
|
+
|
|
29
|
+
:func:`sustainable_income_at_age` is the same question asked the
|
|
30
|
+
other way around (roadmap 9.25): "how much can I draw down if I
|
|
31
|
+
retire at this age?" — the retirement-age decision is fixed at the
|
|
32
|
+
chosen age and the spending level is searched, delegating to the
|
|
33
|
+
7.3 income search under the same exposure gate: an age with no
|
|
34
|
+
retired period inside the run's horizon has nothing to test the
|
|
35
|
+
income in, so it answers ``None`` rather than succeeding vacuously.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from dataclasses import dataclass, replace
|
|
39
|
+
from decimal import Decimal
|
|
40
|
+
from typing import TYPE_CHECKING, Any
|
|
41
|
+
|
|
42
|
+
from glidepath.core.config import RunMode
|
|
43
|
+
from glidepath.core.engine import run
|
|
44
|
+
from glidepath.core.money import Money
|
|
45
|
+
from glidepath.core.montecarlo import (
|
|
46
|
+
has_shortfall,
|
|
47
|
+
probe_with_spending,
|
|
48
|
+
run_paths,
|
|
49
|
+
sustainable_income,
|
|
50
|
+
)
|
|
51
|
+
from glidepath.core.periods import date_age_attained, is_age_attained_by_period_start
|
|
52
|
+
from glidepath.core.provenance import AssumptionKey, int_assumption_value
|
|
53
|
+
|
|
54
|
+
if TYPE_CHECKING:
|
|
55
|
+
from datetime import date
|
|
56
|
+
|
|
57
|
+
from glidepath.core.config import RunConfig
|
|
58
|
+
from glidepath.core.entities import Household, Person
|
|
59
|
+
from glidepath.core.montecarlo import PathParallelism, SustainableIncomeSearch
|
|
60
|
+
from glidepath.core.periods import Period
|
|
61
|
+
from glidepath.core.provenance import AssumptionSet
|
|
62
|
+
from glidepath.core.region import Region
|
|
63
|
+
|
|
64
|
+
_ZERO = Money(Decimal(0))
|
|
65
|
+
_ONE = Decimal(1)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True, slots=True)
|
|
69
|
+
class RetirementAgeSearch:
|
|
70
|
+
"""The parameters of one earliest-retirement-age search (9.14).
|
|
71
|
+
|
|
72
|
+
``target_income`` is the real (today's money) net annual retirement
|
|
73
|
+
income the plan must sustain — the replacement-rate target the app
|
|
74
|
+
layer derives from employment income. Candidate ages run from
|
|
75
|
+
``minimum_age`` to ``maximum_age`` inclusive; a candidate at or
|
|
76
|
+
below the person's current age simply retires the plan from its
|
|
77
|
+
first period (the engine's §4.1 gate convention), so "retire now"
|
|
78
|
+
is an ordinary probe. ``paths`` and ``target_success_rate`` apply
|
|
79
|
+
only under a Monte Carlo config: a candidate succeeds when at least
|
|
80
|
+
the target fraction of its seeded paths avoid ruin. A deterministic
|
|
81
|
+
probe ignores them — success is one run with no unmet need, the
|
|
82
|
+
single-path equivalent of a 100% target.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
target_income: Money
|
|
86
|
+
minimum_age: int
|
|
87
|
+
maximum_age: int
|
|
88
|
+
paths: int = 1
|
|
89
|
+
target_success_rate: Decimal = _ONE
|
|
90
|
+
|
|
91
|
+
def __post_init__(self) -> None:
|
|
92
|
+
"""Reject an empty target, a backwards bracket, or off-range knobs."""
|
|
93
|
+
if self.target_income <= _ZERO:
|
|
94
|
+
msg = "target_income must be positive"
|
|
95
|
+
raise ValueError(msg)
|
|
96
|
+
if self.minimum_age < 0:
|
|
97
|
+
msg = f"minimum_age must be non-negative, got {self.minimum_age}"
|
|
98
|
+
raise ValueError(msg)
|
|
99
|
+
if self.maximum_age < self.minimum_age:
|
|
100
|
+
msg = (
|
|
101
|
+
f"maximum_age {self.maximum_age} precedes"
|
|
102
|
+
f" minimum_age {self.minimum_age}"
|
|
103
|
+
)
|
|
104
|
+
raise ValueError(msg)
|
|
105
|
+
if self.paths < 1:
|
|
106
|
+
msg = f"paths must be positive, got {self.paths}"
|
|
107
|
+
raise ValueError(msg)
|
|
108
|
+
if not Decimal(0) < self.target_success_rate <= _ONE:
|
|
109
|
+
msg = (
|
|
110
|
+
"target_success_rate must lie in (0, 1],"
|
|
111
|
+
f" got {self.target_success_rate}"
|
|
112
|
+
)
|
|
113
|
+
raise ValueError(msg)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def earliest_retirement_age(
|
|
117
|
+
plan: Household,
|
|
118
|
+
assumptions: AssumptionSet,
|
|
119
|
+
region: Region,
|
|
120
|
+
config: RunConfig,
|
|
121
|
+
search: RetirementAgeSearch,
|
|
122
|
+
*,
|
|
123
|
+
parallelism: PathParallelism | None = None,
|
|
124
|
+
) -> int | None:
|
|
125
|
+
"""The earliest retirement age sustaining the target income (9.14).
|
|
126
|
+
|
|
127
|
+
Probes every age in the search bracket in ascending order and
|
|
128
|
+
returns the first that succeeds — exactly the earliest, whatever
|
|
129
|
+
the success shape over ages (module docstring) — or ``None`` when
|
|
130
|
+
no age in the bracket does. A candidate with no *retirement
|
|
131
|
+
exposure* — no projected period opening the plan retired under the
|
|
132
|
+
§4.1 gate convention, because its retirement date falls at or past
|
|
133
|
+
the run's horizon — never tests the target income at all, so it
|
|
134
|
+
fails rather than succeeding vacuously; such candidates are never
|
|
135
|
+
probed. The plan's stated retirement age and spending level are
|
|
136
|
+
irrelevant to the search: each probe carries the candidate age and
|
|
137
|
+
the target income instead, everything else unchanged, and
|
|
138
|
+
re-running the plan at the returned age with the target income as
|
|
139
|
+
its spending reproduces the success. ``parallelism`` spreads each
|
|
140
|
+
Monte Carlo candidate's paths over its executor
|
|
141
|
+
(:class:`~glidepath.core.montecarlo.PathParallelism` — results
|
|
142
|
+
identical to a serial search); pass one executor for the whole
|
|
143
|
+
search so candidates share it rather than paying process startup
|
|
144
|
+
per age.
|
|
145
|
+
|
|
146
|
+
Raises:
|
|
147
|
+
EngineError: If a probe is rejected by the engine — including a
|
|
148
|
+
Monte Carlo config without a seed (planning §5.2).
|
|
149
|
+
"""
|
|
150
|
+
date_of_birth = plan.persons[0].date_of_birth.value
|
|
151
|
+
periods = _projected_periods(plan, assumptions, region, config)
|
|
152
|
+
|
|
153
|
+
def has_retired_period(age: int) -> bool:
|
|
154
|
+
"""Whether any projected period opens the plan retired (§4.1)."""
|
|
155
|
+
return any(
|
|
156
|
+
is_age_attained_by_period_start(date_of_birth, age, period)
|
|
157
|
+
for period in periods
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
def meets(age: int) -> bool:
|
|
161
|
+
"""Whether retiring at ``age`` sustains the target income."""
|
|
162
|
+
probe = _with_retirement_age(
|
|
163
|
+
probe_with_spending(plan, search.target_income, config), age
|
|
164
|
+
)
|
|
165
|
+
if config.mode is RunMode.MONTE_CARLO:
|
|
166
|
+
result = run_paths(
|
|
167
|
+
probe,
|
|
168
|
+
assumptions,
|
|
169
|
+
region,
|
|
170
|
+
config,
|
|
171
|
+
paths=search.paths,
|
|
172
|
+
parallelism=parallelism,
|
|
173
|
+
)
|
|
174
|
+
return result.success_rate >= search.target_success_rate
|
|
175
|
+
return not has_shortfall(run(probe, assumptions, region, config))
|
|
176
|
+
|
|
177
|
+
for age in range(search.minimum_age, search.maximum_age + 1):
|
|
178
|
+
if has_retired_period(age) and meets(age):
|
|
179
|
+
return age
|
|
180
|
+
return None
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def sustainable_income_at_age(
|
|
184
|
+
plan: Household,
|
|
185
|
+
assumptions: AssumptionSet,
|
|
186
|
+
region: Region,
|
|
187
|
+
config: RunConfig,
|
|
188
|
+
*,
|
|
189
|
+
age: int,
|
|
190
|
+
search: SustainableIncomeSearch,
|
|
191
|
+
parallelism: PathParallelism | None = None,
|
|
192
|
+
) -> Money | None:
|
|
193
|
+
"""The highest income sustainable when retiring at ``age`` (9.25).
|
|
194
|
+
|
|
195
|
+
The drawdown dual of :func:`earliest_retirement_age`: the (v1
|
|
196
|
+
single) person's retirement-age decision is replaced with ``age``
|
|
197
|
+
and the spending level is searched through
|
|
198
|
+
:func:`~glidepath.core.montecarlo.sustainable_income` — the same
|
|
199
|
+
scan-plus-bisection over the search's bracket, the same success
|
|
200
|
+
reading per probe (no unmet need under a deterministic config;
|
|
201
|
+
"success rate ≥ target" over the seeded paths under a Monte Carlo
|
|
202
|
+
one), and the same reproducibility: every probe reuses ``config``
|
|
203
|
+
unchanged, so the answer is reproducible from the recorded inputs
|
|
204
|
+
(and seed) alone (§4.6). The plan's stated retirement age and
|
|
205
|
+
spending level are both irrelevant — the probes carry the chosen
|
|
206
|
+
age and the candidate spending instead, everything else unchanged.
|
|
207
|
+
|
|
208
|
+
An ``age`` with no *retirement exposure* — no projected period
|
|
209
|
+
opening the plan retired under the §4.1 gate convention, because
|
|
210
|
+
its retirement date falls at or past the run's horizon — has no
|
|
211
|
+
retired period to test any income in: spending is modelled only in
|
|
212
|
+
retirement, so every level would succeed vacuously. It answers
|
|
213
|
+
``None`` without probing, exactly as such candidates fail in the
|
|
214
|
+
age search. ``None`` otherwise means what the income search means
|
|
215
|
+
by it: not even zero spending survives the plan's outflows.
|
|
216
|
+
|
|
217
|
+
Raises:
|
|
218
|
+
ValueError: If ``age`` is negative.
|
|
219
|
+
EngineError: If a probe is rejected by the engine — including
|
|
220
|
+
a Monte Carlo config without a seed (planning §5.2).
|
|
221
|
+
"""
|
|
222
|
+
if age < 0:
|
|
223
|
+
msg = f"age must be non-negative, got {age}"
|
|
224
|
+
raise ValueError(msg)
|
|
225
|
+
date_of_birth = plan.persons[0].date_of_birth.value
|
|
226
|
+
exposed = any(
|
|
227
|
+
is_age_attained_by_period_start(date_of_birth, age, period)
|
|
228
|
+
for period in _projected_periods(plan, assumptions, region, config)
|
|
229
|
+
)
|
|
230
|
+
if not exposed:
|
|
231
|
+
return None
|
|
232
|
+
return sustainable_income(
|
|
233
|
+
_with_retirement_age(plan, age),
|
|
234
|
+
assumptions,
|
|
235
|
+
region,
|
|
236
|
+
config,
|
|
237
|
+
search,
|
|
238
|
+
parallelism=parallelism,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _projected_periods(
|
|
243
|
+
plan: Household, assumptions: AssumptionSet, region: Region, config: RunConfig
|
|
244
|
+
) -> tuple[Period, ...]:
|
|
245
|
+
"""The periods a probe under ``config`` would project (§5.2).
|
|
246
|
+
|
|
247
|
+
What the solvers' exposure gates scan: the run's own calendar over
|
|
248
|
+
its own horizon, computed exactly as the engine would.
|
|
249
|
+
"""
|
|
250
|
+
return tuple(
|
|
251
|
+
region.calendar.periods(config.today, _horizon_end(plan, assumptions, config))
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _horizon_end(
|
|
256
|
+
plan: Household, assumptions: AssumptionSet, config: RunConfig
|
|
257
|
+
) -> date:
|
|
258
|
+
"""The run's horizon end: configured, or the planning-age default.
|
|
259
|
+
|
|
260
|
+
The same resolution the engine applies (planning §5.2), computed
|
|
261
|
+
here so the exposure gate can see the periods a probe would
|
|
262
|
+
project. v1 households hold one person (§4.4), whose date of birth
|
|
263
|
+
anchors the default.
|
|
264
|
+
"""
|
|
265
|
+
if config.horizon_end is not None:
|
|
266
|
+
return config.horizon_end
|
|
267
|
+
planning_age = int_assumption_value(
|
|
268
|
+
assumptions.get(AssumptionKey.HORIZON_PLANNING_AGE)
|
|
269
|
+
)
|
|
270
|
+
return date_age_attained(plan.persons[0].date_of_birth.value, planning_age)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _with_retirement_age(plan: Household, age: int) -> Household:
|
|
274
|
+
"""The plan with every person's retirement-age decision at ``age``.
|
|
275
|
+
|
|
276
|
+
v1 households hold one person (§4.4), so this is *the* person's
|
|
277
|
+
decision; the decision's recorded-on metadata carries over, exactly
|
|
278
|
+
as a scenario override resolves (§4.3). Couples activation (9.4)
|
|
279
|
+
will need a per-person target here.
|
|
280
|
+
"""
|
|
281
|
+
persons = tuple(_person_at_retirement_age(person, age) for person in plan.persons)
|
|
282
|
+
changes: dict[str, Any] = {"persons": persons}
|
|
283
|
+
return replace(plan, **changes) if changes else plan
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _person_at_retirement_age(person: Person, age: int) -> Person:
|
|
287
|
+
"""One person with their retirement-age decision's value replaced."""
|
|
288
|
+
decision_changes: dict[str, Any] = {"value": age}
|
|
289
|
+
decision = replace(person.target_retirement_age, **decision_changes)
|
|
290
|
+
changes: dict[str, Any] = {"target_retirement_age": decision}
|
|
291
|
+
return replace(person, **changes) if changes else person
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"""Period returns and the return-model boundary (roadmap 4.1; planning §5.2).
|
|
2
|
+
|
|
3
|
+
The engine applies one set of nominal asset-class returns and one CPI
|
|
4
|
+
rate per period — "one inflation truth per run" (planning §5.2): the
|
|
5
|
+
reporting layer (roadmap 4.4) deflates by the same CPI path the engine
|
|
6
|
+
grew nominal figures with. A :class:`ReturnModel` supplies both
|
|
7
|
+
together as a :class:`PeriodReturns`, so they cannot drift apart.
|
|
8
|
+
|
|
9
|
+
The same step function runs under the deterministic and Monte Carlo
|
|
10
|
+
modes; only the return model differs (planning §5.2, a design
|
|
11
|
+
invariant). :class:`DeterministicReturnModel` turns the expected
|
|
12
|
+
real-return assumptions plus the CPI assumption into the same nominal
|
|
13
|
+
returns every period and every path. :class:`StochasticReturnModel`
|
|
14
|
+
(roadmap 7.2) draws correlated lognormal nominal returns instead —
|
|
15
|
+
seeded, pure per ``(seed, path, period)``, mean-matched to the
|
|
16
|
+
deterministic composition — while CPI stays the assumed deterministic
|
|
17
|
+
path on every Monte Carlo path, keeping the single-inflation-truth
|
|
18
|
+
rule intact (stochastic inflation is out of v1 scope; the assumption
|
|
19
|
+
catalogue prices no CPI volatility).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from decimal import Decimal
|
|
24
|
+
from typing import TYPE_CHECKING, Protocol
|
|
25
|
+
|
|
26
|
+
from glidepath.core.investments import AssetReturns
|
|
27
|
+
from glidepath.core.money import Rate
|
|
28
|
+
from glidepath.core.provenance import AssumptionKey, decimal_assumption_value
|
|
29
|
+
from glidepath.core.randomness import RandomSource, SeededRandomSource, derive_seed
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
from collections.abc import Callable
|
|
33
|
+
|
|
34
|
+
from glidepath.core.periods import Period
|
|
35
|
+
from glidepath.core.provenance import TrackedAssumptions
|
|
36
|
+
|
|
37
|
+
_MINUS_ONE = Decimal(-1)
|
|
38
|
+
_ZERO = Decimal(0)
|
|
39
|
+
_ONE = Decimal(1)
|
|
40
|
+
_TWO = Decimal(2)
|
|
41
|
+
_ASSET_CLASS_COUNT = 3
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True, slots=True)
|
|
45
|
+
class PeriodReturns:
|
|
46
|
+
"""One period's nominal asset returns and CPI rate, together.
|
|
47
|
+
|
|
48
|
+
Keeping the two in one value enforces the single-inflation-truth
|
|
49
|
+
rule (planning §5.2): the CPI that built the nominal returns is the
|
|
50
|
+
CPI the reporting layer deflates by.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
assets: AssetReturns
|
|
54
|
+
cpi: Rate
|
|
55
|
+
|
|
56
|
+
def __post_init__(self) -> None:
|
|
57
|
+
"""Reject a CPI at or below -100%.
|
|
58
|
+
|
|
59
|
+
Exactly -1 is rejected too: it would zero the cumulative
|
|
60
|
+
inflation factor and turn an accepted assumption into a
|
|
61
|
+
runtime failure one period later.
|
|
62
|
+
"""
|
|
63
|
+
if self.cpi.value <= _MINUS_ONE:
|
|
64
|
+
msg = "PeriodReturns.cpi must be greater than -1"
|
|
65
|
+
raise ValueError(msg)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ReturnModel(Protocol):
|
|
69
|
+
"""Supplies each period's returns (planning §5.2).
|
|
70
|
+
|
|
71
|
+
The engine step function is mode-agnostic: deterministic and Monte
|
|
72
|
+
Carlo runs differ only in which implementation they inject. Data
|
|
73
|
+
parameters are positional-only so implementations that need neither
|
|
74
|
+
(the deterministic model) remain protocol-compatible.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
def returns_for(self, period: Period, path: int, /) -> PeriodReturns:
|
|
78
|
+
"""The nominal returns and CPI for ``period`` on ``path``."""
|
|
79
|
+
...
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
type ReturnModelFactory = Callable[[TrackedAssumptions], ReturnModel]
|
|
83
|
+
"""Builds a run's return model from the run's tracked assumption view.
|
|
84
|
+
|
|
85
|
+
The engine constructs its tracked view internally (every key read must
|
|
86
|
+
land in the run's provenance), so an injected model — a scripted
|
|
87
|
+
sequence fixture (roadmap 7.4), an alternative distribution — enters
|
|
88
|
+
through this factory rather than as a finished instance. A factory must
|
|
89
|
+
preserve the engine's purity (planning §4.6): the model it returns may
|
|
90
|
+
depend on nothing but the view it is given and its own frozen state.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def nominal_rate(real: Decimal, cpi: Decimal) -> Rate:
|
|
95
|
+
"""Compose a real rate with CPI into a nominal rate (planning §5.2).
|
|
96
|
+
|
|
97
|
+
``(1 + real) * (1 + cpi) - 1`` — the exact Fisher composition, kept
|
|
98
|
+
unquantized like every rate (planning §4.6).
|
|
99
|
+
"""
|
|
100
|
+
return Rate((_ONE + real) * (_ONE + cpi) - _ONE)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass(frozen=True, slots=True)
|
|
104
|
+
class DeterministicReturnModel:
|
|
105
|
+
"""Expected-return model: the same nominal returns every period.
|
|
106
|
+
|
|
107
|
+
Reads the expected real returns per asset class and the CPI
|
|
108
|
+
assumption through the run's tracked view (so every key lands in
|
|
109
|
+
the run's provenance) and composes them into nominal rates. Every
|
|
110
|
+
period and every path sees the same value (planning §5.2).
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
assumptions: TrackedAssumptions
|
|
114
|
+
|
|
115
|
+
def returns_for(self, _period: Period, _path: int, /) -> PeriodReturns:
|
|
116
|
+
"""The nominal returns and CPI (identical for every argument)."""
|
|
117
|
+
cpi = decimal_assumption_value(
|
|
118
|
+
self.assumptions.get(AssumptionKey.INFLATION_CPI)
|
|
119
|
+
)
|
|
120
|
+
real_rates = (
|
|
121
|
+
decimal_assumption_value(self.assumptions.get(key))
|
|
122
|
+
for key in (
|
|
123
|
+
AssumptionKey.RETURNS_EQUITY_REAL,
|
|
124
|
+
AssumptionKey.RETURNS_BONDS_REAL,
|
|
125
|
+
AssumptionKey.RETURNS_CASH_REAL,
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
equity, bonds, cash = (nominal_rate(real, cpi) for real in real_rates)
|
|
129
|
+
return PeriodReturns(
|
|
130
|
+
assets=AssetReturns(equity=equity, bonds=bonds, cash=cash),
|
|
131
|
+
cpi=Rate(cpi),
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def cholesky_lower(
|
|
136
|
+
matrix: tuple[tuple[Decimal, ...], ...],
|
|
137
|
+
) -> tuple[tuple[Decimal, ...], ...]:
|
|
138
|
+
"""Lower-triangular Cholesky factor of a symmetric matrix, in ``Decimal``.
|
|
139
|
+
|
|
140
|
+
``L`` such that ``L @ L.T`` reproduces ``matrix`` (to context
|
|
141
|
+
precision), computed entirely in ``Decimal`` — the correlation
|
|
142
|
+
transform of planning §5.2 never touches float.
|
|
143
|
+
|
|
144
|
+
Raises:
|
|
145
|
+
ValueError: If the matrix is not square, not symmetric, or not
|
|
146
|
+
positive definite (a pivot fails to stay positive).
|
|
147
|
+
"""
|
|
148
|
+
size = len(matrix)
|
|
149
|
+
if any(len(row) != size for row in matrix):
|
|
150
|
+
msg = "matrix must be square"
|
|
151
|
+
raise ValueError(msg)
|
|
152
|
+
if any(matrix[i][j] != matrix[j][i] for i in range(size) for j in range(i)):
|
|
153
|
+
msg = "matrix must be symmetric"
|
|
154
|
+
raise ValueError(msg)
|
|
155
|
+
rows: list[list[Decimal]] = [[_ZERO] * size for _ in range(size)]
|
|
156
|
+
for i in range(size):
|
|
157
|
+
for j in range(i + 1):
|
|
158
|
+
partial = sum((rows[i][k] * rows[j][k] for k in range(j)), start=_ZERO)
|
|
159
|
+
if i == j:
|
|
160
|
+
pivot = matrix[i][i] - partial
|
|
161
|
+
if pivot <= _ZERO:
|
|
162
|
+
msg = "matrix is not positive definite"
|
|
163
|
+
raise ValueError(msg)
|
|
164
|
+
rows[i][j] = pivot.sqrt()
|
|
165
|
+
else:
|
|
166
|
+
rows[i][j] = (matrix[i][j] - partial) / rows[j][j]
|
|
167
|
+
return tuple(tuple(row) for row in rows)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@dataclass(frozen=True, slots=True)
|
|
171
|
+
class StochasticReturnModel:
|
|
172
|
+
"""Correlated lognormal return model for Monte Carlo (roadmap 7.2).
|
|
173
|
+
|
|
174
|
+
Per asset class the period's nominal gross return is lognormal with
|
|
175
|
+
its arithmetic mean equal to the deterministic composition
|
|
176
|
+
``(1 + real)(1 + cpi)`` — so the expected outcome of a Monte Carlo
|
|
177
|
+
run agrees with the deterministic run by construction. The assumed
|
|
178
|
+
volatility is read as the standard deviation of the annual
|
|
179
|
+
log-return; draws are correlated across the three classes (in the
|
|
180
|
+
fixed order equity, bonds, cash) through the ``Decimal`` Cholesky
|
|
181
|
+
factor of the pairwise correlation assumptions. CPI stays the
|
|
182
|
+
assumed deterministic value on every path (module docstring).
|
|
183
|
+
|
|
184
|
+
Purity (planning §4.6): each ``(period, path)`` pair reads its
|
|
185
|
+
draws from a private substream seeded by
|
|
186
|
+
``derive_seed(seed, path, period start)``, so results depend only
|
|
187
|
+
on the arguments — call order is irrelevant, paths are
|
|
188
|
+
order-independent, and any single period of any path is
|
|
189
|
+
individually re-runnable.
|
|
190
|
+
|
|
191
|
+
``source_factory`` is the injectable :class:`RandomSource`
|
|
192
|
+
boundary of planning §4.6: it builds the substream for a derived
|
|
193
|
+
seed, defaulting to :class:`SeededRandomSource`. Injecting a
|
|
194
|
+
factory (a test double, an alternative generator) must preserve
|
|
195
|
+
purity: the source it returns may depend on nothing but the seed
|
|
196
|
+
it is given.
|
|
197
|
+
"""
|
|
198
|
+
|
|
199
|
+
assumptions: TrackedAssumptions
|
|
200
|
+
seed: int
|
|
201
|
+
source_factory: Callable[[int], RandomSource] = SeededRandomSource
|
|
202
|
+
|
|
203
|
+
def returns_for(self, period: Period, path: int, /) -> PeriodReturns:
|
|
204
|
+
"""Draw the period's correlated nominal returns on ``path``.
|
|
205
|
+
|
|
206
|
+
Raises:
|
|
207
|
+
ValueError: If an assumed volatility is negative, a
|
|
208
|
+
correlation falls outside [-1, 1] or the correlation
|
|
209
|
+
matrix is not positive definite, or an expected
|
|
210
|
+
nominal gross return is not positive (its logarithm
|
|
211
|
+
would be undefined).
|
|
212
|
+
"""
|
|
213
|
+
cpi = decimal_assumption_value(
|
|
214
|
+
self.assumptions.get(AssumptionKey.INFLATION_CPI)
|
|
215
|
+
)
|
|
216
|
+
grosses = tuple(
|
|
217
|
+
self._expected_gross(key, cpi)
|
|
218
|
+
for key in (
|
|
219
|
+
AssumptionKey.RETURNS_EQUITY_REAL,
|
|
220
|
+
AssumptionKey.RETURNS_BONDS_REAL,
|
|
221
|
+
AssumptionKey.RETURNS_CASH_REAL,
|
|
222
|
+
)
|
|
223
|
+
)
|
|
224
|
+
sigmas = tuple(
|
|
225
|
+
self._volatility(key)
|
|
226
|
+
for key in (
|
|
227
|
+
AssumptionKey.VOLATILITY_EQUITY,
|
|
228
|
+
AssumptionKey.VOLATILITY_BONDS,
|
|
229
|
+
AssumptionKey.VOLATILITY_CASH,
|
|
230
|
+
)
|
|
231
|
+
)
|
|
232
|
+
factor = cholesky_lower(self._correlation_matrix())
|
|
233
|
+
source = self.source_factory(
|
|
234
|
+
derive_seed(self.seed, path, period.start.isoformat())
|
|
235
|
+
)
|
|
236
|
+
draws = source.standard_normals(_ASSET_CLASS_COUNT)
|
|
237
|
+
correlated = (
|
|
238
|
+
sum((row[k] * draws[k] for k in range(_ASSET_CLASS_COUNT)), start=_ZERO)
|
|
239
|
+
for row in factor
|
|
240
|
+
)
|
|
241
|
+
equity, bonds, cash = (
|
|
242
|
+
_lognormal_rate(gross, sigma, normal)
|
|
243
|
+
for gross, sigma, normal in zip(grosses, sigmas, correlated, strict=True)
|
|
244
|
+
)
|
|
245
|
+
return PeriodReturns(
|
|
246
|
+
assets=AssetReturns(equity=equity, bonds=bonds, cash=cash),
|
|
247
|
+
cpi=Rate(cpi),
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
def _expected_gross(self, key: AssumptionKey, cpi: Decimal) -> Decimal:
|
|
251
|
+
"""The expected nominal gross return ``(1 + real)(1 + cpi)``.
|
|
252
|
+
|
|
253
|
+
Raises:
|
|
254
|
+
ValueError: If the gross is not positive — a lognormal
|
|
255
|
+
cannot have a non-positive mean.
|
|
256
|
+
"""
|
|
257
|
+
real = decimal_assumption_value(self.assumptions.get(key))
|
|
258
|
+
gross = (_ONE + real) * (_ONE + cpi)
|
|
259
|
+
if gross <= _ZERO:
|
|
260
|
+
msg = f"expected nominal gross return for {key!r} must be positive"
|
|
261
|
+
raise ValueError(msg)
|
|
262
|
+
return gross
|
|
263
|
+
|
|
264
|
+
def _volatility(self, key: AssumptionKey) -> Decimal:
|
|
265
|
+
"""The assumed annual log-return volatility, validated non-negative.
|
|
266
|
+
|
|
267
|
+
Raises:
|
|
268
|
+
ValueError: If the volatility is negative.
|
|
269
|
+
"""
|
|
270
|
+
sigma = decimal_assumption_value(self.assumptions.get(key))
|
|
271
|
+
if sigma < _ZERO:
|
|
272
|
+
msg = f"volatility {key!r} must be non-negative"
|
|
273
|
+
raise ValueError(msg)
|
|
274
|
+
return sigma
|
|
275
|
+
|
|
276
|
+
def _correlation_matrix(self) -> tuple[tuple[Decimal, ...], ...]:
|
|
277
|
+
"""The 3-by-3 correlation matrix in the equity, bonds, cash order.
|
|
278
|
+
|
|
279
|
+
Raises:
|
|
280
|
+
ValueError: If a pairwise correlation lies outside [-1, 1].
|
|
281
|
+
"""
|
|
282
|
+
pairs: dict[AssumptionKey, Decimal] = {}
|
|
283
|
+
for key in (
|
|
284
|
+
AssumptionKey.CORRELATION_EQUITY_BONDS,
|
|
285
|
+
AssumptionKey.CORRELATION_EQUITY_CASH,
|
|
286
|
+
AssumptionKey.CORRELATION_BONDS_CASH,
|
|
287
|
+
):
|
|
288
|
+
value = decimal_assumption_value(self.assumptions.get(key))
|
|
289
|
+
if not _MINUS_ONE <= value <= _ONE:
|
|
290
|
+
msg = f"correlation {key!r} must lie between -1 and 1"
|
|
291
|
+
raise ValueError(msg)
|
|
292
|
+
pairs[key] = value
|
|
293
|
+
equity_bonds = pairs[AssumptionKey.CORRELATION_EQUITY_BONDS]
|
|
294
|
+
equity_cash = pairs[AssumptionKey.CORRELATION_EQUITY_CASH]
|
|
295
|
+
bonds_cash = pairs[AssumptionKey.CORRELATION_BONDS_CASH]
|
|
296
|
+
return (
|
|
297
|
+
(_ONE, equity_bonds, equity_cash),
|
|
298
|
+
(equity_bonds, _ONE, bonds_cash),
|
|
299
|
+
(equity_cash, bonds_cash, _ONE),
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _lognormal_rate(gross: Decimal, sigma: Decimal, normal: Decimal) -> Rate:
|
|
304
|
+
"""One lognormal nominal return with arithmetic mean ``gross - 1``.
|
|
305
|
+
|
|
306
|
+
``mu = ln(gross) - sigma²/2`` makes ``E[exp(mu + sigma·Z)]`` exactly
|
|
307
|
+
``gross``, so with zero volatility the draw degenerates to the
|
|
308
|
+
deterministic nominal rate. Exponentials keep the gross strictly
|
|
309
|
+
positive: a lognormal loss can approach but never reach -100%.
|
|
310
|
+
"""
|
|
311
|
+
mu = gross.ln() - sigma * sigma / _TWO
|
|
312
|
+
return Rate((mu + sigma * normal).exp() - _ONE)
|