owlplanner 2025.12.20__py3-none-any.whl → 2026.1.26__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- owlplanner/In Discussion #58, the case of Kim and Sam.md +307 -0
- owlplanner/__init__.py +20 -1
- owlplanner/abcapi.py +18 -17
- owlplanner/cli/README.md +50 -0
- owlplanner/cli/_main.py +52 -0
- owlplanner/cli/cli_logging.py +56 -0
- owlplanner/cli/cmd_list.py +83 -0
- owlplanner/cli/cmd_run.py +86 -0
- owlplanner/config.py +300 -117
- owlplanner/data/__init__.py +21 -0
- owlplanner/data/rates.csv +99 -98
- owlplanner/debts.py +36 -8
- owlplanner/fixedassets.py +95 -21
- owlplanner/mylogging.py +157 -25
- owlplanner/plan.py +793 -349
- owlplanner/plotting/__init__.py +16 -3
- owlplanner/plotting/base.py +17 -3
- owlplanner/plotting/factory.py +16 -3
- owlplanner/plotting/matplotlib_backend.py +30 -7
- owlplanner/plotting/plotly_backend.py +32 -9
- owlplanner/progress.py +16 -3
- owlplanner/rates.py +366 -361
- owlplanner/socialsecurity.py +28 -19
- owlplanner/tax2026.py +109 -30
- owlplanner/timelists.py +194 -18
- owlplanner/utils.py +179 -4
- owlplanner/version.py +20 -1
- {owlplanner-2025.12.20.dist-info → owlplanner-2026.1.26.dist-info}/METADATA +10 -2
- owlplanner-2026.1.26.dist-info/RECORD +36 -0
- owlplanner-2026.1.26.dist-info/entry_points.txt +2 -0
- owlplanner-2026.1.26.dist-info/licenses/AUTHORS +15 -0
- owlplanner/tax2025.py +0 -359
- owlplanner-2025.12.20.dist-info/RECORD +0 -29
- {owlplanner-2025.12.20.dist-info → owlplanner-2026.1.26.dist-info}/WHEEL +0 -0
- {owlplanner-2025.12.20.dist-info → owlplanner-2026.1.26.dist-info}/licenses/LICENSE +0 -0
owlplanner/utils.py
CHANGED
|
@@ -1,17 +1,28 @@
|
|
|
1
1
|
"""
|
|
2
|
+
Utility functions for data formatting and manipulation.
|
|
2
3
|
|
|
3
|
-
|
|
4
|
+
This module provides helper functions for formatting currency, percentages,
|
|
5
|
+
and other data transformations used throughout the retirement planner.
|
|
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
|
######################################################################
|
|
14
24
|
import numpy as np
|
|
25
|
+
import pandas as pd
|
|
15
26
|
|
|
16
27
|
|
|
17
28
|
def d(value, f=0, latex=False) -> str:
|
|
@@ -70,6 +81,15 @@ def getUnits(units) -> int:
|
|
|
70
81
|
return fac
|
|
71
82
|
|
|
72
83
|
|
|
84
|
+
def get_numeric_option(options, key, default, *, min_value=None) -> float:
|
|
85
|
+
value = options.get(key, default)
|
|
86
|
+
if not isinstance(value, (int, float)):
|
|
87
|
+
raise ValueError(f"{key} {value} is not a number.")
|
|
88
|
+
if min_value is not None and value < min_value:
|
|
89
|
+
raise ValueError(f"{key} must be >= {min_value}.")
|
|
90
|
+
return float(value)
|
|
91
|
+
|
|
92
|
+
|
|
73
93
|
# Next two functions could be a one-line lambda functions.
|
|
74
94
|
# e.g., krond = lambda a, b: 1 if a == b else 0
|
|
75
95
|
def krond(a, b) -> int:
|
|
@@ -125,3 +145,158 @@ def parseDobs(dobs):
|
|
|
125
145
|
tobs.append(ls[2])
|
|
126
146
|
|
|
127
147
|
return np.array(yobs, dtype=np.int32), np.array(mobs, dtype=np.int32), np.array(tobs, dtype=np.int32)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def is_row_active(row):
|
|
151
|
+
"""
|
|
152
|
+
Check if a DataFrame row should be processed based on 'active' column.
|
|
153
|
+
|
|
154
|
+
This function handles the common pattern of checking whether a row in a DataFrame
|
|
155
|
+
should be processed based on its 'active' column value. The logic is:
|
|
156
|
+
- If 'active' column doesn't exist, the row is considered active (default behavior)
|
|
157
|
+
- If 'active' value is NaN or None, the row is considered active (default behavior)
|
|
158
|
+
- If 'active' value is explicitly False (or falsy), the row is considered inactive
|
|
159
|
+
- Otherwise (True or truthy), the row is considered active
|
|
160
|
+
|
|
161
|
+
Parameters
|
|
162
|
+
----------
|
|
163
|
+
row : pd.Series
|
|
164
|
+
A pandas Series representing a row from a DataFrame. The row should have
|
|
165
|
+
an 'active' column (or index entry) if the active/inactive status is to be checked.
|
|
166
|
+
|
|
167
|
+
Returns
|
|
168
|
+
-------
|
|
169
|
+
bool
|
|
170
|
+
True if the row should be processed (is active), False if it should be skipped (is inactive).
|
|
171
|
+
"""
|
|
172
|
+
if "active" not in row.index:
|
|
173
|
+
return True # Default to active if column doesn't exist
|
|
174
|
+
active_value = row["active"]
|
|
175
|
+
if pd.isna(active_value) or active_value is None:
|
|
176
|
+
return True # NaN/None means active
|
|
177
|
+
return bool(active_value)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def is_dataframe_empty(df):
|
|
181
|
+
"""
|
|
182
|
+
Check if a DataFrame is None or empty.
|
|
183
|
+
|
|
184
|
+
This function consolidates the common pattern of checking
|
|
185
|
+
`df is None or df.empty` throughout the codebase.
|
|
186
|
+
|
|
187
|
+
Parameters
|
|
188
|
+
----------
|
|
189
|
+
df : pd.DataFrame or None
|
|
190
|
+
The DataFrame to check. Can be None or an empty DataFrame.
|
|
191
|
+
|
|
192
|
+
Returns
|
|
193
|
+
-------
|
|
194
|
+
bool
|
|
195
|
+
True if df is None or empty, False otherwise.
|
|
196
|
+
"""
|
|
197
|
+
return df is None or df.empty
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def ensure_dataframe(df, default_empty=None):
|
|
201
|
+
"""
|
|
202
|
+
Ensure DataFrame is not None or empty, return default if needed.
|
|
203
|
+
|
|
204
|
+
This function checks if a DataFrame is None or empty and returns a default
|
|
205
|
+
value if so. This consolidates the common pattern of checking
|
|
206
|
+
`df is None or df.empty` throughout the codebase.
|
|
207
|
+
|
|
208
|
+
Parameters
|
|
209
|
+
----------
|
|
210
|
+
df : pd.DataFrame or None
|
|
211
|
+
The DataFrame to check. Can be None or an empty DataFrame.
|
|
212
|
+
default_empty : any, optional
|
|
213
|
+
The value to return if df is None or empty. Default is None.
|
|
214
|
+
Common values are 0.0, np.zeros(N_n), or a default DataFrame.
|
|
215
|
+
|
|
216
|
+
Returns
|
|
217
|
+
-------
|
|
218
|
+
any
|
|
219
|
+
Returns default_empty if df is None or empty, otherwise returns df.
|
|
220
|
+
"""
|
|
221
|
+
if is_dataframe_empty(df):
|
|
222
|
+
return default_empty
|
|
223
|
+
return df
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def get_empty_array_or_value(N_n, default_value=0.0):
|
|
227
|
+
"""
|
|
228
|
+
Return empty array or single value based on context.
|
|
229
|
+
|
|
230
|
+
This helper function returns either a numpy array of zeros with length N_n
|
|
231
|
+
if N_n is provided, or a single default value if N_n is None.
|
|
232
|
+
|
|
233
|
+
Parameters
|
|
234
|
+
----------
|
|
235
|
+
N_n : int or None
|
|
236
|
+
Length of the array to create. If None, returns default_value instead.
|
|
237
|
+
default_value : float, optional
|
|
238
|
+
Default value to return if N_n is None. Default is 0.0.
|
|
239
|
+
|
|
240
|
+
Returns
|
|
241
|
+
-------
|
|
242
|
+
np.ndarray or float
|
|
243
|
+
Returns np.zeros(N_n) if N_n is not None, otherwise returns default_value.
|
|
244
|
+
"""
|
|
245
|
+
if N_n is not None:
|
|
246
|
+
return np.zeros(N_n)
|
|
247
|
+
return default_value
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def convert_to_bool(val):
|
|
251
|
+
"""
|
|
252
|
+
Convert various input types to boolean.
|
|
253
|
+
|
|
254
|
+
Handles conversion from strings, numbers, booleans, and NaN values.
|
|
255
|
+
Excel may read booleans as strings ("True"/"False") or numbers (1/0),
|
|
256
|
+
so this function provides robust conversion.
|
|
257
|
+
|
|
258
|
+
Parameters
|
|
259
|
+
----------
|
|
260
|
+
val : any
|
|
261
|
+
Value to convert to boolean. Can be:
|
|
262
|
+
- bool: returned as-is
|
|
263
|
+
- str: "True", "False", "1", "0", "yes", "no", etc.
|
|
264
|
+
- numeric: 1/0 or other numeric values
|
|
265
|
+
- None/NaN: defaults to True
|
|
266
|
+
|
|
267
|
+
Returns
|
|
268
|
+
-------
|
|
269
|
+
bool
|
|
270
|
+
Boolean value. NaN/None and unknown values default to True.
|
|
271
|
+
"""
|
|
272
|
+
# Check for None first (before pd.isna which can fail on some types)
|
|
273
|
+
if val is None:
|
|
274
|
+
return True # Default to True for None
|
|
275
|
+
|
|
276
|
+
# Check for NaN, but handle cases where pd.isna might fail (e.g., empty lists)
|
|
277
|
+
try:
|
|
278
|
+
if pd.isna(val):
|
|
279
|
+
return True # Default to True for NaN
|
|
280
|
+
except (ValueError, TypeError):
|
|
281
|
+
# pd.isna can raise ValueError for empty arrays/lists
|
|
282
|
+
# or TypeError for unhashable types - treat as non-NaN and continue
|
|
283
|
+
pass
|
|
284
|
+
if isinstance(val, bool):
|
|
285
|
+
return val
|
|
286
|
+
if isinstance(val, str):
|
|
287
|
+
# Handle string representations
|
|
288
|
+
val_lower = val.lower().strip()
|
|
289
|
+
if val_lower in ("true", "1", "yes", "y"):
|
|
290
|
+
return True
|
|
291
|
+
elif val_lower in ("false", "0", "no", "n"):
|
|
292
|
+
return False
|
|
293
|
+
else:
|
|
294
|
+
# Unknown string, default to True
|
|
295
|
+
return True
|
|
296
|
+
# Handle numeric values (1/0)
|
|
297
|
+
try:
|
|
298
|
+
num_val = float(val)
|
|
299
|
+
return bool(num_val) if num_val != 0 else False
|
|
300
|
+
except (ValueError, TypeError):
|
|
301
|
+
# Can't convert, default to True
|
|
302
|
+
return True
|
owlplanner/version.py
CHANGED
|
@@ -1 +1,20 @@
|
|
|
1
|
-
|
|
1
|
+
"""
|
|
2
|
+
Package version information.
|
|
3
|
+
|
|
4
|
+
Copyright (C) 2025-2026 The Owlplanner Authors
|
|
5
|
+
|
|
6
|
+
This program is free software: you can redistribute it and/or modify
|
|
7
|
+
it under the terms of the GNU General Public License as published by
|
|
8
|
+
the Free Software Foundation, either version 3 of the License, or
|
|
9
|
+
(at your option) any later version.
|
|
10
|
+
|
|
11
|
+
This program is distributed in the hope that it will be useful,
|
|
12
|
+
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
13
|
+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
14
|
+
GNU General Public License for more details.
|
|
15
|
+
|
|
16
|
+
You should have received a copy of the GNU General Public License
|
|
17
|
+
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
__version__ = "2026.01.26"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: owlplanner
|
|
3
|
-
Version:
|
|
3
|
+
Version: 2026.1.26
|
|
4
4
|
Summary: Owl - Optimal Wealth Lab: Retirement planner with great wisdom
|
|
5
5
|
Project-URL: HomePage, https://github.com/mdlacasse/owl
|
|
6
6
|
Project-URL: Repository, https://github.com/mdlacasse/owl
|
|
@@ -683,6 +683,7 @@ License: GNU GENERAL PUBLIC LICENSE
|
|
|
683
683
|
the library. If this is what you want to do, use the GNU Lesser General
|
|
684
684
|
Public License instead of this License. But first, please read
|
|
685
685
|
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
|
686
|
+
License-File: AUTHORS
|
|
686
687
|
License-File: LICENSE
|
|
687
688
|
Classifier: Development Status :: 5 - Production/Stable
|
|
688
689
|
Classifier: Intended Audience :: End Users/Desktop
|
|
@@ -691,8 +692,10 @@ Classifier: Operating System :: OS Independent
|
|
|
691
692
|
Classifier: Programming Language :: Python :: 3
|
|
692
693
|
Classifier: Topic :: Office/Business :: Financial :: Investment
|
|
693
694
|
Requires-Python: >=3.10
|
|
695
|
+
Requires-Dist: click>=8.3.1
|
|
694
696
|
Requires-Dist: highspy
|
|
695
697
|
Requires-Dist: jupyter>=1.1.1
|
|
698
|
+
Requires-Dist: loguru>=0.7.3
|
|
696
699
|
Requires-Dist: matplotlib
|
|
697
700
|
Requires-Dist: numpy
|
|
698
701
|
Requires-Dist: odfpy
|
|
@@ -712,7 +715,7 @@ Description-Content-Type: text/markdown
|
|
|
712
715
|
|
|
713
716
|
## A retirement exploration tool based on linear programming
|
|
714
717
|
|
|
715
|
-
<img align=right src="
|
|
718
|
+
<img align="right" src="papers/images/owl.png" width="250">
|
|
716
719
|
|
|
717
720
|
-------------------------------------------------------------------------------------
|
|
718
721
|
|
|
@@ -724,6 +727,10 @@ Users can select varying return rates to perform historical back testing,
|
|
|
724
727
|
stochastic rates for performing Monte Carlo analyses,
|
|
725
728
|
or fixed rates either derived from historical averages, or set by the user.
|
|
726
729
|
|
|
730
|
+
Owl is designed for US retirees as it considers US federal tax laws,
|
|
731
|
+
Medicare premiums, rules for 401k including required minimum distributions,
|
|
732
|
+
maturation rules for Roth accounts and conversions, social security rules, etc.
|
|
733
|
+
|
|
727
734
|
There are three ways to run Owl:
|
|
728
735
|
|
|
729
736
|
- **Streamlit Hub:** Run Owl remotely as hosted on the Streamlit Community Server at
|
|
@@ -763,6 +770,7 @@ Follow these [instructions](INSTALL.md) to install from the source code and self
|
|
|
763
770
|
It can also run on [MOSEK](https://mosek.com) if available on your computer.
|
|
764
771
|
- Owl planner relies on the following [Python](https://python.org) packages:
|
|
765
772
|
- [highspy](https://highs.dev),
|
|
773
|
+
[loguru](https://github.com/Delgan/loguru),
|
|
766
774
|
[Matplotlib](https://matplotlib.org),
|
|
767
775
|
[Numpy](https://numpy.org),
|
|
768
776
|
[odfpy](https://https://pypi.org/project/odfpy),
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"owlplanner/In Discussion #58, the case of Kim and Sam.md",sha256=ah5BgkP4Bo04SZ2-Ory8PBeoFRVd7YNUBktYtjp42v0,25193
|
|
2
|
+
owlplanner/__init__.py,sha256=X9u8PrfeTyfjuksYhx0eNrIACgX5Tg3Le7pz8qKkJXk,1341
|
|
3
|
+
owlplanner/abcapi.py,sha256=5yWuiu5eBVO46V5vklO_zfCCrFZX9guRZ9XEemeJ878,7229
|
|
4
|
+
owlplanner/config.py,sha256=-q6TYMagUJGv1ige6VvWPFtZ5ZWkT9Yh-HQrnnWDHfU,19834
|
|
5
|
+
owlplanner/debts.py,sha256=c-eLynjOFryYisae51Po6Iuh46_d_AGOUbOz3z96ZJY,9850
|
|
6
|
+
owlplanner/fixedassets.py,sha256=ItFeXjIdhMVNyXY0xBW6P9ODRajVg1kDRaid9-Eu_Ww,11510
|
|
7
|
+
owlplanner/mylogging.py,sha256=amQA0qWPsXMoJkYYF3nMaVtCDZMWxXJ1hbJS0TkMvf8,8269
|
|
8
|
+
owlplanner/plan.py,sha256=VWrKDGl3UgNB6GQzfw7hQzVNJW_BcupH0I2zP9yKtsw,152509
|
|
9
|
+
owlplanner/progress.py,sha256=ayqoRUMn6dtIxHDYaeX1mu7W2rAhkBYAi4Y2Y_NtdYA,2521
|
|
10
|
+
owlplanner/rates.py,sha256=W-cXEbJl7L9cPEhQbtz4bf_JAkChoCfDSFoZHDUZeys,14393
|
|
11
|
+
owlplanner/socialsecurity.py,sha256=4vn76dUZ2P2fMKyy4eu2ramGNvPQymKzsNFZvwbFWOc,7595
|
|
12
|
+
owlplanner/tax2026.py,sha256=-aHOVv5pErXY82PK5dQBjqoAqYc74w4C5zqGODboUBw,15659
|
|
13
|
+
owlplanner/timelists.py,sha256=4dBZVCgNIx0RwUfimp_k-m9KhRFj6BHX8Hon1vb8oo8,15032
|
|
14
|
+
owlplanner/utils.py,sha256=l2-3HmZutFlEfJUp7gFblY3PBNYm7HiPPCGl0wFkVmA,9461
|
|
15
|
+
owlplanner/version.py,sha256=ah-vzrsjzBTIMnscgEdl8z7Y1yu4uXQ5B3og10cS2y4,747
|
|
16
|
+
owlplanner/cli/README.md,sha256=R-jwwiX5ZW6gCKaRPWVCgwKVoN2MKozrLAkKvfUT4vA,2292
|
|
17
|
+
owlplanner/cli/_main.py,sha256=qAy_2oCLaa_EhvfGFnuh_sVXkCcgp70WMSL7_QKH5XI,1497
|
|
18
|
+
owlplanner/cli/cli_logging.py,sha256=CTBaAMpJIG7ge9Gz44YykBiea3hMxdAXHyiLj1UyvDU,1690
|
|
19
|
+
owlplanner/cli/cmd_list.py,sha256=lOpS_5LGR1IpJLOaHQOrwm1EtbGwv0tfIcZZ8QObmMM,2584
|
|
20
|
+
owlplanner/cli/cmd_run.py,sha256=aLy-X6x_3MSO_yBQ1R4MWKzczfQgWuRQUsNTrVUaWj8,3009
|
|
21
|
+
owlplanner/data/__init__.py,sha256=jpbWHd9n4F06pyfQj1kI3HQ3uiWXfZCjb5BYINOr9_M,850
|
|
22
|
+
owlplanner/data/awi.csv,sha256=w9YwPEqhxqo1Czumh3ihLXLPtk8kJQgqWKClMysOq-c,1625
|
|
23
|
+
owlplanner/data/bendpoints.csv,sha256=h0a7_XqTuyHWe5ZlS3mjIcAWqOMG_93GoiETS-1xxUY,746
|
|
24
|
+
owlplanner/data/newawi.csv,sha256=w9YwPEqhxqo1Czumh3ihLXLPtk8kJQgqWKClMysOq-c,1625
|
|
25
|
+
owlplanner/data/rates.csv,sha256=6Pr5cXFunZKRzB32--kWzhuRnTQMNFfZaww0QF1SIag,3898
|
|
26
|
+
owlplanner/plotting/__init__.py,sha256=UrKO_zHTxZ-Wb2sQbyk2vNHe20Owzt6M1KJ2M6Z5GGw,944
|
|
27
|
+
owlplanner/plotting/base.py,sha256=70-J5AWnHtWqrehVhCNojnTdfNT6PnEpxBKzDh3R57o,3298
|
|
28
|
+
owlplanner/plotting/factory.py,sha256=sVZ0uAuGKBVVh1qkF2IS7xNZGUPIQRIfqgUt2s2Ncjk,1694
|
|
29
|
+
owlplanner/plotting/matplotlib_backend.py,sha256=AILNOhXi_gsR__i1hKoHaEHvMUqQMyLeRAGCoCBfQBY,19222
|
|
30
|
+
owlplanner/plotting/plotly_backend.py,sha256=OqMJRxAhpwIbKSV-Od1-q-tzOs66McZPbKD32be2qx0,34523
|
|
31
|
+
owlplanner-2026.1.26.dist-info/METADATA,sha256=ZHGDl0MzxLspxcYtBCoxp8q1oQKnEZ1qH_gjzInxw1Q,46494
|
|
32
|
+
owlplanner-2026.1.26.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
33
|
+
owlplanner-2026.1.26.dist-info/entry_points.txt,sha256=m2Ql-6VQbotl65wiqx35d3TIh-Tm6Wdjrz3hNdqZKKg,52
|
|
34
|
+
owlplanner-2026.1.26.dist-info/licenses/AUTHORS,sha256=KD6uW6iEOHf51J8DEJ6az-gowFv3NWIpW4zRgqxwuAA,464
|
|
35
|
+
owlplanner-2026.1.26.dist-info/licenses/LICENSE,sha256=IwGE9guuL-ryRPEKi6wFPI_zOhg7zDZbTYuHbSt_SAk,35823
|
|
36
|
+
owlplanner-2026.1.26.dist-info/RECORD,,
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# This is the list of Owlplanner's significant contributors.
|
|
2
|
+
#
|
|
3
|
+
# This does not necessarily list everyone who has contributed code.
|
|
4
|
+
# To see the full list of contributors, see the revision history in
|
|
5
|
+
# source control.
|
|
6
|
+
Martin-D. Lacasse (mdlacasse, original author)
|
|
7
|
+
Robert E. Anderson (NH-RedAnt)
|
|
8
|
+
Clark Jefcoat (hubcity)
|
|
9
|
+
kg333
|
|
10
|
+
John Leonard (jleonard99)
|
|
11
|
+
Benjamin Quinn (blquinn)
|
|
12
|
+
Dale Seng (sengsational)
|
|
13
|
+
Josh Williams (noimjosh)
|
|
14
|
+
Gene Wood (gene1wood)
|
|
15
|
+
|
owlplanner/tax2025.py
DELETED
|
@@ -1,359 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
|
|
3
|
-
Owl/tax2025
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
A retirement planner using linear programming optimization.
|
|
7
|
-
|
|
8
|
-
See companion document for a complete explanation and description
|
|
9
|
-
of all variables and parameters.
|
|
10
|
-
|
|
11
|
-
Module to handle all tax calculations.
|
|
12
|
-
|
|
13
|
-
Copyright © 2024 - Martin-D. Lacasse
|
|
14
|
-
|
|
15
|
-
Disclaimers: This code is for educational purposes only and does not constitute financial advice.
|
|
16
|
-
|
|
17
|
-
"""
|
|
18
|
-
|
|
19
|
-
import numpy as np
|
|
20
|
-
from datetime import date
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
##############################################################################
|
|
24
|
-
# Prepare the data.
|
|
25
|
-
|
|
26
|
-
taxBracketNames = ["10%", "12/15%", "22/25%", "24/28%", "32/33%", "35%", "37/40%"]
|
|
27
|
-
|
|
28
|
-
rates_OBBBA = np.array([0.10, 0.12, 0.22, 0.24, 0.32, 0.35, 0.370])
|
|
29
|
-
rates_preTCJA = np.array([0.10, 0.15, 0.25, 0.28, 0.33, 0.35, 0.396])
|
|
30
|
-
|
|
31
|
-
###############################################################################
|
|
32
|
-
# Start of section where rates need to be actualized every year.
|
|
33
|
-
###############################################################################
|
|
34
|
-
# Single [0] and married filing jointly [1].
|
|
35
|
-
|
|
36
|
-
# These are 2025 current.
|
|
37
|
-
taxBrackets_OBBBA = np.array(
|
|
38
|
-
[
|
|
39
|
-
[11925, 48475, 103350, 197300, 250525, 626350, 9999999],
|
|
40
|
-
[23850, 96950, 206700, 394600, 501050, 751600, 9999999],
|
|
41
|
-
]
|
|
42
|
-
)
|
|
43
|
-
|
|
44
|
-
irmaaBrackets = np.array(
|
|
45
|
-
[
|
|
46
|
-
[0, 106000, 133000, 167000, 200000, 500000],
|
|
47
|
-
[0, 212000, 266000, 334000, 400000, 750000],
|
|
48
|
-
]
|
|
49
|
-
)
|
|
50
|
-
|
|
51
|
-
# Index [0] stores the standard Medicare part B premium.
|
|
52
|
-
# Following values are incremental IRMAA part B monthly fees.
|
|
53
|
-
irmaaFees = 12 * np.array([185.00, 74.00, 111.00, 110.90, 111.00, 37.00])
|
|
54
|
-
|
|
55
|
-
# Make projection for pre-TCJA using 2017 to current year.
|
|
56
|
-
# taxBrackets_2017 = np.array(
|
|
57
|
-
# [ [9325, 37950, 91900, 191650, 416700, 418400, 9999999],
|
|
58
|
-
# [18650, 75900, 153100, 233350, 416700, 470700, 9999999],
|
|
59
|
-
# ])
|
|
60
|
-
#
|
|
61
|
-
# stdDeduction_2017 = [6350, 12700]
|
|
62
|
-
#
|
|
63
|
-
# For 2025, I used a 30.5% adjustment from 2017, rounded to closest 50.
|
|
64
|
-
#
|
|
65
|
-
# These are speculated.
|
|
66
|
-
taxBrackets_preTCJA = np.array(
|
|
67
|
-
[
|
|
68
|
-
[12150, 49550, 119950, 250200, 544000, 546200, 9999999], # Single
|
|
69
|
-
[24350, 99100, 199850, 304600, 543950, 614450, 9999999], # MFJ
|
|
70
|
-
]
|
|
71
|
-
)
|
|
72
|
-
|
|
73
|
-
# These are 2025 current.
|
|
74
|
-
stdDeduction_OBBBA = np.array([15750, 31500]) # Single, MFJ
|
|
75
|
-
# These are speculated (adjusted for inflation).
|
|
76
|
-
stdDeduction_preTCJA = np.array([8300, 16600]) # Single, MFJ
|
|
77
|
-
|
|
78
|
-
# These are current (adjusted for inflation) per individual.
|
|
79
|
-
extra65Deduction = np.array([2000, 1600]) # Single, MFJ
|
|
80
|
-
|
|
81
|
-
# Thresholds setting capital gains brackets 0%, 15%, 20% (adjusted for inflation).
|
|
82
|
-
capGainRates = np.array(
|
|
83
|
-
[
|
|
84
|
-
[48350, 533400],
|
|
85
|
-
[96700, 600050],
|
|
86
|
-
]
|
|
87
|
-
)
|
|
88
|
-
|
|
89
|
-
# Thresholds for net investment income tax (not adjusted for inflation).
|
|
90
|
-
niitThreshold = np.array([200000, 250000])
|
|
91
|
-
niitRate = 0.038
|
|
92
|
-
|
|
93
|
-
# Thresholds for 65+ bonus for circumventing tax on social security.
|
|
94
|
-
bonusThreshold = np.array([75000, 150000])
|
|
95
|
-
|
|
96
|
-
###############################################################################
|
|
97
|
-
# End of section where rates need to be actualized every year.
|
|
98
|
-
###############################################################################
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
def mediVals(yobs, horizons, gamma_n, Nn, Nq):
|
|
102
|
-
"""
|
|
103
|
-
Return tuple (nm, L, C) of year index when Medicare starts and vectors L, and C
|
|
104
|
-
defining end points of constant piecewise linear functions representing IRMAA fees.
|
|
105
|
-
"""
|
|
106
|
-
thisyear = date.today().year
|
|
107
|
-
assert Nq == len(irmaaFees), f"Inconsistent value of Nq: {Nq}."
|
|
108
|
-
assert Nq == len(irmaaBrackets[0]), "Inconsistent IRMAA brackets array."
|
|
109
|
-
Ni = len(yobs)
|
|
110
|
-
# What index year will Medicare start? 65 - age.
|
|
111
|
-
nm = 65 - (thisyear - yobs)
|
|
112
|
-
nm = np.min(nm)
|
|
113
|
-
# Has it already started?
|
|
114
|
-
nm = max(0, nm)
|
|
115
|
-
Nmed = Nn - nm
|
|
116
|
-
|
|
117
|
-
L = np.zeros((Nmed, Nq-1))
|
|
118
|
-
C = np.zeros((Nmed, Nq))
|
|
119
|
-
|
|
120
|
-
# Year starts at offset nm in the plan.
|
|
121
|
-
for nn in range(Nmed):
|
|
122
|
-
imed = 0
|
|
123
|
-
n = nm + nn
|
|
124
|
-
if thisyear + n - yobs[0] >= 65 and n < horizons[0]:
|
|
125
|
-
imed += 1
|
|
126
|
-
if Ni == 2 and thisyear + n - yobs[1] >= 65 and n < horizons[1]:
|
|
127
|
-
imed += 1
|
|
128
|
-
if imed:
|
|
129
|
-
status = 0 if Ni == 1 else 1 if n < horizons[0] and n < horizons[1] else 0
|
|
130
|
-
L[nn] = gamma_n[n] * irmaaBrackets[status][1:]
|
|
131
|
-
C[nn] = imed * gamma_n[n] * irmaaFees
|
|
132
|
-
else:
|
|
133
|
-
raise RuntimeError("mediVals: This should never happen.")
|
|
134
|
-
|
|
135
|
-
return nm, L, C
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
def capitalGainTaxRate(Ni, magi_n, gamma_n, nd, Nn):
|
|
139
|
-
"""
|
|
140
|
-
Return an array of decimal rates for capital gains.
|
|
141
|
-
Parameter nd is the index year of first passing of a spouse, if applicable,
|
|
142
|
-
nd == Nn for single individuals.
|
|
143
|
-
"""
|
|
144
|
-
status = Ni - 1
|
|
145
|
-
cgRate_n = np.zeros(Nn)
|
|
146
|
-
|
|
147
|
-
for n in range(Nn):
|
|
148
|
-
if status and n == nd:
|
|
149
|
-
status -= 1
|
|
150
|
-
|
|
151
|
-
if magi_n[n] > gamma_n[n] * capGainRates[status][1]:
|
|
152
|
-
cgRate_n[n] = 0.20
|
|
153
|
-
elif magi_n[n] > gamma_n[n] * capGainRates[status][0]:
|
|
154
|
-
cgRate_n[n] = 0.15
|
|
155
|
-
|
|
156
|
-
return cgRate_n
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
def mediCosts(yobs, horizons, magi, prevmagi, gamma_n, Nn):
|
|
160
|
-
"""
|
|
161
|
-
Compute Medicare costs directly.
|
|
162
|
-
"""
|
|
163
|
-
thisyear = date.today().year
|
|
164
|
-
Ni = len(yobs)
|
|
165
|
-
costs = np.zeros(Nn)
|
|
166
|
-
for n in range(Nn):
|
|
167
|
-
status = 0 if Ni == 1 else 1 if n < horizons[0] and n < horizons[1] else 0
|
|
168
|
-
for i in range(Ni):
|
|
169
|
-
if thisyear + n - yobs[i] >= 65 and n < horizons[i]:
|
|
170
|
-
# Start with the (inflation-adjusted) basic Medicare part B premium.
|
|
171
|
-
costs[n] += gamma_n[n] * irmaaFees[0]
|
|
172
|
-
if n < 2:
|
|
173
|
-
mymagi = prevmagi[n]
|
|
174
|
-
else:
|
|
175
|
-
mymagi = magi[n - 2]
|
|
176
|
-
for q in range(1, 6):
|
|
177
|
-
if mymagi > gamma_n[n] * irmaaBrackets[status][q]:
|
|
178
|
-
costs[n] += gamma_n[n] * irmaaFees[q]
|
|
179
|
-
|
|
180
|
-
return costs
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
def taxParams(yobs, i_d, n_d, N_n, gamma_n, MAGI_n, yOBBBA=2099):
|
|
184
|
-
"""
|
|
185
|
-
Input is year of birth, index of shortest-lived individual,
|
|
186
|
-
lifespan of shortest-lived individual, total number of years
|
|
187
|
-
in the plan, and the year that preTCJA rates might come back.
|
|
188
|
-
|
|
189
|
-
It returns 3 time series:
|
|
190
|
-
1) Standard deductions at year n (sigma_n).
|
|
191
|
-
2) Tax rate in year n (theta_tn)
|
|
192
|
-
3) Delta from top to bottom of tax brackets (Delta_tn)
|
|
193
|
-
This is pure speculation on future values.
|
|
194
|
-
Returned values are not indexed for inflation.
|
|
195
|
-
"""
|
|
196
|
-
# Compute the deltas in-place between brackets, starting from the end.
|
|
197
|
-
deltaBrackets_OBBBA = np.array(taxBrackets_OBBBA)
|
|
198
|
-
deltaBrackets_preTCJA = np.array(taxBrackets_preTCJA)
|
|
199
|
-
for t in range(6, 0, -1):
|
|
200
|
-
for i in range(2):
|
|
201
|
-
deltaBrackets_OBBBA[i, t] -= deltaBrackets_OBBBA[i, t - 1]
|
|
202
|
-
deltaBrackets_preTCJA[i, t] -= deltaBrackets_preTCJA[i, t - 1]
|
|
203
|
-
|
|
204
|
-
# Prepare the 3 arrays to return - use transpose for easy slicing.
|
|
205
|
-
sigmaBar = np.zeros((N_n))
|
|
206
|
-
Delta = np.zeros((N_n, 7))
|
|
207
|
-
theta = np.zeros((N_n, 7))
|
|
208
|
-
|
|
209
|
-
filingStatus = len(yobs) - 1
|
|
210
|
-
souls = list(range(len(yobs)))
|
|
211
|
-
thisyear = date.today().year
|
|
212
|
-
|
|
213
|
-
for n in range(N_n):
|
|
214
|
-
# First check if shortest-lived individual is still with us.
|
|
215
|
-
if n == n_d:
|
|
216
|
-
souls.remove(i_d)
|
|
217
|
-
filingStatus -= 1
|
|
218
|
-
|
|
219
|
-
if thisyear + n < yOBBBA:
|
|
220
|
-
sigmaBar[n] = stdDeduction_OBBBA[filingStatus] * gamma_n[n]
|
|
221
|
-
Delta[n, :] = deltaBrackets_OBBBA[filingStatus, :]
|
|
222
|
-
else:
|
|
223
|
-
sigmaBar[n] = stdDeduction_preTCJA[filingStatus] * gamma_n[n]
|
|
224
|
-
Delta[n, :] = deltaBrackets_preTCJA[filingStatus, :]
|
|
225
|
-
|
|
226
|
-
# Add 65+ additional exemption(s) and "bonus" phasing out.
|
|
227
|
-
for i in souls:
|
|
228
|
-
if thisyear + n - yobs[i] >= 65:
|
|
229
|
-
sigmaBar[n] += extra65Deduction[filingStatus] * gamma_n[n]
|
|
230
|
-
if thisyear + n <= 2028:
|
|
231
|
-
sigmaBar[n] += 6000 * max(0, 1 - 0.06*max(0, MAGI_n[n] - bonusThreshold[filingStatus]))
|
|
232
|
-
|
|
233
|
-
# Fill in future tax rates for year n.
|
|
234
|
-
if thisyear + n < yOBBBA:
|
|
235
|
-
theta[n, :] = rates_OBBBA[:]
|
|
236
|
-
else:
|
|
237
|
-
theta[n, :] = rates_preTCJA[:]
|
|
238
|
-
|
|
239
|
-
Delta = Delta.transpose()
|
|
240
|
-
theta = theta.transpose()
|
|
241
|
-
|
|
242
|
-
# Return series unadjusted for inflation, except for sigmaBar, in STD order.
|
|
243
|
-
return sigmaBar, theta, Delta
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
def taxBrackets(N_i, n_d, N_n, yOBBBA=2099):
|
|
247
|
-
"""
|
|
248
|
-
Return dictionary containing future tax brackets
|
|
249
|
-
unadjusted for inflation for plotting.
|
|
250
|
-
"""
|
|
251
|
-
if not (0 < N_i <= 2):
|
|
252
|
-
raise ValueError(f"Cannot process {N_i} individuals.")
|
|
253
|
-
|
|
254
|
-
n_d = min(n_d, N_n)
|
|
255
|
-
status = N_i - 1
|
|
256
|
-
|
|
257
|
-
# Number of years left in OBBBA from this year.
|
|
258
|
-
thisyear = date.today().year
|
|
259
|
-
if yOBBBA < thisyear:
|
|
260
|
-
raise ValueError(f"Expiration year {yOBBBA} cannot be in the past.")
|
|
261
|
-
|
|
262
|
-
ytc = yOBBBA - thisyear
|
|
263
|
-
|
|
264
|
-
data = {}
|
|
265
|
-
for t in range(len(taxBracketNames) - 1):
|
|
266
|
-
array = np.zeros(N_n)
|
|
267
|
-
for n in range(N_n):
|
|
268
|
-
stat = status if n < n_d else 0
|
|
269
|
-
array[n] = taxBrackets_OBBBA[stat][t] if n < ytc else taxBrackets_preTCJA[stat][t]
|
|
270
|
-
|
|
271
|
-
data[taxBracketNames[t]] = array
|
|
272
|
-
|
|
273
|
-
return data
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
def computeNIIT(N_i, MAGI_n, I_n, Q_n, n_d, N_n):
|
|
277
|
-
"""
|
|
278
|
-
Compute ACA tax on Dividends (Q) and Interests (I).
|
|
279
|
-
For accounting for rent and/or trust income, one can easily add a column
|
|
280
|
-
to the Wages and Contributions file and add yearly amount to Q_n + I_n below.
|
|
281
|
-
"""
|
|
282
|
-
J_n = np.zeros(N_n)
|
|
283
|
-
status = N_i - 1
|
|
284
|
-
|
|
285
|
-
for n in range(N_n):
|
|
286
|
-
if status and n == n_d:
|
|
287
|
-
status -= 1
|
|
288
|
-
|
|
289
|
-
Gmax = niitThreshold[status]
|
|
290
|
-
if MAGI_n[n] > Gmax:
|
|
291
|
-
J_n[n] = niitRate * min(MAGI_n[n] - Gmax, I_n[n] + Q_n[n])
|
|
292
|
-
|
|
293
|
-
return J_n
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
def rho_in(yobs, N_n):
|
|
297
|
-
"""
|
|
298
|
-
Return Required Minimum Distribution fractions for each individual.
|
|
299
|
-
This implementation does not support spouses with more than
|
|
300
|
-
10-year difference.
|
|
301
|
-
It starts at age 73 until it goes to 75 in 2033.
|
|
302
|
-
"""
|
|
303
|
-
# Notice that table starts at age 72.
|
|
304
|
-
rmdTable = [
|
|
305
|
-
27.4,
|
|
306
|
-
26.5,
|
|
307
|
-
25.5,
|
|
308
|
-
24.6,
|
|
309
|
-
23.7,
|
|
310
|
-
22.9,
|
|
311
|
-
22.0,
|
|
312
|
-
21.1,
|
|
313
|
-
20.2,
|
|
314
|
-
19.4,
|
|
315
|
-
18.5,
|
|
316
|
-
17.7,
|
|
317
|
-
16.8,
|
|
318
|
-
16.0,
|
|
319
|
-
15.2,
|
|
320
|
-
14.4,
|
|
321
|
-
13.7,
|
|
322
|
-
12.9,
|
|
323
|
-
12.2,
|
|
324
|
-
11.5,
|
|
325
|
-
10.8,
|
|
326
|
-
10.1,
|
|
327
|
-
9.5,
|
|
328
|
-
8.9,
|
|
329
|
-
8.4,
|
|
330
|
-
7.8,
|
|
331
|
-
7.3,
|
|
332
|
-
6.8,
|
|
333
|
-
6.4,
|
|
334
|
-
6.0,
|
|
335
|
-
5.6,
|
|
336
|
-
5.2,
|
|
337
|
-
4.9,
|
|
338
|
-
4.6,
|
|
339
|
-
]
|
|
340
|
-
|
|
341
|
-
N_i = len(yobs)
|
|
342
|
-
if N_i == 2 and abs(yobs[0] - yobs[1]) > 10:
|
|
343
|
-
raise RuntimeError("RMD: Unsupported age difference of more than 10 years.")
|
|
344
|
-
|
|
345
|
-
rho = np.zeros((N_i, N_n))
|
|
346
|
-
thisyear = date.today().year
|
|
347
|
-
for i in range(N_i):
|
|
348
|
-
agenow = thisyear - yobs[i]
|
|
349
|
-
# Account for increase of RMD age between 2023 and 2032.
|
|
350
|
-
yrmd = 70 if yobs[i] < 1949 else 72 if 1949 <= yobs[i] <= 1950 else 73 if 1951 <= yobs[i] <= 1959 else 75
|
|
351
|
-
for n in range(N_n):
|
|
352
|
-
yage = agenow + n
|
|
353
|
-
|
|
354
|
-
if yage < yrmd:
|
|
355
|
-
pass # rho[i][n] = 0
|
|
356
|
-
else:
|
|
357
|
-
rho[i][n] = 1.0 / rmdTable[yage - 72]
|
|
358
|
-
|
|
359
|
-
return rho
|