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.
@@ -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
- Owl/conftoml
4
+ This module provides utility functions to save and load retirement planning
5
+ case parameters in TOML format.
4
6
 
5
- This file contains utility functions to save case parameters.
7
+ Copyright (C) 2025-2026 The Owlplanner Authors
6
8
 
7
- Copyright &copy; 2024 - Martin-D. Lacasse
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
- Disclaimers: This code is for educational purposes only and does not constitute financial advice.
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["Plan Name"] = myplan._name
34
- diconf["Description"] = myplan._description
207
+ diconf["case_name"] = myplan._name
208
+ diconf["description"] = myplan._description
35
209
 
36
210
  # Basic Info.
37
- diconf["Basic Info"] = {
38
- "Status": ["unknown", "single", "married"][myplan.N_i],
39
- "Names": myplan.inames,
40
- "Date of birth": myplan.dobs,
41
- "Life expectancy": myplan.expectancy.tolist(),
42
- "Start date": myplan.startDate,
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["Assets"] = {}
220
+ diconf["savings_assets"] = {}
47
221
  for j in range(myplan.N_j):
48
222
  amounts = myplan.beta_ij[:, j] / 1000
49
- diconf["Assets"][f"{AccountTypes[j]} savings balances"] = amounts.tolist()
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["Assets"]["Beneficiary fractions"] = myplan.phi_j.tolist()
52
- diconf["Assets"]["Spousal surplus deposit fraction"] = myplan.eta
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["Household Financial Profile"] = {"HFP file name": myplan.timeListsFileName}
235
+ diconf["household_financial_profile"] = {"HFP_file_name": myplan.timeListsFileName}
56
236
 
57
237
  # Fixed Income.
58
- diconf["Fixed Income"] = {
59
- "Pension monthly amounts": (myplan.pensionAmounts).tolist(),
60
- "Pension ages": myplan.pensionAges.tolist(),
61
- "Pension indexed": myplan.pensionIsIndexed,
62
- "Social security PIA amounts": (myplan.ssecAmounts).tolist(),
63
- "Social security ages": myplan.ssecAges.tolist(),
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["Rates Selection"] = {
68
- "Heirs rate on tax-deferred estate": float(100 * myplan.nu),
69
- "Dividend rate": float(100 * myplan.mu),
70
- "OBBBA expiration year": myplan.yOBBBA,
71
- "Method": myplan.rateMethod,
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["Rates Selection"]["Values"] = (100 * myplan.rateValues).tolist()
259
+ diconf["rates_selection"]["values"] = (100 * myplan.rateValues).tolist()
75
260
  if myplan.rateMethod in ["stochastic"]:
76
- diconf["Rates Selection"]["Standard deviations"] = (100 * myplan.rateStdev).tolist()
77
- diconf["Rates Selection"]["Correlations"] = myplan.rateCorr.tolist()
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["Rates Selection"]["From"] = int(myplan.rateFrm)
80
- diconf["Rates Selection"]["To"] = int(myplan.rateTo)
264
+ diconf["rates_selection"]["from"] = int(myplan.rateFrm)
265
+ diconf["rates_selection"]["to"] = int(myplan.rateTo)
81
266
  else:
82
- diconf["Rates Selection"]["From"] = int(FROM)
83
- diconf["Rates Selection"]["To"] = int(TO)
267
+ diconf["rates_selection"]["from"] = int(FROM)
268
+ diconf["rates_selection"]["to"] = int(TO)
84
269
 
85
270
  # Asset Allocation.
86
- diconf["Asset Allocation"] = {
87
- "Interpolation method": myplan.interpMethod,
88
- "Interpolation center": float(myplan.interpCenter),
89
- "Interpolation width": float(myplan.interpWidth),
90
- "Type": myplan.ARCoord,
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["Asset Allocation"][accType] = myplan.boundsAR[accType]
279
+ diconf["asset_allocation"][accType] = myplan.boundsAR[accType]
95
280
  else:
96
- diconf["Asset Allocation"]["generic"] = myplan.boundsAR["generic"]
281
+ diconf["asset_allocation"]["generic"] = myplan.boundsAR["generic"]
97
282
 
98
283
  # Optimization Parameters.
99
- diconf["Optimization Parameters"] = {
100
- "Spending profile": myplan.spendingProfile,
101
- "Surviving spouse spending percent": int(100 * myplan.chi),
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["Optimization Parameters"]["Smile dip"] = int(myplan.smileDip)
105
- diconf["Optimization Parameters"]["Smile increase"] = int(myplan.smileIncrease)
106
- diconf["Optimization Parameters"]["Smile delay"] = int(myplan.smileDelay)
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["Optimization Parameters"]["Objective"] = myplan.objective
109
- diconf["Solver Options"] = myplan.solverOptions
293
+ diconf["optimization_parameters"]["objective"] = myplan.objective
294
+ diconf["solver_options"] = myplan.solverOptions
110
295
 
111
296
  # Results.
112
- diconf["Results"] = {"Default plots": myplan.defaultPlots}
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
- try:
159
- with open(filename, "r") as f:
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["Plan Name"]
180
- inames = diconf["Basic Info"]["Names"]
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["Basic Info"].get("Date of birth", ["1965-01-15"]*icount)
184
- expectancy = diconf["Basic Info"]["Life expectancy"]
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("Description", "")
352
+ p._description = diconf.get("description", "")
189
353
 
190
354
  # Assets.
191
- startDate = diconf["Basic Info"].get("Start date", "today")
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["Assets"][f"{acc} savings balances"]
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["Assets"]["Beneficiary fractions"]
368
+ phi_j = diconf["savings_assets"]["beneficiary_fractions"]
199
369
  p.setBeneficiaryFractions(phi_j)
200
- eta = diconf["Assets"]["Spousal surplus deposit fraction"]
370
+ eta = diconf["savings_assets"]["spousal_surplus_deposit_fraction"]
201
371
  p.setSpousalDepositFraction(eta)
202
372
 
203
373
  # Household Financial Profile
204
- timeListsFileName = diconf["Household Financial Profile"]["HFP file name"]
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["Fixed Income"].get("Social security PIA amounts", [0]*icount), dtype=np.int32)
220
- ssecAges = np.array(diconf["Fixed Income"]["Social security ages"])
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["Fixed Income"].get("Pension monthly amounts", [0]*icount), dtype=np.float32)
223
- pensionAges = np.array(diconf["Fixed Income"]["Pension ages"])
224
- pensionIsIndexed = diconf["Fixed Income"]["Pension indexed"]
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["Rates Selection"].get("Dividend rate", 1.8))) # Fix for mod.
229
- p.setHeirsTaxRate(float(diconf["Rates Selection"]["Heirs rate on tax-deferred estate"]))
230
- p.yOBBBA = int(diconf["Rates Selection"].get("OBBBA expiration year", 2032))
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
- rateMethod = diconf["Rates Selection"]["Method"]
408
+ rateSeed = None
409
+ reproducibleRates = False
410
+ rateMethod = diconf["rates_selection"]["method"]
238
411
  if rateMethod in ["historical average", "historical", "histochastic"]:
239
- frm = diconf["Rates Selection"]["From"]
412
+ frm = diconf["rates_selection"]["from"]
240
413
  if not isinstance(frm, int):
241
414
  frm = int(frm)
242
- to = diconf["Rates Selection"]["To"]
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["Rates Selection"]["Values"], dtype=np.float32)
419
+ rateValues = np.array(diconf["rates_selection"]["values"], dtype=np.float64)
247
420
  if rateMethod in ["stochastic"]:
