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,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)