owlplanner 2025.12.20__py3-none-any.whl → 2026.1.26__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.
- owlplanner/In Discussion #58, the case of Kim and Sam.md +307 -0
- owlplanner/__init__.py +20 -1
- owlplanner/abcapi.py +18 -17
- owlplanner/cli/README.md +50 -0
- owlplanner/cli/_main.py +52 -0
- owlplanner/cli/cli_logging.py +56 -0
- owlplanner/cli/cmd_list.py +83 -0
- owlplanner/cli/cmd_run.py +86 -0
- owlplanner/config.py +300 -117
- owlplanner/data/__init__.py +21 -0
- owlplanner/data/rates.csv +99 -98
- owlplanner/debts.py +36 -8
- owlplanner/fixedassets.py +95 -21
- owlplanner/mylogging.py +157 -25
- owlplanner/plan.py +793 -349
- owlplanner/plotting/__init__.py +16 -3
- owlplanner/plotting/base.py +17 -3
- owlplanner/plotting/factory.py +16 -3
- owlplanner/plotting/matplotlib_backend.py +30 -7
- owlplanner/plotting/plotly_backend.py +32 -9
- owlplanner/progress.py +16 -3
- owlplanner/rates.py +366 -361
- owlplanner/socialsecurity.py +28 -19
- owlplanner/tax2026.py +109 -30
- owlplanner/timelists.py +194 -18
- owlplanner/utils.py +179 -4
- owlplanner/version.py +20 -1
- {owlplanner-2025.12.20.dist-info → owlplanner-2026.1.26.dist-info}/METADATA +10 -2
- owlplanner-2026.1.26.dist-info/RECORD +36 -0
- owlplanner-2026.1.26.dist-info/entry_points.txt +2 -0
- owlplanner-2026.1.26.dist-info/licenses/AUTHORS +15 -0
- owlplanner/tax2025.py +0 -359
- owlplanner-2025.12.20.dist-info/RECORD +0 -29
- {owlplanner-2025.12.20.dist-info → owlplanner-2026.1.26.dist-info}/WHEEL +0 -0
- {owlplanner-2025.12.20.dist-info → owlplanner-2026.1.26.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI command for running retirement planning cases.
|
|
3
|
+
|
|
4
|
+
This module provides the 'run' command for executing retirement planning
|
|
5
|
+
optimization from the command line.
|
|
6
|
+
|
|
7
|
+
Copyright (C) 2025-2026 The Owlplanner Authors
|
|
8
|
+
|
|
9
|
+
This program is free software: you can redistribute it and/or modify
|
|
10
|
+
it under the terms of the GNU General Public License as published by
|
|
11
|
+
the Free Software Foundation, either version 3 of the License, or
|
|
12
|
+
(at your option) any later version.
|
|
13
|
+
|
|
14
|
+
This program is distributed in the hope that it will be useful,
|
|
15
|
+
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
16
|
+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
17
|
+
GNU General Public License for more details.
|
|
18
|
+
|
|
19
|
+
You should have received a copy of the GNU General Public License
|
|
20
|
+
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import click
|
|
24
|
+
from loguru import logger
|
|
25
|
+
import owlplanner as owl
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def validate_toml(ctx, param, value: Path):
|
|
30
|
+
if value is None:
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
# If no suffix, append .toml
|
|
34
|
+
if value.suffix == "":
|
|
35
|
+
value = value.with_suffix(".toml")
|
|
36
|
+
|
|
37
|
+
# Enforce .toml extension
|
|
38
|
+
if value.suffix.lower() != ".toml":
|
|
39
|
+
raise click.BadParameter("File must have a .toml extension")
|
|
40
|
+
|
|
41
|
+
# Check existence AFTER normalization
|
|
42
|
+
if not value.exists():
|
|
43
|
+
raise click.BadParameter(f"File '{value}' does not exist")
|
|
44
|
+
|
|
45
|
+
if not value.is_file():
|
|
46
|
+
raise click.BadParameter(f"'{value}' is not a file")
|
|
47
|
+
|
|
48
|
+
return value
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@click.command(name="run")
|
|
52
|
+
@click.argument(
|
|
53
|
+
"filename",
|
|
54
|
+
type=click.Path(exists=False, dir_okay=False, path_type=Path),
|
|
55
|
+
callback=validate_toml,
|
|
56
|
+
)
|
|
57
|
+
@click.option(
|
|
58
|
+
"--with-config",
|
|
59
|
+
"with_config",
|
|
60
|
+
type=click.Choice(["no", "first", "last"], case_sensitive=False),
|
|
61
|
+
default="first",
|
|
62
|
+
show_default=True,
|
|
63
|
+
help="Include config TOML sheet at the first or last position.",
|
|
64
|
+
)
|
|
65
|
+
def cmd_run(filename: Path, with_config: str):
|
|
66
|
+
"""Run the solver for an input OWL plan file.
|
|
67
|
+
|
|
68
|
+
FILENAME is the OWL plan file to run. If no extension is provided,
|
|
69
|
+
.toml will be appended. The file must exist.
|
|
70
|
+
|
|
71
|
+
An output Excel file with results will be created in the current directory.
|
|
72
|
+
The output filename is derived from the input filename by appending
|
|
73
|
+
'_results.xlsx' to the stem of the input filename.
|
|
74
|
+
|
|
75
|
+
Optionally include the case configuration as a TOML worksheet.
|
|
76
|
+
|
|
77
|
+
"""
|
|
78
|
+
logger.debug(f"Executing the run command with file: {filename}")
|
|
79
|
+
|
|
80
|
+
plan = owl.readConfig(str(filename), logstreams="loguru", readContributions=True)
|
|
81
|
+
plan.solve(plan.objective, plan.solverOptions)
|
|
82
|
+
click.echo(f"Case status: {plan.caseStatus}")
|
|
83
|
+
if plan.caseStatus == "solved":
|
|
84
|
+
output_filename = filename.with_name(filename.stem + "_results.xlsx")
|
|
85
|
+
plan.saveWorkbook(basename=output_filename, overwrite=True, with_config=with_config)
|
|
86
|
+
click.echo(f"Results saved to: {output_filename}")
|
owlplanner/config.py
CHANGED
|
@@ -1,13 +1,23 @@
|
|
|
1
1
|
"""
|
|
2
|
+
Configuration management for saving and loading case parameters.
|
|
2
3
|
|
|
3
|
-
|
|
4
|
+
This module provides utility functions to save and load retirement planning
|
|
5
|
+
case parameters in TOML format.
|
|
4
6
|
|
|
5
|
-
|
|
7
|
+
Copyright (C) 2025-2026 The Owlplanner Authors
|
|
6
8
|
|
|
7
|
-
|
|
9
|
+
This program is free software: you can redistribute it and/or modify
|
|
10
|
+
it under the terms of the GNU General Public License as published by
|
|
11
|
+
the Free Software Foundation, either version 3 of the License, or
|
|
12
|
+
(at your option) any later version.
|
|
8
13
|
|
|
9
|
-
|
|
14
|
+
This program is distributed in the hope that it will be useful,
|
|
15
|
+
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
16
|
+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
17
|
+
GNU General Public License for more details.
|
|
10
18
|
|
|
19
|
+
You should have received a copy of the GNU General Public License
|
|
20
|
+
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
11
21
|
"""
|
|
12
22
|
|
|
13
23
|
import toml
|
|
@@ -24,92 +34,267 @@ from owlplanner.rates import FROM, TO
|
|
|
24
34
|
AccountTypes = ["taxable", "tax-deferred", "tax-free"]
|
|
25
35
|
|
|
26
36
|
|
|
37
|
+
# Translation dictionary for backward compatibility: old keys -> new snake_case keys
|
|
38
|
+
_KEY_TRANSLATION = {
|
|
39
|
+
# Root level keys
|
|
40
|
+
"Plan Name": "case_name",
|
|
41
|
+
"Description": "description",
|
|
42
|
+
# Section names
|
|
43
|
+
"Basic Info": "basic_info",
|
|
44
|
+
"Assets": "savings_assets",
|
|
45
|
+
"Household Financial Profile": "household_financial_profile",
|
|
46
|
+
"Fixed Income": "fixed_income",
|
|
47
|
+
"Rates Selection": "rates_selection",
|
|
48
|
+
"Asset Allocation": "asset_allocation",
|
|
49
|
+
"Optimization Parameters": "optimization_parameters",
|
|
50
|
+
"Solver Options": "solver_options",
|
|
51
|
+
"Results": "results",
|
|
52
|
+
# Basic Info keys
|
|
53
|
+
"Status": "status",
|
|
54
|
+
"Names": "names",
|
|
55
|
+
"Date of birth": "date_of_birth",
|
|
56
|
+
"Life expectancy": "life_expectancy",
|
|
57
|
+
"Start date": "start_date",
|
|
58
|
+
# Assets keys
|
|
59
|
+
"taxable savings balances": "taxable_savings_balances",
|
|
60
|
+
"tax-deferred savings balances": "tax_deferred_savings_balances",
|
|
61
|
+
"tax-free savings balances": "tax_free_savings_balances",
|
|
62
|
+
"Beneficiary fractions": "beneficiary_fractions",
|
|
63
|
+
"Spousal surplus deposit fraction": "spousal_surplus_deposit_fraction",
|
|
64
|
+
# Household Financial Profile keys
|
|
65
|
+
"HFP file name": "HFP_file_name",
|
|
66
|
+
# Fixed Income keys
|
|
67
|
+
"Pension monthly amounts": "pension_monthly_amounts",
|
|
68
|
+
"Pension ages": "pension_ages",
|
|
69
|
+
"Pension indexed": "pension_indexed",
|
|
70
|
+
"Social security PIA amounts": "social_security_pia_amounts",
|
|
71
|
+
"Social security ages": "social_security_ages",
|
|
72
|
+
# Rates Selection keys
|
|
73
|
+
"Heirs rate on tax-deferred estate": "heirs_rate_on_tax_deferred_estate",
|
|
74
|
+
"Dividend rate": "dividend_rate",
|
|
75
|
+
"OBBBA expiration year": "obbba_expiration_year",
|
|
76
|
+
"Method": "method",
|
|
77
|
+
"Rate seed": "rate_seed",
|
|
78
|
+
"Reproducible rates": "reproducible_rates",
|
|
79
|
+
"Values": "values",
|
|
80
|
+
"Standard deviations": "standard_deviations",
|
|
81
|
+
"Correlations": "correlations",
|
|
82
|
+
"From": "from",
|
|
83
|
+
"To": "to",
|
|
84
|
+
# Asset Allocation keys
|
|
85
|
+
"Interpolation method": "interpolation_method",
|
|
86
|
+
"Interpolation center": "interpolation_center",
|
|
87
|
+
"Interpolation width": "interpolation_width",
|
|
88
|
+
"Type": "type",
|
|
89
|
+
# Optimization Parameters keys
|
|
90
|
+
"Spending profile": "spending_profile",
|
|
91
|
+
"Surviving spouse spending percent": "surviving_spouse_spending_percent",
|
|
92
|
+
"Smile dip": "smile_dip",
|
|
93
|
+
"Smile increase": "smile_increase",
|
|
94
|
+
"Smile delay": "smile_delay",
|
|
95
|
+
"Objective": "objective",
|
|
96
|
+
# Results keys
|
|
97
|
+
"Default plots": "default_plots",
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def translate_old_keys(diconf):
|
|
102
|
+
"""
|
|
103
|
+
Translate old TOML keys to new snake_case keys for backward compatibility.
|
|
104
|
+
This function recursively processes the configuration dictionary and replaces
|
|
105
|
+
old keys with new snake_case keys.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
diconf: Configuration dictionary (may be modified in place)
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
Dictionary with translated keys
|
|
112
|
+
"""
|
|
113
|
+
if not isinstance(diconf, dict):
|
|
114
|
+
return diconf
|
|
115
|
+
|
|
116
|
+
translated = {}
|
|
117
|
+
|
|
118
|
+
# First, translate section names at the top level
|
|
119
|
+
for key, value in diconf.items():
|
|
120
|
+
new_key = _KEY_TRANSLATION.get(key, key)
|
|
121
|
+
|
|
122
|
+
if isinstance(value, dict):
|
|
123
|
+
# Recursively translate keys within sections
|
|
124
|
+
translated[new_key] = {}
|
|
125
|
+
for sub_key, sub_value in value.items():
|
|
126
|
+
new_sub_key = _KEY_TRANSLATION.get(sub_key, sub_key)
|
|
127
|
+
if isinstance(sub_value, dict):
|
|
128
|
+
translated[new_key][new_sub_key] = translate_old_keys(sub_value)
|
|
129
|
+
else:
|
|
130
|
+
translated[new_key][new_sub_key] = sub_value
|
|
131
|
+
else:
|
|
132
|
+
translated[new_key] = value
|
|
133
|
+
|
|
134
|
+
return translated
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _read_toml_file(file):
|
|
138
|
+
"""
|
|
139
|
+
Unified TOML file reading for different input types.
|
|
140
|
+
|
|
141
|
+
This function handles reading TOML content from three different input types:
|
|
142
|
+
- str: File path (reads from filesystem)
|
|
143
|
+
- BytesIO: Binary stream (decodes to UTF-8 string)
|
|
144
|
+
- StringIO: Text stream (reads string directly)
|
|
145
|
+
|
|
146
|
+
Parameters
|
|
147
|
+
----------
|
|
148
|
+
file : str, BytesIO, or StringIO
|
|
149
|
+
The file source to read from. Can be a file path string, BytesIO object,
|
|
150
|
+
or StringIO object.
|
|
151
|
+
|
|
152
|
+
Returns
|
|
153
|
+
-------
|
|
154
|
+
tuple
|
|
155
|
+
A tuple containing:
|
|
156
|
+
- diconf (dict): The loaded TOML configuration dictionary
|
|
157
|
+
- dirname (str): The directory name if file is a string path, empty string otherwise
|
|
158
|
+
- filename (str): The filename if file is a string path, None otherwise
|
|
159
|
+
|
|
160
|
+
Raises
|
|
161
|
+
------
|
|
162
|
+
FileNotFoundError
|
|
163
|
+
If file is a string path and the file cannot be found.
|
|
164
|
+
RuntimeError
|
|
165
|
+
If file is BytesIO or StringIO and reading fails.
|
|
166
|
+
ValueError
|
|
167
|
+
If file is not one of the supported types.
|
|
168
|
+
"""
|
|
169
|
+
dirname = ""
|
|
170
|
+
filename = None
|
|
171
|
+
|
|
172
|
+
if isinstance(file, str):
|
|
173
|
+
filename = file
|
|
174
|
+
dirname = os.path.dirname(filename)
|
|
175
|
+
if not filename.endswith(".toml"):
|
|
176
|
+
filename = filename + ".toml"
|
|
177
|
+
|
|
178
|
+
try:
|
|
179
|
+
with open(filename, "r") as f:
|
|
180
|
+
diconf = toml.load(f)
|
|
181
|
+
except Exception as e:
|
|
182
|
+
raise FileNotFoundError(f"File {filename} not found: {e}") from e
|
|
183
|
+
elif isinstance(file, BytesIO):
|
|
184
|
+
try:
|
|
185
|
+
string = file.getvalue().decode("utf-8")
|
|
186
|
+
diconf = toml.loads(string)
|
|
187
|
+
except Exception as e:
|
|
188
|
+
raise RuntimeError(f"Cannot read from BytesIO: {e}") from e
|
|
189
|
+
elif isinstance(file, StringIO):
|
|
190
|
+
try:
|
|
191
|
+
string = file.getvalue()
|
|
192
|
+
diconf = toml.loads(string)
|
|
193
|
+
except Exception as e:
|
|
194
|
+
raise RuntimeError(f"Cannot read from StringIO: {e}") from e
|
|
195
|
+
else:
|
|
196
|
+
raise ValueError(f"Type {type(file)} not a valid type")
|
|
197
|
+
|
|
198
|
+
return diconf, dirname, filename
|
|
199
|
+
|
|
200
|
+
|
|
27
201
|
def saveConfig(myplan, file, mylog):
|
|
28
202
|
"""
|
|
29
203
|
Save case parameters and return a dictionary containing all parameters.
|
|
30
204
|
"""
|
|
31
205
|
|
|
32
206
|
diconf = {}
|
|
33
|
-
diconf["
|
|
34
|
-
diconf["
|
|
207
|
+
diconf["case_name"] = myplan._name
|
|
208
|
+
diconf["description"] = myplan._description
|
|
35
209
|
|
|
36
210
|
# Basic Info.
|
|
37
|
-
diconf["
|
|
38
|
-
"
|
|
39
|
-
"
|
|
40
|
-
"
|
|
41
|
-
"
|
|
42
|
-
"
|
|
211
|
+
diconf["basic_info"] = {
|
|
212
|
+
"status": ["unknown", "single", "married"][myplan.N_i],
|
|
213
|
+
"names": myplan.inames,
|
|
214
|
+
"date_of_birth": myplan.dobs,
|
|
215
|
+
"life_expectancy": myplan.expectancy.tolist(),
|
|
216
|
+
"start_date": myplan.startDate,
|
|
43
217
|
}
|
|
44
218
|
|
|
45
219
|
# Assets.
|
|
46
|
-
diconf["
|
|
220
|
+
diconf["savings_assets"] = {}
|
|
47
221
|
for j in range(myplan.N_j):
|
|
48
222
|
amounts = myplan.beta_ij[:, j] / 1000
|
|
49
|
-
|
|
223
|
+
# Map account type names to snake_case keys
|
|
224
|
+
account_key_map = {
|
|
225
|
+
"taxable": "taxable_savings_balances",
|
|
226
|
+
"tax-deferred": "tax_deferred_savings_balances",
|
|
227
|
+
"tax-free": "tax_free_savings_balances"
|
|
228
|
+
}
|
|
229
|
+
diconf["savings_assets"][account_key_map[AccountTypes[j]]] = amounts.tolist()
|
|
50
230
|
if myplan.N_i == 2:
|
|
51
|
-
diconf["
|
|
52
|
-
diconf["
|
|
231
|
+
diconf["savings_assets"]["beneficiary_fractions"] = myplan.phi_j.tolist()
|
|
232
|
+
diconf["savings_assets"]["spousal_surplus_deposit_fraction"] = myplan.eta
|
|
53
233
|
|
|
54
234
|
# Household Financial Profile
|
|
55
|
-
diconf["
|
|
235
|
+
diconf["household_financial_profile"] = {"HFP_file_name": myplan.timeListsFileName}
|
|
56
236
|
|
|
57
237
|
# Fixed Income.
|
|
58
|
-
diconf["
|
|
59
|
-
"
|
|
60
|
-
"
|
|
61
|
-
"
|
|
62
|
-
"
|
|
63
|
-
"
|
|
238
|
+
diconf["fixed_income"] = {
|
|
239
|
+
"pension_monthly_amounts": (myplan.pensionAmounts).tolist(),
|
|
240
|
+
"pension_ages": myplan.pensionAges.tolist(),
|
|
241
|
+
"pension_indexed": myplan.pensionIsIndexed,
|
|
242
|
+
"social_security_pia_amounts": (myplan.ssecAmounts).tolist(),
|
|
243
|
+
"social_security_ages": myplan.ssecAges.tolist(),
|
|
64
244
|
}
|
|
65
245
|
|
|
66
246
|
# Rates Selection.
|
|
67
|
-
diconf["
|
|
68
|
-
"
|
|
69
|
-
"
|
|
70
|
-
"
|
|
71
|
-
"
|
|
247
|
+
diconf["rates_selection"] = {
|
|
248
|
+
"heirs_rate_on_tax_deferred_estate": float(100 * myplan.nu),
|
|
249
|
+
"dividend_rate": float(100 * myplan.mu),
|
|
250
|
+
"obbba_expiration_year": myplan.yOBBBA,
|
|
251
|
+
"method": myplan.rateMethod,
|
|
72
252
|
}
|
|
253
|
+
# Store seed and reproducibility flag for stochastic methods
|
|
254
|
+
if myplan.rateMethod in ["stochastic", "histochastic"]:
|
|
255
|
+
if myplan.rateSeed is not None:
|
|
256
|
+
diconf["rates_selection"]["rate_seed"] = int(myplan.rateSeed)
|
|
257
|
+
diconf["rates_selection"]["reproducible_rates"] = bool(myplan.reproducibleRates)
|
|
73
258
|
if myplan.rateMethod in ["user", "stochastic"]:
|
|
74
|
-
diconf["
|
|
259
|
+
diconf["rates_selection"]["values"] = (100 * myplan.rateValues).tolist()
|
|
75
260
|
if myplan.rateMethod in ["stochastic"]:
|
|
76
|
-
diconf["
|
|
77
|
-
diconf["
|
|
261
|
+
diconf["rates_selection"]["standard_deviations"] = (100 * myplan.rateStdev).tolist()
|
|
262
|
+
diconf["rates_selection"]["correlations"] = myplan.rateCorr.tolist()
|
|
78
263
|
if myplan.rateMethod in ["historical average", "historical", "histochastic"]:
|
|
79
|
-
diconf["
|
|
80
|
-
diconf["
|
|
264
|
+
diconf["rates_selection"]["from"] = int(myplan.rateFrm)
|
|
265
|
+
diconf["rates_selection"]["to"] = int(myplan.rateTo)
|
|
81
266
|
else:
|
|
82
|
-
diconf["
|
|
83
|
-
diconf["
|
|
267
|
+
diconf["rates_selection"]["from"] = int(FROM)
|
|
268
|
+
diconf["rates_selection"]["to"] = int(TO)
|
|
84
269
|
|
|
85
270
|
# Asset Allocation.
|
|
86
|
-
diconf["
|
|
87
|
-
"
|
|
88
|
-
"
|
|
89
|
-
"
|
|
90
|
-
"
|
|
271
|
+
diconf["asset_allocation"] = {
|
|
272
|
+
"interpolation_method": myplan.interpMethod,
|
|
273
|
+
"interpolation_center": float(myplan.interpCenter),
|
|
274
|
+
"interpolation_width": float(myplan.interpWidth),
|
|
275
|
+
"type": myplan.ARCoord,
|
|
91
276
|
}
|
|
92
277
|
if myplan.ARCoord == "account":
|
|
93
278
|
for accType in AccountTypes:
|
|
94
|
-
diconf["
|
|
279
|
+
diconf["asset_allocation"][accType] = myplan.boundsAR[accType]
|
|
95
280
|
else:
|
|
96
|
-
diconf["
|
|
281
|
+
diconf["asset_allocation"]["generic"] = myplan.boundsAR["generic"]
|
|
97
282
|
|
|
98
283
|
# Optimization Parameters.
|
|
99
|
-
diconf["
|
|
100
|
-
"
|
|
101
|
-
"
|
|
284
|
+
diconf["optimization_parameters"] = {
|
|
285
|
+
"spending_profile": myplan.spendingProfile,
|
|
286
|
+
"surviving_spouse_spending_percent": int(100 * myplan.chi),
|
|
102
287
|
}
|
|
103
288
|
if myplan.spendingProfile == "smile":
|
|
104
|
-
diconf["
|
|
105
|
-
diconf["
|
|
106
|
-
diconf["
|
|
289
|
+
diconf["optimization_parameters"]["smile_dip"] = int(myplan.smileDip)
|
|
290
|
+
diconf["optimization_parameters"]["smile_increase"] = int(myplan.smileIncrease)
|
|
291
|
+
diconf["optimization_parameters"]["smile_delay"] = int(myplan.smileDelay)
|
|
107
292
|
|
|
108
|
-
diconf["
|
|
109
|
-
diconf["
|
|
293
|
+
diconf["optimization_parameters"]["objective"] = myplan.objective
|
|
294
|
+
diconf["solver_options"] = myplan.solverOptions
|
|
110
295
|
|
|
111
296
|
# Results.
|
|
112
|
-
diconf["
|
|
297
|
+
diconf["results"] = {"default_plots": myplan.defaultPlots}
|
|
113
298
|
|
|
114
299
|
if isinstance(file, str):
|
|
115
300
|
filename = file
|
|
@@ -146,62 +331,48 @@ def readConfig(file, *, verbose=True, logstreams=None, readContributions=True):
|
|
|
146
331
|
"""
|
|
147
332
|
mylog = log.Logger(verbose, logstreams)
|
|
148
333
|
|
|
149
|
-
dirname =
|
|
150
|
-
if isinstance(file, str):
|
|
151
|
-
filename = file
|
|
152
|
-
dirname = os.path.dirname(filename)
|
|
153
|
-
if not filename.endswith(".toml"):
|
|
154
|
-
filename = filename + ".toml"
|
|
334
|
+
diconf, dirname, filename = _read_toml_file(file)
|
|
155
335
|
|
|
336
|
+
if filename is not None:
|
|
156
337
|
mylog.vprint(f"Reading plan from case file '{filename}'.")
|
|
157
338
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
diconf = toml.load(f)
|
|
161
|
-
except Exception as e:
|
|
162
|
-
raise FileNotFoundError(f"File {filename} not found: {e}") from e
|
|
163
|
-
elif isinstance(file, BytesIO):
|
|
164
|
-
try:
|
|
165
|
-
string = file.getvalue().decode("utf-8")
|
|
166
|
-
diconf = toml.loads(string)
|
|
167
|
-
except Exception as e:
|
|
168
|
-
raise RuntimeError(f"Cannot read from BytesIO: {e}") from e
|
|
169
|
-
elif isinstance(file, StringIO):
|
|
170
|
-
try:
|
|
171
|
-
string = file.getvalue()
|
|
172
|
-
diconf = toml.loads(string)
|
|
173
|
-
except Exception as e:
|
|
174
|
-
raise RuntimeError(f"Cannot read from StringIO: {e}") from e
|
|
175
|
-
else:
|
|
176
|
-
raise ValueError(f"Type {type(file)} not a valid type")
|
|
339
|
+
# Translate old keys to new snake_case keys for backward compatibility
|
|
340
|
+
diconf = translate_old_keys(diconf)
|
|
177
341
|
|
|
178
342
|
# Basic Info.
|
|
179
|
-
name = diconf["
|
|
180
|
-
inames = diconf["
|
|
343
|
+
name = diconf["case_name"]
|
|
344
|
+
inames = diconf["basic_info"]["names"]
|
|
181
345
|
icount = len(inames)
|
|
182
346
|
# Default to January 15, 1965 if no entry is found.
|
|
183
|
-
dobs = diconf["
|
|
184
|
-
expectancy = diconf["
|
|
347
|
+
dobs = diconf["basic_info"].get("date_of_birth", ["1965-01-15"]*icount)
|
|
348
|
+
expectancy = diconf["basic_info"]["life_expectancy"]
|
|
185
349
|
s = ["", "s"][icount - 1]
|
|
186
350
|
mylog.vprint(f"Plan for {icount} individual{s}: {inames}.")
|
|
187
351
|
p = plan.Plan(inames, dobs, expectancy, name, verbose=True, logstreams=logstreams)
|
|
188
|
-
p._description = diconf.get("
|
|
352
|
+
p._description = diconf.get("description", "")
|
|
189
353
|
|
|
190
354
|
# Assets.
|
|
191
|
-
startDate = diconf["
|
|
355
|
+
startDate = diconf["basic_info"].get("start_date", "today")
|
|
192
356
|
balances = {}
|
|
357
|
+
# Map account type names to snake_case keys
|
|
358
|
+
account_key_map = {
|
|
359
|
+
"taxable": "taxable_savings_balances",
|
|
360
|
+
"tax-deferred": "tax_deferred_savings_balances",
|
|
361
|
+
"tax-free": "tax_free_savings_balances"
|
|
362
|
+
}
|
|
193
363
|
for acc in AccountTypes:
|
|
194
|
-
balances[acc] = diconf["
|
|
364
|
+
balances[acc] = diconf["savings_assets"][account_key_map[acc]]
|
|
195
365
|
p.setAccountBalances(taxable=balances["taxable"], taxDeferred=balances["tax-deferred"],
|
|
196
366
|
taxFree=balances["tax-free"], startDate=startDate)
|
|
197
367
|
if icount == 2:
|
|
198
|
-
phi_j = diconf["
|
|
368
|
+
phi_j = diconf["savings_assets"]["beneficiary_fractions"]
|
|
199
369
|
p.setBeneficiaryFractions(phi_j)
|
|
200
|
-
eta = diconf["
|
|
370
|
+
eta = diconf["savings_assets"]["spousal_surplus_deposit_fraction"]
|
|
201
371
|
p.setSpousalDepositFraction(eta)
|
|
202
372
|
|
|
203
373
|
# Household Financial Profile
|
|
204
|
-
|
|
374
|
+
hfp_section = diconf.get("household_financial_profile", {})
|
|
375
|
+
timeListsFileName = hfp_section.get("HFP_file_name", "None")
|
|
205
376
|
if timeListsFileName != "None":
|
|
206
377
|
if readContributions:
|
|
207
378
|
if os.path.exists(timeListsFileName):
|
|
@@ -216,50 +387,59 @@ def readConfig(file, *, verbose=True, logstreams=None, readContributions=True):
|
|
|
216
387
|
mylog.vprint(f"Ignoring to read contributions file {timeListsFileName}.")
|
|
217
388
|
|
|
218
389
|
# Fixed Income.
|
|
219
|
-
ssecAmounts = np.array(diconf["
|
|
220
|
-
ssecAges = np.array(diconf["
|
|
390
|
+
ssecAmounts = np.array(diconf["fixed_income"].get("social_security_pia_amounts", [0]*icount), dtype=np.int32)
|
|
391
|
+
ssecAges = np.array(diconf["fixed_income"]["social_security_ages"])
|
|
221
392
|
p.setSocialSecurity(ssecAmounts, ssecAges)
|
|
222
|
-
pensionAmounts = np.array(diconf["
|
|
223
|
-
pensionAges = np.array(diconf["
|
|
224
|
-
pensionIsIndexed = diconf["
|
|
393
|
+
pensionAmounts = np.array(diconf["fixed_income"].get("pension_monthly_amounts", [0]*icount), dtype=np.float32)
|
|
394
|
+
pensionAges = np.array(diconf["fixed_income"]["pension_ages"])
|
|
395
|
+
pensionIsIndexed = diconf["fixed_income"]["pension_indexed"]
|
|
225
396
|
p.setPension(pensionAmounts, pensionAges, pensionIsIndexed)
|
|
226
397
|
|
|
227
398
|
# Rates Selection.
|
|
228
|
-
p.setDividendRate(float(diconf["
|
|
229
|
-
p.setHeirsTaxRate(float(diconf["
|
|
230
|
-
p.yOBBBA = int(diconf["
|
|
399
|
+
p.setDividendRate(float(diconf["rates_selection"].get("dividend_rate", 1.8))) # Fix for mod.
|
|
400
|
+
p.setHeirsTaxRate(float(diconf["rates_selection"]["heirs_rate_on_tax_deferred_estate"]))
|
|
401
|
+
p.yOBBBA = int(diconf["rates_selection"].get("obbba_expiration_year", 2032))
|
|
231
402
|
|
|
232
403
|
frm = None
|
|
233
404
|
to = None
|
|
234
405
|
rateValues = None
|
|
235
406
|
stdev = None
|
|
236
407
|
rateCorr = None
|
|
237
|
-
|
|
408
|
+
rateSeed = None
|
|
409
|
+
reproducibleRates = False
|
|
410
|
+
rateMethod = diconf["rates_selection"]["method"]
|
|
238
411
|
if rateMethod in ["historical average", "historical", "histochastic"]:
|
|
239
|
-
frm = diconf["
|
|
412
|
+
frm = diconf["rates_selection"]["from"]
|
|
240
413
|
if not isinstance(frm, int):
|
|
241
414
|
frm = int(frm)
|
|
242
|
-
to = diconf["
|
|
415
|
+
to = diconf["rates_selection"]["to"]
|
|
243
416
|
if not isinstance(to, int):
|
|
244
417
|
to = int(to)
|
|
245
418
|
if rateMethod in ["user", "stochastic"]:
|
|
246
|
-
rateValues = np.array(diconf["
|
|
419
|
+
rateValues = np.array(diconf["rates_selection"]["values"], dtype=np.float64)
|
|
247
420
|
if rateMethod in ["stochastic"]:
|
|
248
|
-
stdev = np.array(diconf["
|
|
249
|
-
rateCorr = np.array(diconf["
|
|
421
|
+
stdev = np.array(diconf["rates_selection"]["standard_deviations"], dtype=np.float64)
|
|
422
|
+
rateCorr = np.array(diconf["rates_selection"]["correlations"], dtype=np.float64)
|
|
423
|
+
# Load seed and reproducibility flag for stochastic methods
|
|
424
|
+
if rateMethod in ["stochastic", "histochastic"]:
|
|
425
|
+
rateSeed = diconf["rates_selection"].get("rate_seed")
|
|
426
|
+
if rateSeed is not None:
|
|
427
|
+
rateSeed = int(rateSeed)
|
|
428
|
+
reproducibleRates = diconf["rates_selection"].get("reproducible_rates", False)
|
|
429
|
+
p.setReproducible(reproducibleRates, seed=rateSeed)
|
|
250
430
|
p.setRates(rateMethod, frm, to, rateValues, stdev, rateCorr)
|
|
251
431
|
|
|
252
432
|
# Asset Allocation.
|
|
253
433
|
boundsAR = {}
|
|
254
434
|
p.setInterpolationMethod(
|
|
255
|
-
diconf["
|
|
256
|
-
float(diconf["
|
|
257
|
-
float(diconf["
|
|
435
|
+
diconf["asset_allocation"]["interpolation_method"],
|
|
436
|
+
float(diconf["asset_allocation"]["interpolation_center"]),
|
|
437
|
+
float(diconf["asset_allocation"]["interpolation_width"]),
|
|
258
438
|
)
|
|
259
|
-
allocType = diconf["
|
|
439
|
+
allocType = diconf["asset_allocation"]["type"]
|
|
260
440
|
if allocType == "account":
|
|
261
441
|
for aType in AccountTypes:
|
|
262
|
-
boundsAR[aType] = np.array(diconf["
|
|
442
|
+
boundsAR[aType] = np.array(diconf["asset_allocation"][aType], dtype=np.float64)
|
|
263
443
|
|
|
264
444
|
p.setAllocationRatios(
|
|
265
445
|
allocType,
|
|
@@ -268,7 +448,7 @@ def readConfig(file, *, verbose=True, logstreams=None, readContributions=True):
|
|
|
268
448
|
taxFree=boundsAR["tax-free"],
|
|
269
449
|
)
|
|
270
450
|
elif allocType == "individual" or allocType == "spouses":
|
|
271
|
-
boundsAR["generic"] = np.array(diconf["
|
|
451
|
+
boundsAR["generic"] = np.array(diconf["asset_allocation"]["generic"], dtype=np.float64)
|
|
272
452
|
p.setAllocationRatios(
|
|
273
453
|
allocType,
|
|
274
454
|
generic=boundsAR["generic"],
|
|
@@ -277,14 +457,14 @@ def readConfig(file, *, verbose=True, logstreams=None, readContributions=True):
|
|
|
277
457
|
raise ValueError(f"Unknown asset allocation type {allocType}.")
|
|
278
458
|
|
|
279
459
|
# Optimization Parameters.
|
|
280
|
-
p.objective = diconf["
|
|
460
|
+
p.objective = diconf["optimization_parameters"]["objective"]
|
|
281
461
|
|
|
282
|
-
profile = diconf["
|
|
283
|
-
survivor = int(diconf["
|
|
462
|
+
profile = diconf["optimization_parameters"]["spending_profile"]
|
|
463
|
+
survivor = int(diconf["optimization_parameters"]["surviving_spouse_spending_percent"])
|
|
284
464
|
if profile == "smile":
|
|
285
|
-
dip = int(diconf["
|
|
286
|
-
increase = int(diconf["
|
|
287
|
-
delay = int(diconf["
|
|
465
|
+
dip = int(diconf["optimization_parameters"]["smile_dip"])
|
|
466
|
+
increase = int(diconf["optimization_parameters"]["smile_increase"])
|
|
467
|
+
delay = int(diconf["optimization_parameters"]["smile_delay"])
|
|
288
468
|
else:
|
|
289
469
|
dip = 15
|
|
290
470
|
increase = 12
|
|
@@ -293,23 +473,26 @@ def readConfig(file, *, verbose=True, logstreams=None, readContributions=True):
|
|
|
293
473
|
p.setSpendingProfile(profile, survivor, dip, increase, delay)
|
|
294
474
|
|
|
295
475
|
# Solver Options.
|
|
296
|
-
p.solverOptions = diconf["
|
|
476
|
+
p.solverOptions = diconf["solver_options"]
|
|
297
477
|
|
|
298
478
|
# Address legacy case files.
|
|
299
|
-
|
|
300
|
-
|
|
479
|
+
# Convert boolean values (True/False) to string format, but preserve string values
|
|
480
|
+
withMedicare = diconf["solver_options"].get("withMedicare")
|
|
481
|
+
if isinstance(withMedicare, bool):
|
|
482
|
+
p.solverOptions["withMedicare"] = "loop" if withMedicare else "None"
|
|
301
483
|
|
|
302
484
|
# Check consistency of noRothConversions.
|
|
303
485
|
name = p.solverOptions.get("noRothConversions", "None")
|
|
304
486
|
if name != "None" and name not in p.inames:
|
|
305
487
|
raise ValueError(f"Unknown name {name} for noRothConversions.")
|
|
306
488
|
|
|
307
|
-
# Rebase startRothConversions on year change.
|
|
489
|
+
# Rebase startRothConversions and yOBBBA on year change.
|
|
308
490
|
thisyear = date.today().year
|
|
309
491
|
year = p.solverOptions.get("startRothConversions", thisyear)
|
|
310
492
|
p.solverOptions["startRothConversions"] = max(year, thisyear)
|
|
493
|
+
p.yOBBBA = max(p.yOBBBA, thisyear)
|
|
311
494
|
|
|
312
495
|
# Results.
|
|
313
|
-
p.setDefaultPlots(diconf["
|
|
496
|
+
p.setDefaultPlots(diconf["results"]["default_plots"])
|
|
314
497
|
|
|
315
498
|
return p
|