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,831 @@
|
|
|
1
|
+
"""Scenario manager and comparison view models (roadmap 8.5; planning §4.3).
|
|
2
|
+
|
|
3
|
+
A scenario stored as deltas *is* its own diff (§4.3), so the manager
|
|
4
|
+
renders each scenario as its override list — orphaned overrides
|
|
5
|
+
flagged in place, never silently dropped — and presents the comparison
|
|
6
|
+
report (per-period metric values and diffs across scenarios, roadmap
|
|
7
|
+
6.3) as a table and a grouped chart, in either money basis.
|
|
8
|
+
|
|
9
|
+
The scenario-editing state transitions live here too: shells forward
|
|
10
|
+
raw text and stable option keys, targets are offered from exactly the
|
|
11
|
+
§4.3 whitelist (decision variables and assumptions — facts are never
|
|
12
|
+
overridable), and every rejection comes back as a message on an
|
|
13
|
+
:class:`~glidepath.app.plan.OverrideOutcome`, never an exception at a
|
|
14
|
+
shell (§4.7).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from collections import Counter
|
|
18
|
+
from collections.abc import Mapping
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from decimal import Decimal, InvalidOperation
|
|
21
|
+
from enum import Enum
|
|
22
|
+
from typing import TYPE_CHECKING, Final
|
|
23
|
+
|
|
24
|
+
from glidepath.app.charts import (
|
|
25
|
+
BASIS_HEADING,
|
|
26
|
+
DEFAULT_CHART_BASIS,
|
|
27
|
+
ChartBasisOption,
|
|
28
|
+
ChartSeries,
|
|
29
|
+
ChartSpec,
|
|
30
|
+
basis_key,
|
|
31
|
+
basis_options,
|
|
32
|
+
basis_suffix,
|
|
33
|
+
)
|
|
34
|
+
from glidepath.app.display import format_money, format_value
|
|
35
|
+
from glidepath.app.labels import capitalised, display_label, entity_names, pretty_path
|
|
36
|
+
from glidepath.app.plan import (
|
|
37
|
+
OverrideOutcome,
|
|
38
|
+
PlanState,
|
|
39
|
+
_parsed_override_value,
|
|
40
|
+
state_with_scenarios,
|
|
41
|
+
)
|
|
42
|
+
from glidepath.app.tables import table_edit_text
|
|
43
|
+
from glidepath.core import (
|
|
44
|
+
BASE_RUN_NAME,
|
|
45
|
+
AssumptionKey,
|
|
46
|
+
AssumptionTarget,
|
|
47
|
+
EntityId,
|
|
48
|
+
Money,
|
|
49
|
+
Override,
|
|
50
|
+
Scenario,
|
|
51
|
+
compare_scenario_results,
|
|
52
|
+
decision_target_catalogue,
|
|
53
|
+
resolve_scenario,
|
|
54
|
+
scenario_orphans,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
if TYPE_CHECKING:
|
|
58
|
+
from datetime import date
|
|
59
|
+
|
|
60
|
+
from glidepath.core import (
|
|
61
|
+
AssumptionSet,
|
|
62
|
+
DecisionTargetInfo,
|
|
63
|
+
Household,
|
|
64
|
+
OverrideTarget,
|
|
65
|
+
ReportBasis,
|
|
66
|
+
ScenarioPeriodEntry,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
TITLE: Final = "Scenarios"
|
|
70
|
+
|
|
71
|
+
SCENARIOS_HEADING: Final = "Your scenarios"
|
|
72
|
+
|
|
73
|
+
ADD_SCENARIO_LABEL: Final = "Add scenario…"
|
|
74
|
+
|
|
75
|
+
REMOVE_SCENARIO_LABEL: Final = "Remove scenario"
|
|
76
|
+
|
|
77
|
+
NAME_PROMPT_TITLE: Final = "Add scenario"
|
|
78
|
+
|
|
79
|
+
NAME_PROMPT: Final = "Scenario name:"
|
|
80
|
+
|
|
81
|
+
OVERRIDES_HEADING: Final = "Overrides — what this scenario changes"
|
|
82
|
+
|
|
83
|
+
OVERRIDES_COLUMNS: Final = ("Target", "Value", "Note", "Status")
|
|
84
|
+
|
|
85
|
+
ADD_OVERRIDE_LABEL: Final = "Add override…"
|
|
86
|
+
|
|
87
|
+
REMOVE_OVERRIDE_LABEL: Final = "Remove override"
|
|
88
|
+
|
|
89
|
+
TARGET_PROMPT_TITLE: Final = "Add override"
|
|
90
|
+
|
|
91
|
+
TARGET_PROMPT: Final = "What should this scenario change?"
|
|
92
|
+
|
|
93
|
+
VALUE_PROMPT_TITLE: Final = "Override value"
|
|
94
|
+
|
|
95
|
+
VALUE_PROMPT: Final = "New value (blank removes the override):"
|
|
96
|
+
|
|
97
|
+
TABLE_VALUE_PROMPT: Final = (
|
|
98
|
+
"New value — one 'key = value' line per figure, dotted keys for"
|
|
99
|
+
" nested tables (blank removes the override):"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
COMPARISON_HEADING: Final = "Comparison vs base plan"
|
|
103
|
+
|
|
104
|
+
COMPARISON_TABLE_LABEL: Final = "Table"
|
|
105
|
+
|
|
106
|
+
COMPARISON_CHART_LABEL: Final = "Chart"
|
|
107
|
+
|
|
108
|
+
METRIC_HEADING: Final = "Metric"
|
|
109
|
+
|
|
110
|
+
PERIOD_COLUMN_LABEL: Final = "Period"
|
|
111
|
+
|
|
112
|
+
BASE_RUN_LABEL: Final = "Base plan"
|
|
113
|
+
|
|
114
|
+
DELTA_COLUMN_SUFFIX: Final = " (vs base)"
|
|
115
|
+
|
|
116
|
+
NO_DELTA_TEXT: Final = "—"
|
|
117
|
+
|
|
118
|
+
VALID_STATUS: Final = "Ready"
|
|
119
|
+
|
|
120
|
+
ORPHANED_OVERRIDE_STATUS: Final = "Orphaned — target no longer in the plan"
|
|
121
|
+
|
|
122
|
+
NO_PLAN_MESSAGE: Final = (
|
|
123
|
+
"No plan yet — save facts on the Facts tab before building scenarios."
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
NO_SCENARIOS_MESSAGE: Final = (
|
|
127
|
+
"No scenarios yet — add one to compare a what-if against your base plan."
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
NO_VALID_SCENARIOS_MESSAGE: Final = (
|
|
131
|
+
"No valid scenarios to compare — remove or retarget the flagged overrides."
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
SCENARIO_RUN_FAILED_PREFIX: Final = "The scenario comparison failed: "
|
|
135
|
+
|
|
136
|
+
DEFAULT_COMPARISON_METRIC_KEY: Final = "closing_balance"
|
|
137
|
+
|
|
138
|
+
_METRIC_LABELS: Final[Mapping[str, str]] = {
|
|
139
|
+
"closing_balance": "Closing balance",
|
|
140
|
+
"income_total": "Income in payment",
|
|
141
|
+
"lump_sums": "Lump sums",
|
|
142
|
+
"tax_due": "Tax due",
|
|
143
|
+
"contributions": "Contributions",
|
|
144
|
+
"withdrawals_gross": "Withdrawals (gross)",
|
|
145
|
+
"net_withdrawn": "Net withdrawn",
|
|
146
|
+
"spending_need": "Spending need",
|
|
147
|
+
"planned_outflows": "Planned outflows",
|
|
148
|
+
"shortfall": "Shortfall",
|
|
149
|
+
}
|
|
150
|
+
"""Display labels per comparison metric — keyed by the
|
|
151
|
+
:class:`~glidepath.core.PeriodMetrics` field names (guard-tested)."""
|
|
152
|
+
|
|
153
|
+
_DECISION_KEY_SEPARATOR: Final = ":"
|
|
154
|
+
"""Joins entity id and field path into one stable decision-target key.
|
|
155
|
+
Assumption keys are dotted but never hold a colon, so the two key
|
|
156
|
+
grammars cannot collide."""
|
|
157
|
+
|
|
158
|
+
_UNKNOWN_METRIC_MESSAGE: Final = "unknown comparison metric key"
|
|
159
|
+
_NO_PLAN_EDIT_MESSAGE: Final = "save your facts before building scenarios"
|
|
160
|
+
_EMPTY_NAME_MESSAGE: Final = "enter a scenario name"
|
|
161
|
+
_RESERVED_NAME_MESSAGE: Final = f"{BASE_RUN_NAME!r} is reserved for the base plan"
|
|
162
|
+
_DUPLICATE_NAME_MESSAGE: Final = "a scenario with that name already exists"
|
|
163
|
+
_UNKNOWN_SCENARIO_MESSAGE: Final = "unknown scenario"
|
|
164
|
+
_UNKNOWN_TARGET_MESSAGE: Final = "unknown override target"
|
|
165
|
+
_ORPHANED_TARGET_MESSAGE: Final = (
|
|
166
|
+
"that target is no longer in the plan — remove the override instead"
|
|
167
|
+
)
|
|
168
|
+
_INT_VALUE_MESSAGE: Final = "enter a whole number"
|
|
169
|
+
_DECIMAL_VALUE_MESSAGE: Final = "enter a plain number, e.g. 0.05"
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@dataclass(frozen=True)
|
|
173
|
+
class TargetOption:
|
|
174
|
+
"""One override target a scenario may change (the §4.3 whitelist).
|
|
175
|
+
|
|
176
|
+
``key`` is the stable identity a shell hands back on selection;
|
|
177
|
+
``edit_text`` pre-fills the value prompt with the base value; a
|
|
178
|
+
non-empty ``choices`` narrows entry to those options (enum-valued
|
|
179
|
+
targets); ``structured`` marks a table-valued assumption, edited
|
|
180
|
+
as multi-line ``key = value`` text (issue #71).
|
|
181
|
+
"""
|
|
182
|
+
|
|
183
|
+
key: str
|
|
184
|
+
label: str
|
|
185
|
+
edit_text: str
|
|
186
|
+
structured: bool
|
|
187
|
+
choices: tuple[str, ...] = ()
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@dataclass(frozen=True)
|
|
191
|
+
class OverrideRow:
|
|
192
|
+
"""One scenario override rendered as its own diff line (§4.3)."""
|
|
193
|
+
|
|
194
|
+
target_key: str
|
|
195
|
+
target_label: str
|
|
196
|
+
value: str
|
|
197
|
+
note: str
|
|
198
|
+
status: str
|
|
199
|
+
orphaned: bool
|
|
200
|
+
edit_text: str
|
|
201
|
+
structured: bool
|
|
202
|
+
choices: tuple[str, ...] = ()
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@dataclass(frozen=True)
|
|
206
|
+
class ScenarioItem:
|
|
207
|
+
"""One scenario in the manager: name, validity, and its override list."""
|
|
208
|
+
|
|
209
|
+
name: str
|
|
210
|
+
note: str
|
|
211
|
+
status: str
|
|
212
|
+
valid: bool
|
|
213
|
+
overrides: tuple[OverrideRow, ...]
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@dataclass(frozen=True)
|
|
217
|
+
class ComparisonMetricOption:
|
|
218
|
+
"""One per-period metric the comparison report can present."""
|
|
219
|
+
|
|
220
|
+
key: str
|
|
221
|
+
label: str
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@dataclass(frozen=True)
|
|
225
|
+
class ScenariosViewModel:
|
|
226
|
+
"""The whole scenario manager + comparison screen (roadmap 8.5).
|
|
227
|
+
|
|
228
|
+
``comparison_rows`` are pre-formatted cells matching
|
|
229
|
+
``comparison_columns``; ``chart`` holds one series per run —
|
|
230
|
+
alternatives side by side, never a stack — or ``None`` when there
|
|
231
|
+
is nothing to compare, in which case ``message`` says why.
|
|
232
|
+
"""
|
|
233
|
+
|
|
234
|
+
title: str
|
|
235
|
+
scenarios_heading: str
|
|
236
|
+
add_scenario_label: str
|
|
237
|
+
remove_scenario_label: str
|
|
238
|
+
name_prompt_title: str
|
|
239
|
+
name_prompt: str
|
|
240
|
+
overrides_heading: str
|
|
241
|
+
overrides_columns: tuple[str, ...]
|
|
242
|
+
add_override_label: str
|
|
243
|
+
remove_override_label: str
|
|
244
|
+
target_prompt_title: str
|
|
245
|
+
target_prompt: str
|
|
246
|
+
value_prompt_title: str
|
|
247
|
+
value_prompt: str
|
|
248
|
+
table_value_prompt: str
|
|
249
|
+
scenarios: tuple[ScenarioItem, ...]
|
|
250
|
+
target_options: tuple[TargetOption, ...]
|
|
251
|
+
comparison_heading: str
|
|
252
|
+
comparison_table_label: str
|
|
253
|
+
comparison_chart_label: str
|
|
254
|
+
basis_heading: str
|
|
255
|
+
basis_options: tuple[ChartBasisOption, ...]
|
|
256
|
+
selected_basis_key: str
|
|
257
|
+
metric_heading: str
|
|
258
|
+
metric_options: tuple[ComparisonMetricOption, ...]
|
|
259
|
+
selected_metric_key: str
|
|
260
|
+
comparison_columns: tuple[str, ...]
|
|
261
|
+
comparison_rows: tuple[tuple[str, ...], ...]
|
|
262
|
+
chart_categories: tuple[str, ...]
|
|
263
|
+
chart: ChartSpec | None
|
|
264
|
+
message: str
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def metric_from_key(key: str) -> str:
|
|
268
|
+
"""The comparison metric a shell-selected option key denotes.
|
|
269
|
+
|
|
270
|
+
Raises:
|
|
271
|
+
ValueError: If ``key`` is not a known metric key.
|
|
272
|
+
"""
|
|
273
|
+
if key not in _METRIC_LABELS:
|
|
274
|
+
raise ValueError(_UNKNOWN_METRIC_MESSAGE)
|
|
275
|
+
return key
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _target_key(target: OverrideTarget) -> str:
|
|
279
|
+
"""A target's stable option key: the assumption key, or id + path."""
|
|
280
|
+
if isinstance(target, AssumptionTarget):
|
|
281
|
+
return str(target.key.value)
|
|
282
|
+
return f"{target.entity_id}{_DECISION_KEY_SEPARATOR}{target.field_path}"
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _edit_text(value: object) -> str:
|
|
286
|
+
"""A value as prompt-prefill text that parses back to its own shape."""
|
|
287
|
+
if isinstance(value, Money):
|
|
288
|
+
return str(value.quantized().amount)
|
|
289
|
+
if isinstance(value, Enum):
|
|
290
|
+
return format_value(value)
|
|
291
|
+
if isinstance(value, Mapping):
|
|
292
|
+
return table_edit_text(value)
|
|
293
|
+
return str(value)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _choices(value: object) -> tuple[str, ...]:
|
|
297
|
+
"""The display choices an enum-valued target narrows entry to."""
|
|
298
|
+
if isinstance(value, Enum):
|
|
299
|
+
return tuple(format_value(member) for member in type(value))
|
|
300
|
+
return ()
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _decision_infos(
|
|
304
|
+
household: Household | None,
|
|
305
|
+
) -> dict[tuple[EntityId, str], DecisionTargetInfo]:
|
|
306
|
+
"""The addressable decision targets, keyed for override lookup."""
|
|
307
|
+
if household is None:
|
|
308
|
+
return {}
|
|
309
|
+
return {
|
|
310
|
+
(info.target.entity_id, info.target.field_path): info
|
|
311
|
+
for info in decision_target_catalogue(household)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def scenario_target_options(state: PlanState) -> tuple[TargetOption, ...]:
|
|
316
|
+
"""Every legal override target: the plan's decisions, then assumptions.
|
|
317
|
+
|
|
318
|
+
Exactly the §4.3 whitelist — the option list has no way to name a
|
|
319
|
+
fact, so nothing user-stated can be offered for replacement.
|
|
320
|
+
Labels are guaranteed unique (duplicates are numbered), so a shell
|
|
321
|
+
resolving the user's pick by label cannot hit the wrong target.
|
|
322
|
+
"""
|
|
323
|
+
if state.household is None:
|
|
324
|
+
return ()
|
|
325
|
+
names = entity_names(state.household)
|
|
326
|
+
options = [
|
|
327
|
+
TargetOption(
|
|
328
|
+
key=_target_key(info.target),
|
|
329
|
+
label=display_label(info.label, names),
|
|
330
|
+
edit_text=_edit_text(info.value),
|
|
331
|
+
structured=False,
|
|
332
|
+
choices=_choices(info.value),
|
|
333
|
+
)
|
|
334
|
+
for info in decision_target_catalogue(state.household)
|
|
335
|
+
]
|
|
336
|
+
for key in sorted(state.assumptions.keys, key=str):
|
|
337
|
+
value = state.assumptions.get(key).value
|
|
338
|
+
options.append(
|
|
339
|
+
TargetOption(
|
|
340
|
+
key=str(key.value),
|
|
341
|
+
label=str(key.value),
|
|
342
|
+
edit_text=_edit_text(value),
|
|
343
|
+
structured=isinstance(value, Mapping),
|
|
344
|
+
)
|
|
345
|
+
)
|
|
346
|
+
return _uniquely_labelled(options)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _uniquely_labelled(options: list[TargetOption]) -> tuple[TargetOption, ...]:
|
|
350
|
+
"""The options with duplicate labels numbered in order.
|
|
351
|
+
|
|
352
|
+
Planned-outflow labels are free user text with no uniqueness rule,
|
|
353
|
+
so two outflows named alike would otherwise be indistinguishable
|
|
354
|
+
in a picker that returns display text.
|
|
355
|
+
"""
|
|
356
|
+
counts = Counter(option.label for option in options)
|
|
357
|
+
numbered: Counter[str] = Counter()
|
|
358
|
+
unique = []
|
|
359
|
+
for option in options:
|
|
360
|
+
if counts[option.label] == 1:
|
|
361
|
+
unique.append(option)
|
|
362
|
+
continue
|
|
363
|
+
numbered[option.label] += 1
|
|
364
|
+
unique.append(
|
|
365
|
+
TargetOption(
|
|
366
|
+
key=option.key,
|
|
367
|
+
label=f"{option.label} ({numbered[option.label]})",
|
|
368
|
+
edit_text=option.edit_text,
|
|
369
|
+
structured=option.structured,
|
|
370
|
+
choices=option.choices,
|
|
371
|
+
)
|
|
372
|
+
)
|
|
373
|
+
return tuple(unique)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def state_with_scenario_added(
|
|
377
|
+
state: PlanState, name: str, *, today: date
|
|
378
|
+
) -> OverrideOutcome:
|
|
379
|
+
"""Add an empty scenario named ``name``, or say why not (§4.3).
|
|
380
|
+
|
|
381
|
+
Names must be non-blank, unique, and not the reserved base run
|
|
382
|
+
name; scenarios need a base plan to diff against, so a household
|
|
383
|
+
must have been captured first.
|
|
384
|
+
"""
|
|
385
|
+
if state.household is None:
|
|
386
|
+
return OverrideOutcome(state=state, error=_NO_PLAN_EDIT_MESSAGE)
|
|
387
|
+
text = name.strip()
|
|
388
|
+
if not text:
|
|
389
|
+
return OverrideOutcome(state=state, error=_EMPTY_NAME_MESSAGE)
|
|
390
|
+
if text == BASE_RUN_NAME:
|
|
391
|
+
return OverrideOutcome(state=state, error=_RESERVED_NAME_MESSAGE)
|
|
392
|
+
if any(scenario.name == text for scenario in state.scenarios):
|
|
393
|
+
return OverrideOutcome(state=state, error=_DUPLICATE_NAME_MESSAGE)
|
|
394
|
+
scenarios = (*state.scenarios, Scenario(name=text))
|
|
395
|
+
return OverrideOutcome(state=state_with_scenarios(state, scenarios, today=today))
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def state_without_scenario(state: PlanState, name: str, *, today: date) -> PlanState:
|
|
399
|
+
"""The state with the named scenario removed (a no-op if unknown)."""
|
|
400
|
+
scenarios = tuple(s for s in state.scenarios if s.name != name)
|
|
401
|
+
if len(scenarios) == len(state.scenarios):
|
|
402
|
+
return state
|
|
403
|
+
return state_with_scenarios(state, scenarios, today=today)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def state_with_scenario_override(
|
|
407
|
+
state: PlanState,
|
|
408
|
+
scenario_name: str,
|
|
409
|
+
target_key: str,
|
|
410
|
+
raw_value: str,
|
|
411
|
+
*,
|
|
412
|
+
today: date,
|
|
413
|
+
) -> OverrideOutcome:
|
|
414
|
+
"""Set one override on a scenario and re-run the comparison.
|
|
415
|
+
|
|
416
|
+
A blank ``raw_value`` removes the override (the §4.3 symmetric of
|
|
417
|
+
"blank restores the default"). A value that does not parse — or
|
|
418
|
+
that the target entity's own invariants reject at resolution —
|
|
419
|
+
leaves the state untouched and reports why.
|
|
420
|
+
"""
|
|
421
|
+
scenario = _scenario_named(state, scenario_name)
|
|
422
|
+
if scenario is None or state.household is None:
|
|
423
|
+
return OverrideOutcome(state=state, error=_UNKNOWN_SCENARIO_MESSAGE)
|
|
424
|
+
text = raw_value.strip()
|
|
425
|
+
if not text:
|
|
426
|
+
removed = state_without_scenario_override(
|
|
427
|
+
state, scenario_name, target_key, today=today
|
|
428
|
+
)
|
|
429
|
+
return OverrideOutcome(state=removed)
|
|
430
|
+
try:
|
|
431
|
+
override = _parsed_override(state.household, state, target_key, text)
|
|
432
|
+
except ValueError as exc:
|
|
433
|
+
return OverrideOutcome(state=state, error=str(exc))
|
|
434
|
+
candidate = _scenario_with_override(scenario, override)
|
|
435
|
+
checkable = _without_orphans(candidate, state.household, state.assumptions)
|
|
436
|
+
try:
|
|
437
|
+
resolve_scenario(state.household, state.assumptions, checkable)
|
|
438
|
+
except ValueError as exc:
|
|
439
|
+
return OverrideOutcome(state=state, error=str(exc))
|
|
440
|
+
scenarios = tuple(
|
|
441
|
+
candidate if s.name == scenario_name else s for s in state.scenarios
|
|
442
|
+
)
|
|
443
|
+
return OverrideOutcome(state=state_with_scenarios(state, scenarios, today=today))
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def state_without_scenario_override(
|
|
447
|
+
state: PlanState, scenario_name: str, target_key: str, *, today: date
|
|
448
|
+
) -> PlanState:
|
|
449
|
+
"""The state with one override removed (a no-op if not present)."""
|
|
450
|
+
scenario = _scenario_named(state, scenario_name)
|
|
451
|
+
if scenario is None:
|
|
452
|
+
return state
|
|
453
|
+
overrides = tuple(
|
|
454
|
+
override
|
|
455
|
+
for override in scenario.overrides
|
|
456
|
+
if _target_key(override.target) != target_key
|
|
457
|
+
)
|
|
458
|
+
if len(overrides) == len(scenario.overrides):
|
|
459
|
+
return state
|
|
460
|
+
changed = Scenario(name=scenario.name, overrides=overrides, note=scenario.note)
|
|
461
|
+
scenarios = tuple(
|
|
462
|
+
changed if s.name == scenario_name else s for s in state.scenarios
|
|
463
|
+
)
|
|
464
|
+
return state_with_scenarios(state, scenarios, today=today)
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _scenario_named(state: PlanState, name: str) -> Scenario | None:
|
|
468
|
+
"""The state's scenario named ``name``, if any."""
|
|
469
|
+
for scenario in state.scenarios:
|
|
470
|
+
if scenario.name == name:
|
|
471
|
+
return scenario
|
|
472
|
+
return None
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def _without_orphans(
|
|
476
|
+
scenario: Scenario, household: Household, assumptions: AssumptionSet
|
|
477
|
+
) -> Scenario:
|
|
478
|
+
"""The scenario with its orphaned overrides stripped, for validation.
|
|
479
|
+
|
|
480
|
+
An orphaned override cannot resolve, but its presence must not
|
|
481
|
+
exempt the *addressable* overrides from the entity invariants a
|
|
482
|
+
resolution enforces — otherwise a scenario with one deleted target
|
|
483
|
+
would silently accept invalid values everywhere else.
|
|
484
|
+
"""
|
|
485
|
+
orphans = scenario_orphans(scenario, household, assumptions)
|
|
486
|
+
if not orphans:
|
|
487
|
+
return scenario
|
|
488
|
+
overrides = tuple(
|
|
489
|
+
override for override in scenario.overrides if override not in orphans
|
|
490
|
+
)
|
|
491
|
+
return Scenario(name=scenario.name, overrides=overrides, note=scenario.note)
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _scenario_with_override(scenario: Scenario, override: Override) -> Scenario:
|
|
495
|
+
"""The scenario with ``override`` replacing its target's entry, in place.
|
|
496
|
+
|
|
497
|
+
An override on a new target appends; one on an existing target
|
|
498
|
+
keeps its position — and its note, when the replacement carries
|
|
499
|
+
none — so the diff view stays stable under value edits.
|
|
500
|
+
"""
|
|
501
|
+
existing = next(
|
|
502
|
+
(entry for entry in scenario.overrides if entry.target == override.target),
|
|
503
|
+
None,
|
|
504
|
+
)
|
|
505
|
+
if existing is None:
|
|
506
|
+
overrides = (*scenario.overrides, override)
|
|
507
|
+
else:
|
|
508
|
+
merged = Override(
|
|
509
|
+
target=override.target,
|
|
510
|
+
value=override.value,
|
|
511
|
+
note=override.note if override.note is not None else existing.note,
|
|
512
|
+
)
|
|
513
|
+
overrides = tuple(
|
|
514
|
+
merged if entry.target == override.target else entry
|
|
515
|
+
for entry in scenario.overrides
|
|
516
|
+
)
|
|
517
|
+
return Scenario(name=scenario.name, overrides=overrides, note=scenario.note)
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _parsed_override(
|
|
521
|
+
household: Household, state: PlanState, target_key: str, text: str
|
|
522
|
+
) -> Override:
|
|
523
|
+
"""The typed override ``target_key`` and ``text`` denote.
|
|
524
|
+
|
|
525
|
+
Raises:
|
|
526
|
+
ValueError: If the key names no addressable target or the text
|
|
527
|
+
does not parse as the base value's shape.
|
|
528
|
+
"""
|
|
529
|
+
if _DECISION_KEY_SEPARATOR in target_key:
|
|
530
|
+
entity_text, _, field_path = target_key.partition(_DECISION_KEY_SEPARATOR)
|
|
531
|
+
info = _decision_infos(household).get((EntityId(entity_text), field_path))
|
|
532
|
+
if info is None:
|
|
533
|
+
raise ValueError(_ORPHANED_TARGET_MESSAGE)
|
|
534
|
+
return Override(
|
|
535
|
+
target=info.target, value=_parsed_decision_value(info.value, text)
|
|
536
|
+
)
|
|
537
|
+
try:
|
|
538
|
+
key = AssumptionKey(target_key)
|
|
539
|
+
except ValueError:
|
|
540
|
+
raise ValueError(_UNKNOWN_TARGET_MESSAGE) from None
|
|
541
|
+
base = state.assumptions.get(key)
|
|
542
|
+
return Override(
|
|
543
|
+
target=AssumptionTarget(key=key), value=_parsed_override_value(base, text)
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def _parsed_decision_value(base_value: object, raw: str) -> object:
|
|
548
|
+
"""The typed value ``raw`` denotes for a decision target's shape.
|
|
549
|
+
|
|
550
|
+
The §4.3 whitelist holds ``Money``, ``int``, ``Decimal``, and enum
|
|
551
|
+
decisions only, so those are the shapes parsed here.
|
|
552
|
+
|
|
553
|
+
Raises:
|
|
554
|
+
ValueError: If ``raw`` does not parse as the base value's shape.
|
|
555
|
+
"""
|
|
556
|
+
if isinstance(base_value, Money):
|
|
557
|
+
return Money(_parsed_decimal(raw))
|
|
558
|
+
if isinstance(base_value, Enum):
|
|
559
|
+
return _parsed_choice(base_value, raw)
|
|
560
|
+
if isinstance(base_value, int):
|
|
561
|
+
try:
|
|
562
|
+
return int(raw, 10)
|
|
563
|
+
except ValueError:
|
|
564
|
+
raise ValueError(_INT_VALUE_MESSAGE) from None
|
|
565
|
+
if isinstance(base_value, Decimal):
|
|
566
|
+
return _parsed_decimal(raw)
|
|
567
|
+
msg = f"cannot override a {type(base_value).__name__} value"
|
|
568
|
+
raise ValueError(msg)
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def _parsed_decimal(raw: str) -> Decimal:
|
|
572
|
+
"""A finite decimal from user text.
|
|
573
|
+
|
|
574
|
+
Raises:
|
|
575
|
+
ValueError: If ``raw`` is not a plain finite number.
|
|
576
|
+
"""
|
|
577
|
+
try:
|
|
578
|
+
value = Decimal(raw)
|
|
579
|
+
except InvalidOperation:
|
|
580
|
+
raise ValueError(_DECIMAL_VALUE_MESSAGE) from None
|
|
581
|
+
if not value.is_finite():
|
|
582
|
+
raise ValueError(_DECIMAL_VALUE_MESSAGE)
|
|
583
|
+
return value
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def _parsed_choice(base_value: Enum, raw: str) -> Enum:
|
|
587
|
+
"""The enum member ``raw`` names, by display text or member name.
|
|
588
|
+
|
|
589
|
+
Raises:
|
|
590
|
+
ValueError: If ``raw`` names no member, listing the choices.
|
|
591
|
+
"""
|
|
592
|
+
text = raw.strip().lower()
|
|
593
|
+
for member in type(base_value):
|
|
594
|
+
if text in {format_value(member).lower(), member.name.lower()}:
|
|
595
|
+
return member
|
|
596
|
+
choices = ", ".join(format_value(member) for member in type(base_value))
|
|
597
|
+
msg = f"choose one of: {choices}"
|
|
598
|
+
raise ValueError(msg)
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def build_scenarios_view_model(
|
|
602
|
+
state: PlanState,
|
|
603
|
+
basis: ReportBasis = DEFAULT_CHART_BASIS,
|
|
604
|
+
metric_key: str = DEFAULT_COMPARISON_METRIC_KEY,
|
|
605
|
+
) -> ScenariosViewModel:
|
|
606
|
+
"""The scenario manager + comparison screen for ``state``.
|
|
607
|
+
|
|
608
|
+
Scenarios render as their override lists (§4.3: the deltas *are*
|
|
609
|
+
the diff); the comparison presents the selected metric per period
|
|
610
|
+
across the base and every valid scenario. Without anything to
|
|
611
|
+
compare, ``message`` carries the reason.
|
|
612
|
+
|
|
613
|
+
Raises:
|
|
614
|
+
ValueError: If ``metric_key`` is not a known metric key.
|
|
615
|
+
"""
|
|
616
|
+
metric = metric_from_key(metric_key)
|
|
617
|
+
names = entity_names(state.household)
|
|
618
|
+
infos = _decision_infos(state.household)
|
|
619
|
+
items = tuple(
|
|
620
|
+
_scenario_item(scenario, state, names, infos) for scenario in state.scenarios
|
|
621
|
+
)
|
|
622
|
+
columns, rows, categories, chart = _comparison(state, basis, metric)
|
|
623
|
+
return ScenariosViewModel(
|
|
624
|
+
title=TITLE,
|
|
625
|
+
scenarios_heading=SCENARIOS_HEADING,
|
|
626
|
+
add_scenario_label=ADD_SCENARIO_LABEL,
|
|
627
|
+
remove_scenario_label=REMOVE_SCENARIO_LABEL,
|
|
628
|
+
name_prompt_title=NAME_PROMPT_TITLE,
|
|
629
|
+
name_prompt=NAME_PROMPT,
|
|
630
|
+
overrides_heading=OVERRIDES_HEADING,
|
|
631
|
+
overrides_columns=OVERRIDES_COLUMNS,
|
|
632
|
+
add_override_label=ADD_OVERRIDE_LABEL,
|
|
633
|
+
remove_override_label=REMOVE_OVERRIDE_LABEL,
|
|
634
|
+
target_prompt_title=TARGET_PROMPT_TITLE,
|
|
635
|
+
target_prompt=TARGET_PROMPT,
|
|
636
|
+
value_prompt_title=VALUE_PROMPT_TITLE,
|
|
637
|
+
value_prompt=VALUE_PROMPT,
|
|
638
|
+
table_value_prompt=TABLE_VALUE_PROMPT,
|
|
639
|
+
scenarios=items,
|
|
640
|
+
target_options=scenario_target_options(state),
|
|
641
|
+
comparison_heading=COMPARISON_HEADING,
|
|
642
|
+
comparison_table_label=COMPARISON_TABLE_LABEL,
|
|
643
|
+
comparison_chart_label=COMPARISON_CHART_LABEL,
|
|
644
|
+
basis_heading=BASIS_HEADING,
|
|
645
|
+
basis_options=basis_options(),
|
|
646
|
+
selected_basis_key=basis_key(basis),
|
|
647
|
+
metric_heading=METRIC_HEADING,
|
|
648
|
+
metric_options=tuple(
|
|
649
|
+
ComparisonMetricOption(key=key, label=label)
|
|
650
|
+
for key, label in _METRIC_LABELS.items()
|
|
651
|
+
),
|
|
652
|
+
selected_metric_key=metric,
|
|
653
|
+
comparison_columns=columns,
|
|
654
|
+
comparison_rows=rows,
|
|
655
|
+
chart_categories=categories,
|
|
656
|
+
chart=chart,
|
|
657
|
+
message=_message(state),
|
|
658
|
+
)
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def _message(state: PlanState) -> str:
|
|
662
|
+
"""The empty-state copy — why there is nothing to compare, if so."""
|
|
663
|
+
if state.household is None:
|
|
664
|
+
return NO_PLAN_MESSAGE
|
|
665
|
+
if not state.scenarios:
|
|
666
|
+
return NO_SCENARIOS_MESSAGE
|
|
667
|
+
if state.scenario_run_error is not None:
|
|
668
|
+
return SCENARIO_RUN_FAILED_PREFIX + state.scenario_run_error
|
|
669
|
+
if state.scenario_runs is None:
|
|
670
|
+
return NO_VALID_SCENARIOS_MESSAGE
|
|
671
|
+
return ""
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def _scenario_item(
|
|
675
|
+
scenario: Scenario,
|
|
676
|
+
state: PlanState,
|
|
677
|
+
names: Mapping[str, str],
|
|
678
|
+
infos: Mapping[tuple[EntityId, str], DecisionTargetInfo],
|
|
679
|
+
) -> ScenarioItem:
|
|
680
|
+
"""One scenario as its manager entry: status plus override rows."""
|
|
681
|
+
orphans: tuple[Override, ...] = ()
|
|
682
|
+
if state.household is not None:
|
|
683
|
+
orphans = scenario_orphans(scenario, state.household, state.assumptions)
|
|
684
|
+
rows = tuple(
|
|
685
|
+
_override_row(override, names, infos, orphaned=override in orphans)
|
|
686
|
+
for override in scenario.overrides
|
|
687
|
+
)
|
|
688
|
+
return ScenarioItem(
|
|
689
|
+
name=scenario.name,
|
|
690
|
+
note=scenario.note or "",
|
|
691
|
+
status=VALID_STATUS if not orphans else _invalid_status(len(orphans)),
|
|
692
|
+
valid=not orphans,
|
|
693
|
+
overrides=rows,
|
|
694
|
+
)
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def _invalid_status(count: int) -> str:
|
|
698
|
+
"""The status line of a scenario with ``count`` orphaned overrides."""
|
|
699
|
+
noun = "override" if count == 1 else "overrides"
|
|
700
|
+
return f"Invalid — {count} orphaned {noun}; excluded from the comparison"
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def _override_row(
|
|
704
|
+
override: Override,
|
|
705
|
+
names: Mapping[str, str],
|
|
706
|
+
infos: Mapping[tuple[EntityId, str], DecisionTargetInfo],
|
|
707
|
+
*,
|
|
708
|
+
orphaned: bool,
|
|
709
|
+
) -> OverrideRow:
|
|
710
|
+
"""One override as its diff line, orphan status included."""
|
|
711
|
+
target = override.target
|
|
712
|
+
if isinstance(target, AssumptionTarget):
|
|
713
|
+
label = str(target.key.value)
|
|
714
|
+
else:
|
|
715
|
+
info = infos.get((target.entity_id, target.field_path))
|
|
716
|
+
label = (
|
|
717
|
+
display_label(info.label, names)
|
|
718
|
+
if info is not None
|
|
719
|
+
else capitalised(pretty_path(target.field_path))
|
|
720
|
+
)
|
|
721
|
+
return OverrideRow(
|
|
722
|
+
target_key=_target_key(target),
|
|
723
|
+
target_label=label,
|
|
724
|
+
value=format_value(override.value),
|
|
725
|
+
note=override.note or "",
|
|
726
|
+
status=ORPHANED_OVERRIDE_STATUS if orphaned else "",
|
|
727
|
+
orphaned=orphaned,
|
|
728
|
+
edit_text=_edit_text(override.value),
|
|
729
|
+
structured=isinstance(override.value, Mapping),
|
|
730
|
+
choices=_choices(override.value),
|
|
731
|
+
)
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def _comparison(
|
|
735
|
+
state: PlanState, basis: ReportBasis, metric_key: str
|
|
736
|
+
) -> tuple[
|
|
737
|
+
tuple[str, ...],
|
|
738
|
+
tuple[tuple[str, ...], ...],
|
|
739
|
+
tuple[str, ...],
|
|
740
|
+
ChartSpec | None,
|
|
741
|
+
]:
|
|
742
|
+
"""The comparison table and chart for the selected metric and basis.
|
|
743
|
+
|
|
744
|
+
The table covers the union of the runs' periods (blank cells where
|
|
745
|
+
a run never modelled one); the chart clamps to periods every run
|
|
746
|
+
modelled — a run with a shorter horizon has *no data* there, and a
|
|
747
|
+
zero-height bar would falsely read as the metric falling to zero.
|
|
748
|
+
"""
|
|
749
|
+
if state.scenario_runs is None:
|
|
750
|
+
return (), (), (), None
|
|
751
|
+
comparison = compare_scenario_results(state.scenario_runs, basis)
|
|
752
|
+
base_name = comparison.run_names[0]
|
|
753
|
+
columns = [PERIOD_COLUMN_LABEL, BASE_RUN_LABEL]
|
|
754
|
+
for name in comparison.run_names[1:]:
|
|
755
|
+
columns.extend((name, f"{name}{DELTA_COLUMN_SUFFIX}"))
|
|
756
|
+
rows: list[tuple[str, ...]] = []
|
|
757
|
+
categories: list[str] = []
|
|
758
|
+
values: dict[str, list[Decimal]] = {name: [] for name in comparison.run_names}
|
|
759
|
+
for row in comparison.rows:
|
|
760
|
+
period_label = str(row.period.start.year)
|
|
761
|
+
entries = {entry.run_name: entry for entry in row.entries}
|
|
762
|
+
cells = [period_label, _metric_text(entries.get(base_name), metric_key)]
|
|
763
|
+
for name in comparison.run_names[1:]:
|
|
764
|
+
entry = entries.get(name)
|
|
765
|
+
cells.append(_metric_text(entry, metric_key))
|
|
766
|
+
cells.append(_delta_text(entry, metric_key))
|
|
767
|
+
rows.append(tuple(cells))
|
|
768
|
+
if len(entries) == len(comparison.run_names):
|
|
769
|
+
categories.append(period_label)
|
|
770
|
+
for name in comparison.run_names:
|
|
771
|
+
values[name].append(getattr(entries[name].metrics, metric_key).amount)
|
|
772
|
+
metric_label = _METRIC_LABELS[metric_key]
|
|
773
|
+
series = tuple(
|
|
774
|
+
ChartSeries(
|
|
775
|
+
label=BASE_RUN_LABEL if name == base_name else name,
|
|
776
|
+
values=tuple(values[name]),
|
|
777
|
+
)
|
|
778
|
+
for name in comparison.run_names
|
|
779
|
+
)
|
|
780
|
+
flat = [value for entry in values.values() for value in entry]
|
|
781
|
+
chart = ChartSpec(
|
|
782
|
+
title=metric_label,
|
|
783
|
+
y_axis_label=f"{metric_label}, £ ({basis_suffix(basis)})",
|
|
784
|
+
y_axis_max=max([*flat, Decimal(1)]),
|
|
785
|
+
series=series,
|
|
786
|
+
)
|
|
787
|
+
return tuple(columns), tuple(rows), tuple(categories), chart
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def _metric_text(entry: ScenarioPeriodEntry | None, metric_key: str) -> str:
|
|
791
|
+
"""One run's metric amount in one period, or blank if not modelled."""
|
|
792
|
+
if entry is None:
|
|
793
|
+
return ""
|
|
794
|
+
money: Money = getattr(entry.metrics, metric_key)
|
|
795
|
+
return format_money(money)
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
def _delta_text(entry: ScenarioPeriodEntry | None, metric_key: str) -> str:
|
|
799
|
+
"""A run's delta vs base, signed; a dash where no diff is meaningful."""
|
|
800
|
+
if entry is None:
|
|
801
|
+
return ""
|
|
802
|
+
if entry.delta_vs_base is None:
|
|
803
|
+
return NO_DELTA_TEXT
|
|
804
|
+
delta: Money = getattr(entry.delta_vs_base, metric_key)
|
|
805
|
+
text = format_money(delta)
|
|
806
|
+
return f"+{text}" if delta.amount > 0 else text
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
__all__ = [
|
|
810
|
+
"ADD_OVERRIDE_LABEL",
|
|
811
|
+
"ADD_SCENARIO_LABEL",
|
|
812
|
+
"DEFAULT_COMPARISON_METRIC_KEY",
|
|
813
|
+
"NO_PLAN_MESSAGE",
|
|
814
|
+
"NO_SCENARIOS_MESSAGE",
|
|
815
|
+
"NO_VALID_SCENARIOS_MESSAGE",
|
|
816
|
+
"ORPHANED_OVERRIDE_STATUS",
|
|
817
|
+
"SCENARIO_RUN_FAILED_PREFIX",
|
|
818
|
+
"VALID_STATUS",
|
|
819
|
+
"ComparisonMetricOption",
|
|
820
|
+
"OverrideRow",
|
|
821
|
+
"ScenarioItem",
|
|
822
|
+
"ScenariosViewModel",
|
|
823
|
+
"TargetOption",
|
|
824
|
+
"build_scenarios_view_model",
|
|
825
|
+
"metric_from_key",
|
|
826
|
+
"scenario_target_options",
|
|
827
|
+
"state_with_scenario_added",
|
|
828
|
+
"state_with_scenario_override",
|
|
829
|
+
"state_without_scenario",
|
|
830
|
+
"state_without_scenario_override",
|
|
831
|
+
]
|