owlplanner 2025.12.5__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.
Files changed (38) hide show
  1. owlplanner/In Discussion #58, the case of Kim and Sam.md +307 -0
  2. owlplanner/__init__.py +20 -1
  3. owlplanner/abcapi.py +24 -23
  4. owlplanner/cli/README.md +50 -0
  5. owlplanner/cli/_main.py +52 -0
  6. owlplanner/cli/cli_logging.py +56 -0
  7. owlplanner/cli/cmd_list.py +83 -0
  8. owlplanner/cli/cmd_run.py +86 -0
  9. owlplanner/config.py +315 -136
  10. owlplanner/data/__init__.py +21 -0
  11. owlplanner/data/awi.csv +75 -0
  12. owlplanner/data/bendpoints.csv +49 -0
  13. owlplanner/data/newawi.csv +75 -0
  14. owlplanner/data/rates.csv +99 -98
  15. owlplanner/debts.py +315 -0
  16. owlplanner/fixedassets.py +288 -0
  17. owlplanner/mylogging.py +157 -25
  18. owlplanner/plan.py +1044 -332
  19. owlplanner/plotting/__init__.py +16 -3
  20. owlplanner/plotting/base.py +17 -3
  21. owlplanner/plotting/factory.py +16 -3
  22. owlplanner/plotting/matplotlib_backend.py +30 -7
  23. owlplanner/plotting/plotly_backend.py +33 -10
  24. owlplanner/progress.py +66 -9
  25. owlplanner/rates.py +366 -361
  26. owlplanner/socialsecurity.py +142 -22
  27. owlplanner/tax2026.py +170 -57
  28. owlplanner/timelists.py +316 -32
  29. owlplanner/utils.py +204 -5
  30. owlplanner/version.py +20 -1
  31. {owlplanner-2025.12.5.dist-info → owlplanner-2026.1.26.dist-info}/METADATA +50 -158
  32. owlplanner-2026.1.26.dist-info/RECORD +36 -0
  33. owlplanner-2026.1.26.dist-info/entry_points.txt +2 -0
  34. owlplanner-2026.1.26.dist-info/licenses/AUTHORS +15 -0
  35. owlplanner/tax2025.py +0 -339
  36. owlplanner-2025.12.5.dist-info/RECORD +0 -24
  37. {owlplanner-2025.12.5.dist-info → owlplanner-2026.1.26.dist-info}/WHEEL +0 -0
  38. {owlplanner-2025.12.5.dist-info → owlplanner-2026.1.26.dist-info}/licenses/LICENSE +0 -0
owlplanner/config.py CHANGED
@@ -1,16 +1,26 @@
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 © 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
- import toml as toml
23
+ import toml
14
24
  from io import StringIO, BytesIO
15
25
  import numpy as np
16
26
  from datetime import date
@@ -21,95 +31,270 @@ from owlplanner import mylogging as log
21
31
  from owlplanner.rates import FROM, TO
22
32
 
23
33
 
34
+ AccountTypes = ["taxable", "tax-deferred", "tax-free"]
35
+
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
+
24
201
  def saveConfig(myplan, file, mylog):
25
202
  """
26
203
  Save case parameters and return a dictionary containing all parameters.
27
204
  """
28
- # np.set_printoptions(legacy='1.21')
29
- accountTypes = ["taxable", "tax-deferred", "tax-free"]
30
205
 
31
206
  diconf = {}
32
- diconf["Plan Name"] = myplan._name
33
- diconf["Description"] = myplan._description
207
+ diconf["case_name"] = myplan._name
208
+ diconf["description"] = myplan._description
34
209
 
35
210
  # Basic Info.
