owlplanner 2025.12.20__py3-none-any.whl → 2026.2.2__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/__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 +315 -118
- 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 +938 -390
- 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 +50 -34
- owlplanner/socialsecurity.py +28 -19
- owlplanner/tax2026.py +119 -38
- owlplanner/timelists.py +194 -18
- owlplanner/utils.py +179 -4
- owlplanner/version.py +20 -1
- {owlplanner-2025.12.20.dist-info → owlplanner-2026.2.2.dist-info}/METADATA +11 -3
- owlplanner-2026.2.2.dist-info/RECORD +35 -0
- owlplanner-2026.2.2.dist-info/entry_points.txt +2 -0
- owlplanner-2026.2.2.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.2.2.dist-info}/WHEEL +0 -0
- {owlplanner-2025.12.20.dist-info → owlplanner-2026.2.2.dist-info}/licenses/LICENSE +0 -0
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,271 @@ 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
|
+
"Reverse sequence": "reverse_sequence",
|
|
85
|
+
"Roll sequence": "roll_sequence",
|
|
86
|
+
# Asset Allocation keys
|
|
87
|
+
"Interpolation method": "interpolation_method",
|
|
88
|
+
"Interpolation center": "interpolation_center",
|
|
89
|
+
"Interpolation width": "interpolation_width",
|
|
90
|
+
"Type": "type",
|
|
91
|
+
# Optimization Parameters keys
|
|
92
|
+
"Spending profile": "spending_profile",
|
|
93
|
+
"Surviving spouse spending percent": "surviving_spouse_spending_percent",
|
|
94
|
+
"Smile dip": "smile_dip",
|
|
95
|
+
"Smile increase": "smile_increase",
|
|
96
|
+
"Smile delay": "smile_delay",
|
|
97
|
+
"Objective": "objective",
|
|
98
|
+
# Results keys
|
|
99
|
+
"Default plots": "default_plots",
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def translate_old_keys(diconf):
|
|
104
|
+
"""
|
|
105
|
+
Translate old TOML keys to new snake_case keys for backward compatibility.
|
|
106
|
+
This function recursively processes the configuration dictionary and replaces
|
|
107
|
+
old keys with new snake_case keys.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
diconf: Configuration dictionary (may be modified in place)
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
Dictionary with translated keys
|
|
114
|
+
"""
|
|
115
|
+
if not isinstance(diconf, dict):
|
|
116
|
+
return diconf
|
|
117
|
+
|
|
118
|
+
translated = {}
|
|
119
|
+
|
|
120
|
+
# First, translate section names at the top level
|
|
121
|
+
for key, value in diconf.items():
|
|
122
|
+
new_key = _KEY_TRANSLATION.get(key, key)
|
|
123
|
+
|
|
124
|
+
if isinstance(value, dict):
|
|
125
|
+
# Recursively translate keys within sections
|
|
126
|
+
translated[new_key] = {}
|
|
127
|
+
for sub_key, sub_value in value.items():
|
|
128
|
+
new_sub_key = _KEY_TRANSLATION.get(sub_key, sub_key)
|
|
129
|
+
if isinstance(sub_value, dict):
|
|
130
|
+
translated[new_key][new_sub_key] = translate_old_keys(sub_value)
|
|
131
|
+
else:
|
|
132
|
+
translated[new_key][new_sub_key] = sub_value
|
|
133
|
+
else:
|
|
134
|
+
translated[new_key] = value
|
|
135
|
+
|
|
136
|
+
return translated
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _read_toml_file(file):
|
|
140
|
+
"""
|
|
141
|
+
Unified TOML file reading for different input types.
|
|
142
|
+
|
|
143
|
+
This function handles reading TOML content from three different input types:
|
|
144
|
+
- str: File path (reads from filesystem)
|
|
145
|
+
- BytesIO: Binary stream (decodes to UTF-8 string)
|
|
146
|
+
- StringIO: Text stream (reads string directly)
|
|
147
|
+
|
|
148
|
+
Parameters
|
|
149
|
+
----------
|
|
150
|
+
file : str, BytesIO, or StringIO
|
|
151
|
+
The file source to read from. Can be a file path string, BytesIO object,
|
|
152
|
+
or StringIO object.
|
|
153
|
+
|
|
154
|
+
Returns
|
|
155
|
+
-------
|
|
156
|
+
tuple
|
|
157
|
+
A tuple containing:
|
|
158
|
+
- diconf (dict): The loaded TOML configuration dictionary
|
|
159
|
+
- dirname (str): The directory name if file is a string path, empty string otherwise
|
|
160
|
+
- filename (str): The filename if file is a string path, None otherwise
|
|
161
|
+
|
|
162
|
+
Raises
|
|
163
|
+
------
|
|
164
|
+
FileNotFoundError
|
|
165
|
+
If file is a string path and the file cannot be found.
|
|
166
|
+
RuntimeError
|
|
167
|
+
If file is BytesIO or StringIO and reading fails.
|
|
168
|
+
ValueError
|
|
169
|
+
If file is not one of the supported types.
|
|
170
|
+
"""
|
|
171
|
+
dirname = ""
|
|
172
|
+
filename = None
|
|
173
|
+
|
|
174
|
+
if isinstance(file, str):
|
|
175
|
+
filename = file
|
|
176
|
+
dirname = os.path.dirname(filename)
|
|
177
|
+
if not filename.endswith(".toml"):
|
|
178
|
+
filename = filename + ".toml"
|
|
179
|
+
|
|
180
|
+
try:
|
|
181
|
+
with open(filename, "r") as f:
|
|
182
|
+
diconf = toml.load(f)
|
|
183
|
+
except Exception as e:
|
|
184
|
+
raise FileNotFoundError(f"File {filename} not found: {e}") from e
|
|
185
|
+
elif isinstance(file, BytesIO):
|
|
186
|
+
try:
|
|
187
|
+
string = file.getvalue().decode("utf-8")
|
|
188
|
+
diconf = toml.loads(string)
|
|
189
|
+
except Exception as e:
|
|
190
|
+
raise RuntimeError(f"Cannot read from BytesIO: {e}") from e
|
|
191
|
+
elif isinstance(file, StringIO):
|
|
192
|
+
try:
|
|
193
|
+
string = file.getvalue()
|
|
194
|
+
diconf = toml.loads(string)
|
|
195
|
+
except Exception as e:
|
|
196
|
+
raise RuntimeError(f"Cannot read from StringIO: {e}") from e
|
|
197
|
+
else:
|
|
198
|
+
raise ValueError(f"Type {type(file)} not a valid type")
|
|
199
|
+
|
|
200
|
+
return diconf, dirname, filename
|
|
201
|
+
|
|
202
|
+
|
|
27
203
|
def saveConfig(myplan, file, mylog):
|
|
28
204
|
"""
|
|
29
205
|
Save case parameters and return a dictionary containing all parameters.
|
|
30
206
|
"""
|
|
31
207
|
|
|
32
208
|
diconf = {}
|
|
33
|
-
diconf["
|
|
34
|
-
diconf["
|
|
209
|
+
diconf["case_name"] = myplan._name
|
|
210
|
+
diconf["description"] = myplan._description
|
|
35
211
|
|
|
36
212
|
# Basic Info.
|
|
37
|
-
diconf["
|
|
38
|
-
"
|
|
39
|
-
"
|
|
40
|
-
"
|
|
41
|
-
"
|
|
42
|
-
"
|
|
213
|
+
diconf["basic_info"] = {
|
|
214
|
+
"status": ["unknown", "single", "married"][myplan.N_i],
|
|
215
|
+
"names": myplan.inames,
|
|
216
|
+
"date_of_birth": myplan.dobs,
|
|
217
|
+
"life_expectancy": myplan.expectancy.tolist(),
|
|
218
|
+
"start_date": myplan.startDate,
|
|
43
219
|
}
|
|
44
220
|
|
|
45
221
|
# Assets.
|
|
46
|
-
diconf["
|
|
222
|
+
diconf["savings_assets"] = {}
|
|
47
223
|
for j in range(myplan.N_j):
|
|
48
224
|
amounts = myplan.beta_ij[:, j] / 1000
|
|
49
|
-
|
|
225
|
+
# Map account type names to snake_case keys
|
|
226
|
+
account_key_map = {
|
|
227
|
+
"taxable": "taxable_savings_balances",
|
|
228
|
+
"tax-deferred": "tax_deferred_savings_balances",
|
|
229
|
+
"tax-free": "tax_free_savings_balances"
|
|
230
|
+
}
|
|
231
|
+
diconf["savings_assets"][account_key_map[AccountTypes[j]]] = amounts.tolist()
|
|
50
232
|
if myplan.N_i == 2:
|
|
51
|
-
diconf["
|
|
52
|
-
diconf["
|
|
233
|
+
diconf["savings_assets"]["beneficiary_fractions"] = myplan.phi_j.tolist()
|
|
234
|
+
diconf["savings_assets"]["spousal_surplus_deposit_fraction"] = myplan.eta
|
|
53
235
|
|
|
54
236
|
# Household Financial Profile
|
|
55
|
-
diconf["
|
|
237
|
+
diconf["household_financial_profile"] = {"HFP_file_name": myplan.timeListsFileName}
|
|
56
238
|
|
|
57
239
|
# Fixed Income.
|
|
58
|
-
diconf["
|
|
59
|
-
"
|
|
60
|
-
"
|
|
61
|
-
"
|
|
62
|
-
"
|
|
63
|
-
"
|
|
240
|
+
diconf["fixed_income"] = {
|
|
241
|
+
"pension_monthly_amounts": (myplan.pensionAmounts).tolist(),
|
|
242
|
+
"pension_ages": myplan.pensionAges.tolist(),
|
|
243
|
+
"pension_indexed": myplan.pensionIsIndexed,
|
|
244
|
+
"social_security_pia_amounts": (myplan.ssecAmounts).tolist(),
|
|
245
|
+
"social_security_ages": myplan.ssecAges.tolist(),
|
|
64
246
|
}
|
|
65
247
|
|
|
66
248
|
# Rates Selection.
|
|
67
|
-
diconf["
|
|
68
|
-
"
|
|
69
|
-
"
|
|
70
|
-
"
|
|
71
|
-
"
|
|
249
|
+
diconf["rates_selection"] = {
|
|
250
|
+
"heirs_rate_on_tax_deferred_estate": float(100 * myplan.nu),
|
|
251
|
+
"dividend_rate": float(100 * myplan.mu),
|
|
252
|
+
"obbba_expiration_year": myplan.yOBBBA,
|
|
253
|
+
"method": myplan.rateMethod,
|
|
72
254
|
}
|
|
255
|
+
# Store seed and reproducibility flag for stochastic methods
|
|
256
|
+
if myplan.rateMethod in ["stochastic", "histochastic"]:
|
|
257
|
+
if myplan.rateSeed is not None:
|
|
258
|
+
diconf["rates_selection"]["rate_seed"] = int(myplan.rateSeed)
|
|
259
|
+
diconf["rates_selection"]["reproducible_rates"] = bool(myplan.reproducibleRates)
|
|
73
260
|
if myplan.rateMethod in ["user", "stochastic"]:
|
|
74
|
-
diconf["
|
|
261
|
+
diconf["rates_selection"]["values"] = (100 * myplan.rateValues).tolist()
|
|
75
262
|
if myplan.rateMethod in ["stochastic"]:
|
|
76
|
-
diconf["
|
|
77
|
-
diconf["
|
|
263
|
+
diconf["rates_selection"]["standard_deviations"] = (100 * myplan.rateStdev).tolist()
|
|
264
|
+
diconf["rates_selection"]["correlations"] = myplan.rateCorr.tolist()
|
|
78
265
|
if myplan.rateMethod in ["historical average", "historical", "histochastic"]:
|
|
79
|
-
diconf["
|
|
80
|
-
diconf["
|
|
266
|
+
diconf["rates_selection"]["from"] = int(myplan.rateFrm)
|
|
267
|
+
diconf["rates_selection"]["to"] = int(myplan.rateTo)
|
|
81
268
|
else:
|
|
82
|
-
diconf["
|
|
83
|
-
diconf["
|
|
269
|
+
diconf["rates_selection"]["from"] = int(FROM)
|
|
270
|
+
diconf["rates_selection"]["to"] = int(TO)
|
|
271
|
+
diconf["rates_selection"]["reverse_sequence"] = bool(myplan.rateReverse)
|
|
272
|
+
diconf["rates_selection"]["roll_sequence"] = int(myplan.rateRoll)
|
|
84
273
|
|
|
85
274
|
# Asset Allocation.
|
|
86
|
-
diconf["
|
|
87
|
-
"
|
|
88
|
-
"
|
|
89
|
-
"
|
|
90
|
-
"
|
|
275
|
+
diconf["asset_allocation"] = {
|
|
276
|
+
"interpolation_method": myplan.interpMethod,
|
|
277
|
+
"interpolation_center": float(myplan.interpCenter),
|
|
278
|
+
"interpolation_width": float(myplan.interpWidth),
|
|
279
|
+
"type": myplan.ARCoord,
|
|
91
280
|
}
|
|
92
281
|
if myplan.ARCoord == "account":
|
|
93
282
|
for accType in AccountTypes:
|
|
94
|
-
diconf["
|
|
283
|
+
diconf["asset_allocation"][accType] = myplan.boundsAR[accType]
|
|
95
284
|
else:
|
|
96
|
-
diconf["
|
|
285
|
+
diconf["asset_allocation"]["generic"] = myplan.boundsAR["generic"]
|
|
97
286
|
|
|
98
287
|
# Optimization Parameters.
|
|
99
|
-
diconf["
|
|
100
|
-
"
|
|
101
|
-
"
|
|
288
|
+
diconf["optimization_parameters"] = {
|
|
289
|
+
"spending_profile": myplan.spendingProfile,
|
|
290
|
+
"surviving_spouse_spending_percent": int(100 * myplan.chi),
|
|
102
291
|
}
|
|
103
292
|
if myplan.spendingProfile == "smile":
|
|
104
|
-
diconf["
|
|
105
|
-
diconf["
|
|
106
|
-
diconf["
|
|
293
|
+
diconf["optimization_parameters"]["smile_dip"] = int(myplan.smileDip)
|
|
294
|
+
diconf["optimization_parameters"]["smile_increase"] = int(myplan.smileIncrease)
|
|
295
|
+
diconf["optimization_parameters"]["smile_delay"] = int(myplan.smileDelay)
|
|
107
296
|
|
|
108
|
-
diconf["
|
|
109
|
-
diconf["
|
|
297
|
+
diconf["optimization_parameters"]["objective"] = myplan.objective
|
|
298
|
+
diconf["solver_options"] = myplan.solverOptions
|
|
110
299
|
|
|
111
300
|
# Results.
|
|
112
|
-
diconf["
|
|
301
|
+
diconf["results"] = {"default_plots": myplan.defaultPlots}
|
|
113
302
|
|
|
114
303
|
if isinstance(file, str):
|
|
115
304
|
filename = file
|
|
@@ -146,62 +335,48 @@ def readConfig(file, *, verbose=True, logstreams=None, readContributions=True):
|
|
|
146
335
|
"""
|
|
147
336
|
mylog = log.Logger(verbose, logstreams)
|
|
148
337
|
|
|
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"
|
|
338
|
+
diconf, dirname, filename = _read_toml_file(file)
|
|
155
339
|
|
|
340
|
+
if filename is not None:
|
|
156
341
|
mylog.vprint(f"Reading plan from case file '{filename}'.")
|
|
157
342
|
|
|
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")
|
|
343
|
+
# Translate old keys to new snake_case keys for backward compatibility
|
|
344
|
+
diconf = translate_old_keys(diconf)
|
|
177
345
|
|
|
178
346
|
# Basic Info.
|
|
179
|
-
name = diconf["
|
|
180
|
-
inames = diconf["
|
|
347
|
+
name = diconf["case_name"]
|
|
348
|
+
inames = diconf["basic_info"]["names"]
|
|
181
349
|
icount = len(inames)
|
|
182
350
|
# Default to January 15, 1965 if no entry is found.
|
|
183
|
-
dobs = diconf["
|
|
184
|
-
expectancy = diconf["
|
|
351
|
+
dobs = diconf["basic_info"].get("date_of_birth", ["1965-01-15"]*icount)
|
|
352
|
+
expectancy = diconf["basic_info"]["life_expectancy"]
|
|
185
353
|
s = ["", "s"][icount - 1]
|
|
186
354
|
mylog.vprint(f"Plan for {icount} individual{s}: {inames}.")
|
|
187
355
|
p = plan.Plan(inames, dobs, expectancy, name, verbose=True, logstreams=logstreams)
|
|
188
|
-
p._description = diconf.get("
|
|
356
|
+
p._description = diconf.get("description", "")
|
|
189
357
|
|
|
190
358
|
# Assets.
|
|
191
|
-
startDate = diconf["
|
|
359
|
+
startDate = diconf["basic_info"].get("start_date", "today")
|
|
192
360
|
balances = {}
|
|
361
|
+
# Map account type names to snake_case keys
|
|
362
|
+
account_key_map = {
|
|
363
|
+
"taxable": "taxable_savings_balances",
|
|
364
|
+
"tax-deferred": "tax_deferred_savings_balances",
|
|
365
|
+
"tax-free": "tax_free_savings_balances"
|
|
366
|
+
}
|
|
193
367
|
for acc in AccountTypes:
|
|
194
|
-
balances[acc] = diconf["
|
|
368
|
+
balances[acc] = diconf["savings_assets"][account_key_map[acc]]
|
|
195
369
|
p.setAccountBalances(taxable=balances["taxable"], taxDeferred=balances["tax-deferred"],
|
|
196
370
|
taxFree=balances["tax-free"], startDate=startDate)
|
|
197
371
|
if icount == 2:
|
|
198
|
-
phi_j = diconf["
|
|
372
|
+
phi_j = diconf["savings_assets"]["beneficiary_fractions"]
|
|
199
373
|
p.setBeneficiaryFractions(phi_j)
|
|
200
|
-
eta = diconf["
|
|
374
|
+
eta = diconf["savings_assets"]["spousal_surplus_deposit_fraction"]
|
|
201
375
|
p.setSpousalDepositFraction(eta)
|
|
202
376
|
|
|
203
377
|
# Household Financial Profile
|
|
204
|
-
|
|
378
|
+
hfp_section = diconf.get("household_financial_profile", {})
|
|
379
|
+
timeListsFileName = hfp_section.get("HFP_file_name", "None")
|
|
205
380
|
if timeListsFileName != "None":
|
|
206
381
|
if readContributions:
|
|
207
382
|
if os.path.exists(timeListsFileName):
|
|
@@ -216,50 +391,62 @@ def readConfig(file, *, verbose=True, logstreams=None, readContributions=True):
|
|
|
216
391
|
mylog.vprint(f"Ignoring to read contributions file {timeListsFileName}.")
|
|
217
392
|
|
|
218
393
|
# Fixed Income.
|
|
219
|
-
ssecAmounts = np.array(diconf["
|
|
220
|
-
ssecAges = np.array(diconf["
|
|
394
|
+
ssecAmounts = np.array(diconf["fixed_income"].get("social_security_pia_amounts", [0]*icount), dtype=np.int32)
|
|
395
|
+
ssecAges = np.array(diconf["fixed_income"]["social_security_ages"])
|
|
221
396
|
p.setSocialSecurity(ssecAmounts, ssecAges)
|
|
222
|
-
pensionAmounts = np.array(diconf["
|
|
223
|
-
pensionAges = np.array(diconf["
|
|
224
|
-
pensionIsIndexed = diconf["
|
|
397
|
+
pensionAmounts = np.array(diconf["fixed_income"].get("pension_monthly_amounts", [0]*icount), dtype=np.float32)
|
|
398
|
+
pensionAges = np.array(diconf["fixed_income"]["pension_ages"])
|
|
399
|
+
pensionIsIndexed = diconf["fixed_income"]["pension_indexed"]
|
|
225
400
|
p.setPension(pensionAmounts, pensionAges, pensionIsIndexed)
|
|
226
401
|
|
|
227
402
|
# Rates Selection.
|
|
228
|
-
p.setDividendRate(float(diconf["
|
|
229
|
-
p.setHeirsTaxRate(float(diconf["
|
|
230
|
-
p.yOBBBA = int(diconf["
|
|
403
|
+
p.setDividendRate(float(diconf["rates_selection"].get("dividend_rate", 1.8))) # Fix for mod.
|
|
404
|
+
p.setHeirsTaxRate(float(diconf["rates_selection"]["heirs_rate_on_tax_deferred_estate"]))
|
|
405
|
+
p.yOBBBA = int(diconf["rates_selection"].get("obbba_expiration_year", 2032))
|
|
231
406
|
|
|
232
407
|
frm = None
|
|
233
408
|
to = None
|
|
234
409
|
rateValues = None
|
|
235
410
|
stdev = None
|
|
236
411
|
rateCorr = None
|
|
237
|
-
|
|
412
|
+
rateSeed = None
|
|
413
|
+
reproducibleRates = False
|
|
414
|
+
rateMethod = diconf["rates_selection"]["method"]
|
|
238
415
|
if rateMethod in ["historical average", "historical", "histochastic"]:
|
|
239
|
-
frm = diconf["
|
|
416
|
+
frm = diconf["rates_selection"]["from"]
|
|
240
417
|
if not isinstance(frm, int):
|
|
241
418
|
frm = int(frm)
|
|
242
|
-
to = diconf["
|
|
419
|
+
to = diconf["rates_selection"]["to"]
|
|
243
420
|
if not isinstance(to, int):
|
|
244
421
|
to = int(to)
|
|
245
422
|
if rateMethod in ["user", "stochastic"]:
|
|
246
|
-
rateValues = np.array(diconf["
|
|
423
|
+
rateValues = np.array(diconf["rates_selection"]["values"], dtype=np.float64)
|
|
247
424
|
if rateMethod in ["stochastic"]:
|
|
248
|
-
stdev = np.array(diconf["
|
|
249
|
-
rateCorr = np.array(diconf["
|
|
250
|
-
|
|
425
|
+
stdev = np.array(diconf["rates_selection"]["standard_deviations"], dtype=np.float64)
|
|
426
|
+
rateCorr = np.array(diconf["rates_selection"]["correlations"], dtype=np.float64)
|
|
427
|
+
# Load seed and reproducibility flag for stochastic methods
|
|
428
|
+
if rateMethod in ["stochastic", "histochastic"]:
|
|
429
|
+
rateSeed = diconf["rates_selection"].get("rate_seed")
|
|
430
|
+
if rateSeed is not None:
|
|
431
|
+
rateSeed = int(rateSeed)
|
|
432
|
+
reproducibleRates = diconf["rates_selection"].get("reproducible_rates", False)
|
|
433
|
+
p.setReproducible(reproducibleRates, seed=rateSeed)
|
|
434
|
+
reverseSequence = diconf["rates_selection"].get("reverse_sequence", False)
|
|
435
|
+
rollSequence = diconf["rates_selection"].get("roll_sequence", 0)
|
|
436
|
+
p.setRates(rateMethod, frm, to, rateValues, stdev, rateCorr,
|
|
437
|
+
reverse=reverseSequence, roll=rollSequence)
|
|
251
438
|
|
|
252
439
|
# Asset Allocation.
|
|
253
440
|
boundsAR = {}
|
|
254
441
|
p.setInterpolationMethod(
|
|
255
|
-
diconf["
|
|
256
|
-
float(diconf["
|
|
257
|
-
float(diconf["
|
|
442
|
+
diconf["asset_allocation"]["interpolation_method"],
|
|
443
|
+
float(diconf["asset_allocation"]["interpolation_center"]),
|
|
444
|
+
float(diconf["asset_allocation"]["interpolation_width"]),
|
|
258
445
|
)
|
|
259
|
-
allocType = diconf["
|
|
446
|
+
allocType = diconf["asset_allocation"]["type"]
|
|
260
447
|
if allocType == "account":
|
|
261
448
|
for aType in AccountTypes:
|
|
262
|
-
boundsAR[aType] = np.array(diconf["
|
|
449
|
+
boundsAR[aType] = np.array(diconf["asset_allocation"][aType], dtype=np.float64)
|
|
263
450
|
|
|
264
451
|
p.setAllocationRatios(
|
|
265
452
|
allocType,
|
|
@@ -268,7 +455,7 @@ def readConfig(file, *, verbose=True, logstreams=None, readContributions=True):
|
|
|
268
455
|
taxFree=boundsAR["tax-free"],
|
|
269
456
|
)
|
|
270
457
|
elif allocType == "individual" or allocType == "spouses":
|
|
271
|
-
boundsAR["generic"] = np.array(diconf["
|
|
458
|
+
boundsAR["generic"] = np.array(diconf["asset_allocation"]["generic"], dtype=np.float64)
|
|
272
459
|
p.setAllocationRatios(
|
|
273
460
|
allocType,
|
|
274
461
|
generic=boundsAR["generic"],
|
|
@@ -277,14 +464,14 @@ def readConfig(file, *, verbose=True, logstreams=None, readContributions=True):
|
|
|
277
464
|
raise ValueError(f"Unknown asset allocation type {allocType}.")
|
|
278
465
|
|
|
279
466
|
# Optimization Parameters.
|
|
280
|
-
p.objective = diconf["
|
|
467
|
+
p.objective = diconf["optimization_parameters"]["objective"]
|
|
281
468
|
|
|
282
|
-
profile = diconf["
|
|
283
|
-
survivor = int(diconf["
|
|
469
|
+
profile = diconf["optimization_parameters"]["spending_profile"]
|
|
470
|
+
survivor = int(diconf["optimization_parameters"]["surviving_spouse_spending_percent"])
|
|
284
471
|
if profile == "smile":
|
|
285
|
-
dip = int(diconf["
|
|
286
|
-
increase = int(diconf["
|
|
287
|
-
delay = int(diconf["
|
|
472
|
+
dip = int(diconf["optimization_parameters"]["smile_dip"])
|
|
473
|
+
increase = int(diconf["optimization_parameters"]["smile_increase"])
|
|
474
|
+
delay = int(diconf["optimization_parameters"]["smile_delay"])
|
|
288
475
|
else:
|
|
289
476
|
dip = 15
|
|
290
477
|
increase = 12
|
|
@@ -293,23 +480,33 @@ def readConfig(file, *, verbose=True, logstreams=None, readContributions=True):
|
|
|
293
480
|
p.setSpendingProfile(profile, survivor, dip, increase, delay)
|
|
294
481
|
|
|
295
482
|
# Solver Options.
|
|
296
|
-
p.solverOptions = diconf["
|
|
483
|
+
p.solverOptions = dict(diconf["solver_options"])
|
|
297
484
|
|
|
298
|
-
#
|
|
299
|
-
|
|
485
|
+
# Defaults for options not present in case file (e.g. Case_joe.toml).
|
|
486
|
+
# Ensures Medicare is computed in loop mode and self-consistent loop runs.
|
|
487
|
+
if "withMedicare" not in p.solverOptions:
|
|
300
488
|
p.solverOptions["withMedicare"] = "loop"
|
|
489
|
+
if "withSCLoop" not in p.solverOptions:
|
|
490
|
+
p.solverOptions["withSCLoop"] = True
|
|
491
|
+
|
|
492
|
+
# Address legacy case files.
|
|
493
|
+
# Convert boolean values (True/False) to string format, but preserve string values
|
|
494
|
+
withMedicare = p.solverOptions.get("withMedicare")
|
|
495
|
+
if isinstance(withMedicare, bool):
|
|
496
|
+
p.solverOptions["withMedicare"] = "loop" if withMedicare else "None"
|
|
301
497
|
|
|
302
498
|
# Check consistency of noRothConversions.
|
|
303
499
|
name = p.solverOptions.get("noRothConversions", "None")
|
|
304
500
|
if name != "None" and name not in p.inames:
|
|
305
501
|
raise ValueError(f"Unknown name {name} for noRothConversions.")
|
|
306
502
|
|
|
307
|
-
# Rebase startRothConversions on year change.
|
|
503
|
+
# Rebase startRothConversions and yOBBBA on year change.
|
|
308
504
|
thisyear = date.today().year
|
|
309
505
|
year = p.solverOptions.get("startRothConversions", thisyear)
|
|
310
506
|
p.solverOptions["startRothConversions"] = max(year, thisyear)
|
|
507
|
+
p.yOBBBA = max(p.yOBBBA, thisyear)
|
|
311
508
|
|
|
312
509
|
# Results.
|
|
313
|
-
p.setDefaultPlots(diconf["
|
|
510
|
+
p.setDefaultPlots(diconf["results"]["default_plots"])
|
|
314
511
|
|
|
315
512
|
return p
|
owlplanner/data/__init__.py
CHANGED
|
@@ -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
|
+
"""
|