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,399 @@
|
|
|
1
|
+
"""Monte Carlo in the shell: run mode, transition, panel (roadmap 9.13).
|
|
2
|
+
|
|
3
|
+
The Phase 7 core (:func:`~glidepath.core.run_paths`) surfaced per
|
|
4
|
+
planning §4.7: a run-mode control (deterministic | Monte Carlo with
|
|
5
|
+
paths + seed), the success-metrics readout, and the fan-chart specs
|
|
6
|
+
the Monte Carlo chart tab draws (the fan itself is composed in
|
|
7
|
+
:mod:`glidepath.app.charts`, roadmap 9.24). The Monte Carlo run is an
|
|
8
|
+
explicit user action — even at the recorded 9.15 per-path cost
|
|
9
|
+
(planning §4.6) it is
|
|
10
|
+
far too slow to re-run on every keystroke — and every plan-changing
|
|
11
|
+
transition routes through :func:`~glidepath.app.plan.replanned_state`, which
|
|
12
|
+
drops the held result, so a stale Monte Carlo surface can never be
|
|
13
|
+
shown against a changed plan. Same seed + inputs reproduce identical
|
|
14
|
+
results (§4.6): the transition passes the parsed seed straight to the
|
|
15
|
+
seeded path runner.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
from concurrent.futures import ProcessPoolExecutor
|
|
20
|
+
from contextlib import contextmanager
|
|
21
|
+
from dataclasses import dataclass, replace
|
|
22
|
+
from decimal import Decimal
|
|
23
|
+
from typing import TYPE_CHECKING, Any, Final
|
|
24
|
+
|
|
25
|
+
from glidepath.app.display import format_money, format_percent
|
|
26
|
+
from glidepath.app.plan import PlanState, region_for, replanned_state
|
|
27
|
+
from glidepath.core import Money, PathParallelism, RunConfig, RunMode, run_paths
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from collections.abc import Iterator, Mapping
|
|
31
|
+
from datetime import date
|
|
32
|
+
|
|
33
|
+
from glidepath.core import MonteCarloResult
|
|
34
|
+
|
|
35
|
+
DEFAULT_RUN_MODE: Final = RunMode.DETERMINISTIC
|
|
36
|
+
|
|
37
|
+
RUN_MODE_HEADING: Final = "Run mode"
|
|
38
|
+
|
|
39
|
+
SEED_LABEL: Final = "Seed"
|
|
40
|
+
|
|
41
|
+
PATHS_LABEL: Final = "Paths"
|
|
42
|
+
|
|
43
|
+
RUN_MONTE_CARLO_LABEL: Final = "Run Monte Carlo"
|
|
44
|
+
|
|
45
|
+
DEFAULT_SEED_VALUE: Final = "1"
|
|
46
|
+
|
|
47
|
+
DEFAULT_PATHS_VALUE: Final = "100"
|
|
48
|
+
|
|
49
|
+
MAX_PATHS: Final = 10_000
|
|
50
|
+
|
|
51
|
+
PARALLEL_PATHS_MIN: Final = 100
|
|
52
|
+
"""Fewest path-projections a run must total before a pool pays off.
|
|
53
|
+
|
|
54
|
+
Spawning worker processes costs around a second (each re-imports the
|
|
55
|
+
package); below this many paths the serial run finishes comparably
|
|
56
|
+
fast, so small runs — and the app-layer test suite — stay in-process.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
MAX_POOL_WORKERS: Final = 61
|
|
60
|
+
"""Most workers a pool may hold — Windows' wait-handle ceiling.
|
|
61
|
+
|
|
62
|
+
``ProcessPoolExecutor`` rejects more than 61 workers on Windows (the
|
|
63
|
+
64-object ``WaitForMultipleObjects`` limit minus bookkeeping handles);
|
|
64
|
+
without the cap, every qualifying run on a 63+-logical-CPU Windows
|
|
65
|
+
machine would fail at pool construction. Capped on every platform so
|
|
66
|
+
the policy stays uniform.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
NO_MONTE_CARLO_MESSAGE: Final = (
|
|
70
|
+
"No Monte Carlo run yet — choose the paths and seed, then press Run Monte Carlo."
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
MONTE_CARLO_NO_PLAN_MESSAGE: Final = (
|
|
74
|
+
"Save facts on the Facts tab before running Monte Carlo."
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
MONTE_CARLO_SEED_MESSAGE: Final = "Monte Carlo needs a whole-number seed."
|
|
78
|
+
|
|
79
|
+
MONTE_CARLO_PATHS_MESSAGE: Final = (
|
|
80
|
+
"Monte Carlo needs a whole-number path count between 1 and 10,000."
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
MONTE_CARLO_FAILED_PREFIX: Final = "The Monte Carlo run failed: "
|
|
84
|
+
|
|
85
|
+
MONTE_CARLO_RUNNING_MESSAGE: Final = "Running Monte Carlo…"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def monte_carlo_running_status(paths_text: str) -> str:
|
|
89
|
+
"""The busy status line for a Monte Carlo run just started (9.16).
|
|
90
|
+
|
|
91
|
+
Names the path count when the raw text the shell captured parses
|
|
92
|
+
to a usable one — "Running Monte Carlo — 1,000 paths…" — and falls
|
|
93
|
+
back to the plain running message otherwise; the transition itself
|
|
94
|
+
reports unusable input when it delivers (planning §4.7: the shell
|
|
95
|
+
never parses).
|
|
96
|
+
"""
|
|
97
|
+
try:
|
|
98
|
+
paths = int(paths_text.strip(), 10)
|
|
99
|
+
except ValueError:
|
|
100
|
+
return MONTE_CARLO_RUNNING_MESSAGE
|
|
101
|
+
if not 1 <= paths <= MAX_PATHS:
|
|
102
|
+
return MONTE_CARLO_RUNNING_MESSAGE
|
|
103
|
+
return f"Running Monte Carlo — {paths:,} paths…"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
MONTE_CARLO_STALE_MESSAGE: Final = (
|
|
107
|
+
"Monte Carlo result discarded — the plan changed while it ran."
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
SUCCESS_RATE_LABEL: Final = "Success rate"
|
|
111
|
+
|
|
112
|
+
RUIN_LABEL: Final = "Probability of ruin"
|
|
113
|
+
|
|
114
|
+
_MODE_BY_KEY: Final[Mapping[str, RunMode]] = {
|
|
115
|
+
"deterministic": RunMode.DETERMINISTIC,
|
|
116
|
+
"monte_carlo": RunMode.MONTE_CARLO,
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
_KEY_BY_MODE: Final[Mapping[RunMode, str]] = {
|
|
120
|
+
mode: key for key, mode in _MODE_BY_KEY.items()
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
_MODE_LABELS: Final[Mapping[str, str]] = {
|
|
124
|
+
"deterministic": "Deterministic",
|
|
125
|
+
"monte_carlo": "Monte Carlo",
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
_UNKNOWN_MODE_MESSAGE: Final = "unknown run mode key"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass(frozen=True)
|
|
132
|
+
class BandSpec:
|
|
133
|
+
"""One ending-pot percentile the Monte Carlo readout reports (§5.2)."""
|
|
134
|
+
|
|
135
|
+
ending_pot_label: str
|
|
136
|
+
percentile: Decimal
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
BAND_SPECS: Final[tuple[BandSpec, ...]] = (
|
|
140
|
+
BandSpec(
|
|
141
|
+
ending_pot_label="Ending pot, 10th percentile",
|
|
142
|
+
percentile=Decimal(10),
|
|
143
|
+
),
|
|
144
|
+
BandSpec(
|
|
145
|
+
ending_pot_label="Ending pot, median",
|
|
146
|
+
percentile=Decimal(50),
|
|
147
|
+
),
|
|
148
|
+
BandSpec(
|
|
149
|
+
ending_pot_label="Ending pot, 90th percentile",
|
|
150
|
+
percentile=Decimal(90),
|
|
151
|
+
),
|
|
152
|
+
)
|
|
153
|
+
"""The 10/50/90 ending-pot metrics of roadmap 9.13."""
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@dataclass(frozen=True)
|
|
157
|
+
class FanSpec:
|
|
158
|
+
"""One inter-percentile fill of the Monte Carlo fan chart (9.24).
|
|
159
|
+
|
|
160
|
+
``label`` is the legend and tooltip copy; ``lower``/``upper`` are
|
|
161
|
+
the percentiles bounding the fill, so the fill is a genuine
|
|
162
|
+
interval statement — the central share of paths that closed
|
|
163
|
+
between them every period.
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
label: str
|
|
167
|
+
lower: Decimal
|
|
168
|
+
upper: Decimal
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
FAN_SPECS: Final[tuple[FanSpec, ...]] = (
|
|
172
|
+
FanSpec(label="5th-95th percentile", lower=Decimal(5), upper=Decimal(95)),
|
|
173
|
+
FanSpec(label="15th-85th percentile", lower=Decimal(15), upper=Decimal(85)),
|
|
174
|
+
FanSpec(label="25th-75th percentile", lower=Decimal(25), upper=Decimal(75)),
|
|
175
|
+
FanSpec(label="35th-65th percentile", lower=Decimal(35), upper=Decimal(65)),
|
|
176
|
+
)
|
|
177
|
+
"""The fan's nested fills, outermost first — shells step the fill
|
|
178
|
+
colour deeper inward, so central probability mass reads as depth."""
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
FAN_MEDIAN_LABEL: Final = "Median"
|
|
182
|
+
"""The fan chart's single overlay line — the per-period median."""
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@dataclass(frozen=True)
|
|
186
|
+
class RunModeOption:
|
|
187
|
+
"""One run-mode choice for the charts screen (roadmap 9.13)."""
|
|
188
|
+
|
|
189
|
+
key: str
|
|
190
|
+
label: str
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@dataclass(frozen=True)
|
|
194
|
+
class MonteCarloMetric:
|
|
195
|
+
"""One success-metric readout row: a label and its formatted value."""
|
|
196
|
+
|
|
197
|
+
label: str
|
|
198
|
+
value: str
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@dataclass(frozen=True)
|
|
202
|
+
class MonteCarloPanelViewModel:
|
|
203
|
+
"""The run-mode control and Monte Carlo readout (roadmap 9.13).
|
|
204
|
+
|
|
205
|
+
``seed_value`` and ``paths_value`` echo the held result's actual
|
|
206
|
+
inputs (the §4.6 manifest side) or the defaults before any run.
|
|
207
|
+
``controls_visible`` hides the seed, paths, run action, metrics,
|
|
208
|
+
and message under the deterministic mode. ``message`` carries the
|
|
209
|
+
no-run or failure copy; it is blank whenever ``metrics`` is
|
|
210
|
+
populated.
|
|
211
|
+
"""
|
|
212
|
+
|
|
213
|
+
heading: str
|
|
214
|
+
mode_options: tuple[RunModeOption, ...]
|
|
215
|
+
selected_mode_key: str
|
|
216
|
+
seed_label: str
|
|
217
|
+
seed_value: str
|
|
218
|
+
paths_label: str
|
|
219
|
+
paths_value: str
|
|
220
|
+
run_label: str
|
|
221
|
+
controls_visible: bool
|
|
222
|
+
metrics: tuple[MonteCarloMetric, ...]
|
|
223
|
+
message: str
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@contextmanager
|
|
227
|
+
def path_pool(total_paths: int) -> Iterator[PathParallelism | None]:
|
|
228
|
+
"""A worker-process pool for ``total_paths`` path-projections, or ``None``.
|
|
229
|
+
|
|
230
|
+
The app layer's parallelism policy (planning §5.2): paths are pure
|
|
231
|
+
CPU-bound ``Decimal`` work, so a run big enough to amortize process
|
|
232
|
+
startup (``PARALLEL_PATHS_MIN``) gets a process pool sized to the
|
|
233
|
+
machine — every available core but one, so the GUI thread keeps a
|
|
234
|
+
core, capped at ``MAX_POOL_WORKERS`` — and anything smaller runs
|
|
235
|
+
serially (``None``). One pool
|
|
236
|
+
serves a whole transition: a retirement-age search passes it to
|
|
237
|
+
every candidate's paths rather than re-spawning per age.
|
|
238
|
+
"""
|
|
239
|
+
spare_cores = max(1, (os.process_cpu_count() or 1) - 1)
|
|
240
|
+
workers = min(spare_cores, total_paths, MAX_POOL_WORKERS)
|
|
241
|
+
if workers <= 1 or total_paths < PARALLEL_PATHS_MIN:
|
|
242
|
+
yield None
|
|
243
|
+
return
|
|
244
|
+
with ProcessPoolExecutor(max_workers=workers) as executor:
|
|
245
|
+
yield PathParallelism(executor=executor, workers=workers)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def run_mode_from_key(key: str) -> RunMode:
|
|
249
|
+
"""The run mode a shell-selected option key denotes.
|
|
250
|
+
|
|
251
|
+
Raises:
|
|
252
|
+
ValueError: If ``key`` is not a known run-mode option key.
|
|
253
|
+
"""
|
|
254
|
+
mode = _MODE_BY_KEY.get(key)
|
|
255
|
+
if mode is None:
|
|
256
|
+
raise ValueError(_UNKNOWN_MODE_MESSAGE)
|
|
257
|
+
return mode
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def run_mode_key(mode: RunMode) -> str:
|
|
261
|
+
"""The option key denoting ``mode`` — the inverse of ``run_mode_from_key``."""
|
|
262
|
+
return _KEY_BY_MODE[mode]
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def run_mode_options() -> tuple[RunModeOption, ...]:
|
|
266
|
+
"""The run-mode choices the charts screen offers (roadmap 9.13)."""
|
|
267
|
+
return tuple(
|
|
268
|
+
RunModeOption(key=key, label=_MODE_LABELS[key]) for key in _MODE_BY_KEY
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def state_with_monte_carlo(
|
|
273
|
+
state: PlanState, seed_text: str, paths_text: str, *, today: date
|
|
274
|
+
) -> PlanState:
|
|
275
|
+
"""The state after running the plan over seeded Monte Carlo paths.
|
|
276
|
+
|
|
277
|
+
``seed_text`` and ``paths_text`` arrive as the shell captured
|
|
278
|
+
them; anything unusable — no plan, an unparseable seed or path
|
|
279
|
+
count, an engine rejection — folds into ``monte_carlo_error`` on
|
|
280
|
+
the returned state, never raising at a shell (the
|
|
281
|
+
:mod:`glidepath.app.plan` rule). Before the paths run, the base
|
|
282
|
+
projection (and the scenario runs with it) is recomputed at the
|
|
283
|
+
same ``today`` through :func:`~glidepath.app.plan.replanned_state`
|
|
284
|
+
— the :func:`~glidepath.app.plan.state_with_scenarios` rule: in a
|
|
285
|
+
session left open across a date boundary, the bands and the
|
|
286
|
+
deterministic chart they overlay must share one anchor date. Same
|
|
287
|
+
seed + inputs reproduce identical results (§4.6).
|
|
288
|
+
"""
|
|
289
|
+
if state.household is None:
|
|
290
|
+
return _with_monte_carlo_error(state, MONTE_CARLO_NO_PLAN_MESSAGE)
|
|
291
|
+
try:
|
|
292
|
+
seed = int(seed_text.strip(), 10)
|
|
293
|
+
except ValueError:
|
|
294
|
+
return _with_monte_carlo_error(state, MONTE_CARLO_SEED_MESSAGE)
|
|
295
|
+
try:
|
|
296
|
+
paths = int(paths_text.strip(), 10)
|
|
297
|
+
except ValueError:
|
|
298
|
+
return _with_monte_carlo_error(state, MONTE_CARLO_PATHS_MESSAGE)
|
|
299
|
+
if not 1 <= paths <= MAX_PATHS:
|
|
300
|
+
return _with_monte_carlo_error(state, MONTE_CARLO_PATHS_MESSAGE)
|
|
301
|
+
base = replanned_state(
|
|
302
|
+
state.assumptions,
|
|
303
|
+
state.household,
|
|
304
|
+
state.scenarios,
|
|
305
|
+
today=today,
|
|
306
|
+
modified=state.modified,
|
|
307
|
+
)
|
|
308
|
+
config = RunConfig(today=today, mode=RunMode.MONTE_CARLO, seed=seed)
|
|
309
|
+
try:
|
|
310
|
+
with path_pool(paths) as parallelism:
|
|
311
|
+
result = run_paths(
|
|
312
|
+
state.household,
|
|
313
|
+
state.assumptions,
|
|
314
|
+
region_for(state.assumptions),
|
|
315
|
+
config,
|
|
316
|
+
paths=paths,
|
|
317
|
+
parallelism=parallelism,
|
|
318
|
+
)
|
|
319
|
+
except Exception as exc:
|
|
320
|
+
# Broad by design: beyond the engine's ValueErrors, the process
|
|
321
|
+
# pool can raise OSError at spawn, pickling TypeErrors, or a
|
|
322
|
+
# BrokenExecutor — an escape here would leave the shell's
|
|
323
|
+
# in-flight guard held (buttons disabled, spinner running)
|
|
324
|
+
# forever, so every failure folds into the state (§4.7).
|
|
325
|
+
return _with_monte_carlo_error(base, MONTE_CARLO_FAILED_PREFIX + str(exc))
|
|
326
|
+
changes: dict[str, Any] = {"monte_carlo": result, "monte_carlo_error": None}
|
|
327
|
+
return replace(base, **changes) if changes else base
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def build_monte_carlo_panel(
|
|
331
|
+
state: PlanState,
|
|
332
|
+
mode: RunMode,
|
|
333
|
+
*,
|
|
334
|
+
ending_pot_deflator: Decimal | None,
|
|
335
|
+
basis_suffix: str,
|
|
336
|
+
) -> MonteCarloPanelViewModel:
|
|
337
|
+
"""The run-mode control and readout for the charts screen (9.13).
|
|
338
|
+
|
|
339
|
+
``ending_pot_deflator`` is the final period's balance deflator in
|
|
340
|
+
the screen's basis (1 under nominal) — CPI is deterministic across
|
|
341
|
+
paths (planning §5.2), so the deterministic report's deflator
|
|
342
|
+
presents the Monte Carlo pots too; ``None`` (no projection to read
|
|
343
|
+
it from) leaves the metrics unbuilt. ``basis_suffix`` names the
|
|
344
|
+
basis on the ending-pot labels.
|
|
345
|
+
"""
|
|
346
|
+
result = state.monte_carlo
|
|
347
|
+
metrics: tuple[MonteCarloMetric, ...] = ()
|
|
348
|
+
message = ""
|
|
349
|
+
controls_visible = mode is RunMode.MONTE_CARLO
|
|
350
|
+
if controls_visible:
|
|
351
|
+
if state.monte_carlo_error is not None:
|
|
352
|
+
message = state.monte_carlo_error
|
|
353
|
+
elif result is None or ending_pot_deflator is None:
|
|
354
|
+
message = NO_MONTE_CARLO_MESSAGE
|
|
355
|
+
else:
|
|
356
|
+
metrics = _metrics(result, ending_pot_deflator, basis_suffix)
|
|
357
|
+
return MonteCarloPanelViewModel(
|
|
358
|
+
heading=RUN_MODE_HEADING,
|
|
359
|
+
mode_options=run_mode_options(),
|
|
360
|
+
selected_mode_key=run_mode_key(mode),
|
|
361
|
+
seed_label=SEED_LABEL,
|
|
362
|
+
seed_value=DEFAULT_SEED_VALUE if result is None else str(result.config.seed),
|
|
363
|
+
paths_label=PATHS_LABEL,
|
|
364
|
+
paths_value=DEFAULT_PATHS_VALUE if result is None else str(result.path_count),
|
|
365
|
+
run_label=RUN_MONTE_CARLO_LABEL,
|
|
366
|
+
controls_visible=controls_visible,
|
|
367
|
+
metrics=metrics,
|
|
368
|
+
message=message,
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _metrics(
|
|
373
|
+
result: MonteCarloResult, ending_pot_deflator: Decimal, basis_suffix: str
|
|
374
|
+
) -> tuple[MonteCarloMetric, ...]:
|
|
375
|
+
"""The success-metrics readout rows (planning §5.2)."""
|
|
376
|
+
rows = [
|
|
377
|
+
MonteCarloMetric(
|
|
378
|
+
label=SUCCESS_RATE_LABEL, value=format_percent(result.success_rate)
|
|
379
|
+
),
|
|
380
|
+
MonteCarloMetric(
|
|
381
|
+
label=RUIN_LABEL, value=format_percent(result.probability_of_ruin)
|
|
382
|
+
),
|
|
383
|
+
]
|
|
384
|
+
for spec in BAND_SPECS:
|
|
385
|
+
pot = result.ending_pot_percentile(spec.percentile)
|
|
386
|
+
presented = Money(pot.amount / ending_pot_deflator).quantized()
|
|
387
|
+
rows.append(
|
|
388
|
+
MonteCarloMetric(
|
|
389
|
+
label=f"{spec.ending_pot_label} ({basis_suffix})",
|
|
390
|
+
value=format_money(presented),
|
|
391
|
+
)
|
|
392
|
+
)
|
|
393
|
+
return tuple(rows)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _with_monte_carlo_error(state: PlanState, message: str) -> PlanState:
|
|
397
|
+
"""The state carrying a Monte Carlo failure, any held result dropped."""
|
|
398
|
+
changes: dict[str, Any] = {"monte_carlo": None, "monte_carlo_error": message}
|
|
399
|
+
return replace(state, **changes) if changes else state
|