36
- diconf["Basic Info"] = {
37
- "Status": ["unknown", "single", "married"][myplan.N_i],
38
- "Names": myplan.inames,
39
- "Birth year": myplan.yobs.tolist(),
40
- "Birth month": myplan.mobs.tolist(),
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
- # Wages and Contributions.
55
- diconf["Wages and Contributions"] = {"Contributions file name": myplan.timeListsFileName}
234
+ # Household Financial Profile
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
- for accType in accountTypes:
94
- diconf["Asset Allocation"][accType] = myplan.boundsAR[accType]
278
+ for accType in AccountTypes:
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,72 +331,54 @@ def readConfig(file, *, verbose=True, logstreams=None, readContributions=True):
146
331
  """
147
332
  mylog = log.Logger(verbose, logstreams)
148
333
 
149
- accountTypes = ["taxable", "tax-deferred", "tax-free"]
150
-
151
- dirname = ""
152
- if isinstance(file, str):
153
- filename = file
154
- dirname = os.path.dirname(filename)
155
- if not filename.endswith(".toml"):
156
- filename = filename + ".toml"
334
+ diconf, dirname, filename = _read_toml_file(file)
157
335
 
336
+ if filename is not None:
158
337
  mylog.vprint(f"Reading plan from case file '{filename}'.")
159
338
 
160
- try:
161
- with open(filename, "r") as f:
162
- diconf = toml.load(f)
163
- except Exception as e:
164
- raise FileNotFoundError(f"File {filename} not found: {e}") from e
165
- elif isinstance(file, BytesIO):
166
- try:
167
- string = file.getvalue().decode("utf-8")
168
- diconf = toml.loads(string)
169
- except Exception as e:
170
- raise RuntimeError(f"Cannot read from BytesIO: {e}") from e
171
- elif isinstance(file, StringIO):
172
- try:
173
- string = file.getvalue()
174
- diconf = toml.loads(string)
175
- except Exception as e:
176
- raise RuntimeError(f"Cannot read from StringIO: {e}") from e
177
- else:
178
- 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)
179
341
 
180
342
  # Basic Info.
181
- name = diconf["Plan Name"]
182
- inames = diconf["Basic Info"]["Names"]
183
- # status = diconf['Basic Info']['Status']
184
- yobs = diconf["Basic Info"]["Birth year"]
185
- icount = len(yobs)
186
- # Default to January if no month entry found.
187
- mobs = diconf["Basic Info"].get("Birth month", [1]*icount)
188
- expectancy = diconf["Basic Info"]["Life expectancy"]
343
+ name = diconf["case_name"]
344
+ inames = diconf["basic_info"]["names"]
345
+ icount = len(inames)
346
+ # Default to January 15, 1965 if no entry is found.
347
+ dobs = diconf["basic_info"].get("date_of_birth", ["1965-01-15"]*icount)
348
+ expectancy = diconf["basic_info"]["life_expectancy"]
189
349
  s = ["", "s"][icount - 1]
190
350
  mylog.vprint(f"Plan for {icount} individual{s}: {inames}.")
191
- p = plan.Plan(inames, yobs, mobs, expectancy, name, verbose=True, logstreams=logstreams)
192
- p._description = diconf.get("Description", "")
351
+ p = plan.Plan(inames, dobs, expectancy, name, verbose=True, logstreams=logstreams)
352
+ p._description = diconf.get("description", "")
193
353
 
194
354
  # Assets.
195
- startDate = diconf["Basic Info"].get("Start date", "today")
355
+ startDate = diconf["basic_info"].get("start_date", "today")
196
356
  balances = {}
197
- for acc in accountTypes:
198
- balances[acc] = diconf["Assets"][f"{acc} savings 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
+ }
363
+ for acc in AccountTypes:
364
+ balances[acc] = diconf["savings_assets"][account_key_map[acc]]
199
365
  p.setAccountBalances(taxable=balances["taxable"], taxDeferred=balances["tax-deferred"],
200
366
  taxFree=balances["tax-free"], startDate=startDate)
201
367
  if icount == 2:
202
- phi_j = diconf["Assets"]["Beneficiary fractions"]
368
+ phi_j = diconf["savings_assets"]["beneficiary_fractions"]
203
369
  p.setBeneficiaryFractions(phi_j)
204
- eta = diconf["Assets"]["Spousal surplus deposit fraction"]
370
+ eta = diconf["savings_assets"]["spousal_surplus_deposit_fraction"]
205
371
  p.setSpousalDepositFraction(eta)
206
372
 
207
- # Wages and Contributions.
208
- timeListsFileName = diconf["Wages and Contributions"]["Contributions file name"]
373
+ # Household Financial Profile
374
+ hfp_section = diconf.get("household_financial_profile", {})
375
+ timeListsFileName = hfp_section.get("HFP_file_name", "None")
209
376
  if timeListsFileName != "None":
210
377
  if readContributions:
211
378
  if os.path.exists(timeListsFileName):
212
379
  myfile = timeListsFileName
213
- elif dirname != "" and os.path.exists(dirname + "/" + timeListsFileName):
214
- myfile = dirname + "/" + timeListsFileName
380
+ elif dirname != "" and os.path.exists(os.path.join(dirname, timeListsFileName)):
381
+ myfile = os.path.join(dirname, timeListsFileName)
215
382
  else:
216
383
  raise FileNotFoundError(f"File '{timeListsFileName}' not found.")
217
384
  p.readContributions(myfile)
@@ -220,50 +387,59 @@ def readConfig(file, *, verbose=True, logstreams=None, readContributions=True):
220
387
  mylog.vprint(f"Ignoring to read contributions file {timeListsFileName}.")
221
388
 
222
389
  # Fixed Income.
223
- ssecAmounts = np.array(diconf["Fixed Income"].get("Social security PIA amounts", [0]*icount), dtype=np.int32)
224
- 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"])
225
392
  p.setSocialSecurity(ssecAmounts, ssecAges)
226
- pensionAmounts = np.array(diconf["Fixed Income"].get("Pension monthly amounts", [0]*icount), dtype=np.float32)
227
- pensionAges = np.array(diconf["Fixed Income"]["Pension ages"])
228
- 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"]
229
396
  p.setPension(pensionAmounts, pensionAges, pensionIsIndexed)
230
397
 
231
398
  # Rates Selection.
232
- p.setDividendRate(float(diconf["Rates Selection"].get("Dividend rate", 1.8))) # Fix for mod.
233
- p.setHeirsTaxRate(float(diconf["Rates Selection"]["Heirs rate on tax-deferred estate"]))
234
- 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))
235
402
 
236
403
  frm = None
237
404
  to = None
238
405
  rateValues = None
239
406
  stdev = None
240
407
  rateCorr = None
241
- rateMethod = diconf["Rates Selection"]["Method"]
408
+ rateSeed = None
409
+ reproducibleRates = False
410
+ rateMethod = diconf["rates_selection"]["method"]
242
411
  if rateMethod in ["historical average", "historical", "histochastic"]:
243
- frm = diconf["Rates Selection"]["From"]
412
+ frm = diconf["rates_selection"]["from"]
244
413
  if not isinstance(frm, int):
245
414
  frm = int(frm)
246
- to = int(diconf["Rates Selection"]["To"])
415
+ to = diconf["rates_selection"]["to"]
247
416
  if not isinstance(to, int):
248
417
  to = int(to)
249
418
  if rateMethod in ["user", "stochastic"]:
250
- rateValues = np.array(diconf["Rates Selection"]["Values"], dtype=np.float32)
419
+ rateValues = np.array(diconf["rates_selection"]["values"], dtype=np.float64)
251
420
  if rateMethod in ["stochastic"]:
252
- stdev = np.array(diconf["Rates Selection"]["Standard deviations"], dtype=np.float32)
253
- 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)
254
430
  p.setRates(rateMethod, frm, to, rateValues, stdev, rateCorr)
255
431
 
256
432
  # Asset Allocation.
257
433
  boundsAR = {}
258
434
  p.setInterpolationMethod(
259
- diconf["Asset Allocation"]["Interpolation method"],
260
- float(diconf["Asset Allocation"]["Interpolation center"]),
261
- 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"]),
262
438
  )
263
- allocType = diconf["Asset Allocation"]["Type"]
439
+ allocType = diconf["asset_allocation"]["type"]
264
440
  if allocType == "account":
265
- for aType in accountTypes:
266
- boundsAR[aType] = np.array(diconf["Asset Allocation"][aType], dtype=np.float32)
441
+ for aType in AccountTypes:
442
+ boundsAR[aType] = np.array(diconf["asset_allocation"][aType], dtype=np.float64)
267
443
 
268
444
  p.setAllocationRatios(
269
445
  allocType,
@@ -272,7 +448,7 @@ def readConfig(file, *, verbose=True, logstreams=None, readContributions=True):
272
448
  taxFree=boundsAR["tax-free"],
273
449
  )
274
450
  elif allocType == "individual" or allocType == "spouses":
275
- boundsAR["generic"] = np.array(diconf["Asset Allocation"]["generic"], dtype=np.float32)
451
+ boundsAR["generic"] = np.array(diconf["asset_allocation"]["generic"], dtype=np.float64)
276
452
  p.setAllocationRatios(
277
453
  allocType,
278
454
  generic=boundsAR["generic"],
@@ -281,14 +457,14 @@ def readConfig(file, *, verbose=True, logstreams=None, readContributions=True):
281
457
  raise ValueError(f"Unknown asset allocation type {allocType}.")
282
458
 
283
459
  # Optimization Parameters.
284
- p.objective = diconf["Optimization Parameters"]["Objective"]
460
+ p.objective = diconf["optimization_parameters"]["objective"]
285
461
 
286
- profile = diconf["Optimization Parameters"]["Spending profile"]
287
- 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"])
288
464
  if profile == "smile":
289
- dip = int(diconf["Optimization Parameters"]["Smile dip"])
290
- increase = int(diconf["Optimization Parameters"]["Smile increase"])
291
- 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"])
292
468
  else:
293
469
  dip = 15
294
470
  increase = 12
@@ -297,23 +473,26 @@ def readConfig(file, *, verbose=True, logstreams=None, readContributions=True):
297
473
  p.setSpendingProfile(profile, survivor, dip, increase, delay)
298
474
 
299
475
  # Solver Options.
300
- p.solverOptions = diconf["Solver Options"]
476
+ p.solverOptions = diconf["solver_options"]
301
477
 
302
478
  # Address legacy case files.
303
- if diconf["Solver Options"].get("withMedicare", None) is True:
304
- 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"
305
483
 
306
484
  # Check consistency of noRothConversions.
307
485
  name = p.solverOptions.get("noRothConversions", "None")
308
486
  if name != "None" and name not in p.inames:
309
487
  raise ValueError(f"Unknown name {name} for noRothConversions.")
310
488
 
311
- # Rebase startRothConversions on year change.
489
+ # Rebase startRothConversions and yOBBBA on year change.
312
490
  thisyear = date.today().year
313
491
  year = p.solverOptions.get("startRothConversions", thisyear)
314
- diconf["Solver Options"]["startRothConversions"] = max(year, thisyear)
492
+ p.solverOptions["startRothConversions"] = max(year, thisyear)
493
+ p.yOBBBA = max(p.yOBBBA, thisyear)
315
494
 
316
495
  # Results.
317
- p.setDefaultPlots(diconf["Results"]["Default plots"])
496
+ p.setDefaultPlots(diconf["results"]["default_plots"])
318
497
 
319
498
  return p
@@ -0,0 +1,21 @@
1
+ """
2
+ Data package for Owl retirement planner.
3
+
4
+ This package contains data files and utilities for the retirement planner,
5
+ including historical rate of return data.
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
+ """