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.
- owlplanner/In Discussion #58, the case of Kim and Sam.md +307 -0
- owlplanner/__init__.py +20 -1
- owlplanner/abcapi.py +24 -23
- 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 -136
- owlplanner/data/__init__.py +21 -0
- owlplanner/data/awi.csv +75 -0
- owlplanner/data/bendpoints.csv +49 -0
- owlplanner/data/newawi.csv +75 -0
- owlplanner/data/rates.csv +99 -98
- owlplanner/debts.py +315 -0
- owlplanner/fixedassets.py +288 -0
- owlplanner/mylogging.py +157 -25
- owlplanner/plan.py +1044 -332
- 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 +33 -10
- owlplanner/progress.py +66 -9
- owlplanner/rates.py +366 -361
- owlplanner/socialsecurity.py +142 -22
- owlplanner/tax2026.py +170 -57
- owlplanner/timelists.py +316 -32
- owlplanner/utils.py +204 -5
- owlplanner/version.py +20 -1
- {owlplanner-2025.12.5.dist-info → owlplanner-2026.1.26.dist-info}/METADATA +50 -158
- 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 -339
- owlplanner-2025.12.5.dist-info/RECORD +0 -24
- {owlplanner-2025.12.5.dist-info → owlplanner-2026.1.26.dist-info}/WHEEL +0 -0
- {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
|
-
|
|
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
|
-
import 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["
|
|
33
|
-
diconf["
|
|
207
|
+
diconf["case_name"] = myplan._name
|
|
208
|
+
diconf["description"] = myplan._description
|
|
34
209
|
|
|
35
210
|
# Basic Info.
|
|
36
|
-
diconf["
|
|
37
|
-
"
|
|
38
|
-
"
|
|
39
|
-
"
|
|
40
|
-
"
|
|
41
|
-
"
|
|
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["
|
|
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
|
-
#
|
|
55
|
-
diconf["
|
|
234
|
+
# Household Financial Profile
|
|
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
|
-
for accType in
|
|
94
|
-
diconf["
|
|
278
|
+
for accType in AccountTypes:
|
|
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,72 +331,54 @@ def readConfig(file, *, verbose=True, logstreams=None, readContributions=True):
|
|
|
146
331
|
"""
|
|
147
332
|
mylog = log.Logger(verbose, logstreams)
|
|
148
333
|
|
|
149
|
-
|
|
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
|
-
|
|
161
|
-
|
|
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["
|
|
182
|
-
inames = diconf["
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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,
|
|
192
|
-
p._description = diconf.get("
|
|
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["
|
|
355
|
+
startDate = diconf["basic_info"].get("start_date", "today")
|
|
196
356
|
balances = {}
|
|
197
|
-
|
|
198
|
-
|
|
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["
|
|
368
|
+
phi_j = diconf["savings_assets"]["beneficiary_fractions"]
|
|
203
369
|
p.setBeneficiaryFractions(phi_j)
|
|
204
|
-
eta = diconf["
|
|
370
|
+
eta = diconf["savings_assets"]["spousal_surplus_deposit_fraction"]
|
|
205
371
|
p.setSpousalDepositFraction(eta)
|
|
206
372
|
|
|
207
|
-
#
|
|
208
|
-
|
|
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
|
|
214
|
-
myfile = dirname
|
|
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["
|
|
224
|
-
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"])
|
|
225
392
|
p.setSocialSecurity(ssecAmounts, ssecAges)
|
|
226
|
-
pensionAmounts = np.array(diconf["
|
|
227
|
-
pensionAges = np.array(diconf["
|
|
228
|
-
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"]
|
|
229
396
|
p.setPension(pensionAmounts, pensionAges, pensionIsIndexed)
|
|
230
397
|
|
|
231
398
|
# Rates Selection.
|
|
232
|
-
p.setDividendRate(float(diconf["
|
|
233
|
-
p.setHeirsTaxRate(float(diconf["
|
|
234
|
-
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))
|
|
235
402
|
|
|
236
403
|
frm = None
|
|
237
404
|
to = None
|
|
238
405
|
rateValues = None
|
|
239
406
|
stdev = None
|
|
240
407
|
rateCorr = None
|
|
241
|
-
|
|
408
|
+
rateSeed = None
|
|
409
|
+
reproducibleRates = False
|
|
410
|
+
rateMethod = diconf["rates_selection"]["method"]
|
|
242
411
|
if rateMethod in ["historical average", "historical", "histochastic"]:
|
|
243
|
-
frm = diconf["
|
|
412
|
+
frm = diconf["rates_selection"]["from"]
|
|
244
413
|
if not isinstance(frm, int):
|
|
245
414
|
frm = int(frm)
|
|
246
|
-
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["
|
|
419
|
+
rateValues = np.array(diconf["rates_selection"]["values"], dtype=np.float64)
|
|
251
420
|
if rateMethod in ["stochastic"]:
|
|
252
|
-
stdev = np.array(diconf["
|
|
253
|
-
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)
|
|
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["
|
|
260
|
-
float(diconf["
|
|
261
|
-
float(diconf["
|
|
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["
|
|
439
|
+
allocType = diconf["asset_allocation"]["type"]
|
|
264
440
|
if allocType == "account":
|
|
265
|
-
for aType in
|
|
266
|
-
boundsAR[aType] = np.array(diconf["
|
|
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["
|
|
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["
|
|
460
|
+
p.objective = diconf["optimization_parameters"]["objective"]
|
|
285
461
|
|
|
286
|
-
profile = diconf["
|
|
287
|
-
survivor = int(diconf["
|
|
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["
|
|
290
|
-
increase = int(diconf["
|
|
291
|
-
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"])
|
|
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["
|
|
476
|
+
p.solverOptions = diconf["solver_options"]
|
|
301
477
|
|
|
302
478
|
# Address legacy case files.
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
492
|
+
p.solverOptions["startRothConversions"] = max(year, thisyear)
|
|
493
|
+
p.yOBBBA = max(p.yOBBBA, thisyear)
|
|
315
494
|
|
|
316
495
|
# Results.
|
|
317
|
-
p.setDefaultPlots(diconf["
|
|
496
|
+
p.setDefaultPlots(diconf["results"]["default_plots"])
|
|
318
497
|
|
|
319
498
|
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
|
+
"""
|