248
- stdev = np.array(diconf["Rates Selection"]["Standard deviations"], dtype=np.float32)
249
- rateCorr = np.array(diconf["Rates Selection"]["Correlations"], dtype=np.float32)
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["Asset Allocation"]["Interpolation method"],
256
- float(diconf["Asset Allocation"]["Interpolation center"]),
257
- float(diconf["Asset Allocation"]["Interpolation width"]),
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["Asset Allocation"]["Type"]
439
+ allocType = diconf["asset_allocation"]["type"]
260
440
  if allocType == "account":
261
441
  for aType in AccountTypes:
262
- boundsAR[aType] = np.array(diconf["Asset Allocation"][aType], dtype=np.float32)
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["Asset Allocation"]["generic"], dtype=np.float32)
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["Optimization Parameters"]["Objective"]
460
+ p.objective = diconf["optimization_parameters"]["objective"]
281
461
 
282
- profile = diconf["Optimization Parameters"]["Spending profile"]
283
- survivor = int(diconf["Optimization Parameters"]["Surviving spouse spending percent"])
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["Optimization Parameters"]["Smile dip"])
286
- increase = int(diconf["Optimization Parameters"]["Smile increase"])
287
- delay = int(diconf["Optimization Parameters"]["Smile delay"])
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["Solver Options"]
476
+ p.solverOptions = diconf["solver_options"]
297
477
 
298
478
  # Address legacy case files.
299
- if diconf["Solver Options"].get("withMedicare"):
300
- p.solverOptions["withMedicare"] = "loop"
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["Results"]["Default plots"])
496
+ p.setDefaultPlots(diconf["results"]["default_plots"])
314
497
 
315
498
  return p