owlplanner 2025.6.3__tar.gz → 2025.6.21__tar.gz
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-2025.6.3 → owlplanner-2025.6.21}/PKG-INFO +6 -4
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/README.md +5 -3
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/docs/owl.pdf +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/docs/owl.tex +19 -1
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/pyproject.toml +1 -1
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/requirements.txt +1 -1
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/src/owlplanner/plan.py +63 -19
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/src/owlplanner/timelists.py +9 -9
- owlplanner-2025.6.21/src/owlplanner/version.py +1 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/tests/test_repro.py +15 -15
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/ui/About_Owl.py +6 -5
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/ui/Current_Assets.py +15 -9
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/ui/Documentation.py +69 -40
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/ui/Optimization_Parameters.py +6 -6
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/ui/Output_Files.py +13 -13
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/ui/Quick_Start.py +4 -4
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/ui/Rates_Selection.py +2 -2
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/ui/Wages_and_Contributions.py +74 -72
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/ui/main.py +1 -1
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/ui/owlbridge.py +3 -3
- owlplanner-2025.6.3/src/owlplanner/version.py +0 -1
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/.devcontainer/devcontainer.json +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/.flake8 +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/.gitattributes +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/.github/workflows/github-actions-runtests.yml +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/.gitignore +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/.streamlit/config.toml +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/.streamlit/fullconfig.toml +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/INSTALL.md +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/LICENSE +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/USER_GUIDE.md +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/docker/Dockerfile.build +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/docker/Dockerfile.run +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/docker/README.md +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/docker/buildentrypoint.sh +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/docker/docker-compose.yml +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/docker/runentrypoint.sh +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/docs/images/AD-taxDef.png +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/docs/images/AD-taxFree.png +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/docs/images/AD-taxable.png +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/docs/images/Hist_Bequest.png +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/docs/images/Hist_Spending.png +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/docs/images/MC-tutorial2a.png +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/docs/images/MC-tutorial2b.png +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/docs/images/OwlUI.png +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/docs/images/allocations.png +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/docs/images/owl.png +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/docs/images/profile.png +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/docs/images/ratesCorrelations.png +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/docs/images/ratesPlot.png +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/docs/images/savingsPlot.png +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/docs/images/sourcesPlot.png +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/docs/images/spendingPlot.png +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/docs/images/taxIncomePlot.png +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/docs/images/taxesPlot.png +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/examples/case_jack+jill.toml +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/examples/case_joe.toml +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/examples/case_john+sally.toml +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/examples/case_jon+jane.toml +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/examples/case_kim+sam-bequest.toml +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/examples/case_kim+sam-spending.toml +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/examples/jack+jill.xlsx +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/examples/joe.xlsx +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/examples/john+sally.xlsx +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/examples/jon+jane.xlsx +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/examples/template.xlsx +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/notebooks/john+sally.ipynb +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/notebooks/kim+sam.ipynb +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/notebooks/template.ipynb +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/notebooks/tutorial_1.ipynb +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/notebooks/tutorial_2.ipynb +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/notebooks/tutorial_3.ipynb +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/owlplanner.cmd +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/owlplanner.sh +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/pytest.ini +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/src/owlplanner/__init__.py +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/src/owlplanner/abcapi.py +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/src/owlplanner/config.py +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/src/owlplanner/data/__init__.py +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/src/owlplanner/data/rates.csv +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/src/owlplanner/mylogging.py +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/src/owlplanner/plotting/__init__.py +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/src/owlplanner/plotting/base.py +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/src/owlplanner/plotting/factory.py +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/src/owlplanner/plotting/matplotlib_backend.py +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/src/owlplanner/plotting/plotly_backend.py +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/src/owlplanner/progress.py +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/src/owlplanner/rates.py +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/src/owlplanner/tax2025.py +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/src/owlplanner/utils.py +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/tests/test_logger.py +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/tests/test_regressions.py +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/tests/test_toml_cases.py +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/tests/test_ui_asset_allocation.py +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/tests/test_ui_compare_summaries.py +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/tests/test_ui_sskeys.py +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/tests/test_units.py +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/ui/Asset_Allocation.py +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/ui/Create_Case.py +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/ui/Fixed_Income.py +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/ui/Graphs.py +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/ui/Historical_Range.py +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/ui/Logs.py +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/ui/Monte_Carlo.py +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/ui/README.md +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/ui/Settings.py +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/ui/Worksheets.py +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/ui/__init__.py +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/ui/progress.py +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/ui/sskeys.py +0 -0
- {owlplanner-2025.6.3 → owlplanner-2025.6.21}/ui/tomlexamples.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: owlplanner
|
|
3
|
-
Version: 2025.6.
|
|
3
|
+
Version: 2025.6.21
|
|
4
4
|
Summary: Owl: 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
|
|
@@ -837,6 +837,8 @@ which are all tracked separately for married individuals. Asset transition to th
|
|
|
837
837
|
is done according to beneficiary fractions for each type of savings account.
|
|
838
838
|
Tax status covers married filing jointly and single, depending on the number of individuals reported.
|
|
839
839
|
|
|
840
|
+
Maturation rules for Roth contributions and conversions are implemented as constraints
|
|
841
|
+
limiting withdrawal amounts to cover Roth account balances for 5 years after the events.
|
|
840
842
|
Medicare and IRMAA calculations are performed through a self-consistent loop on cash flow constraints.
|
|
841
843
|
Future values are simple projections of current values with the assumed inflation rates.
|
|
842
844
|
|
|
@@ -885,7 +887,7 @@ assets to support, even with no estate being left.
|
|
|
885
887
|
- Streamlit Community Cloud [Streamlit](https://streamlit.io)
|
|
886
888
|
- Contributors: Josh (noimjosh@gmail.com) for Docker image code,
|
|
887
889
|
Dale Seng (sengsational) for great insights and suggestions,
|
|
888
|
-
Robert E. Anderson (NH-RedAnt) for bug fixes and suggestions.
|
|
890
|
+
Robert E. Anderson (NH-RedAnt) for bug fixes and suggestions, Clark Jefcoat (hubcity) for fruitful interactions.
|
|
889
891
|
|
|
890
892
|
---------------------------------------------------------------------
|
|
891
893
|
|
|
@@ -893,8 +895,8 @@ Copyright © 2024 - Martin-D. Lacasse
|
|
|
893
895
|
|
|
894
896
|
Disclaimers: This code is for educatonal purposes only and does not constitute financial advice.
|
|
895
897
|
|
|
896
|
-
Code output has been verified with analytical solutions and
|
|
897
|
-
Nevertheless, accuracy of results
|
|
898
|
+
Code output has been verified with analytical solutions when applicable, and comparative approaches otherwise.
|
|
899
|
+
Nevertheless, accuracy of results is not guaranteed.
|
|
898
900
|
|
|
899
901
|
--------------------------------------------------------
|
|
900
902
|
|
|
@@ -131,6 +131,8 @@ which are all tracked separately for married individuals. Asset transition to th
|
|
|
131
131
|
is done according to beneficiary fractions for each type of savings account.
|
|
132
132
|
Tax status covers married filing jointly and single, depending on the number of individuals reported.
|
|
133
133
|
|
|
134
|
+
Maturation rules for Roth contributions and conversions are implemented as constraints
|
|
135
|
+
limiting withdrawal amounts to cover Roth account balances for 5 years after the events.
|
|
134
136
|
Medicare and IRMAA calculations are performed through a self-consistent loop on cash flow constraints.
|
|
135
137
|
Future values are simple projections of current values with the assumed inflation rates.
|
|
136
138
|
|
|
@@ -179,7 +181,7 @@ assets to support, even with no estate being left.
|
|
|
179
181
|
- Streamlit Community Cloud [Streamlit](https://streamlit.io)
|
|
180
182
|
- Contributors: Josh (noimjosh@gmail.com) for Docker image code,
|
|
181
183
|
Dale Seng (sengsational) for great insights and suggestions,
|
|
182
|
-
Robert E. Anderson (NH-RedAnt) for bug fixes and suggestions.
|
|
184
|
+
Robert E. Anderson (NH-RedAnt) for bug fixes and suggestions, Clark Jefcoat (hubcity) for fruitful interactions.
|
|
183
185
|
|
|
184
186
|
---------------------------------------------------------------------
|
|
185
187
|
|
|
@@ -187,8 +189,8 @@ Copyright © 2024 - Martin-D. Lacasse
|
|
|
187
189
|
|
|
188
190
|
Disclaimers: This code is for educatonal purposes only and does not constitute financial advice.
|
|
189
191
|
|
|
190
|
-
Code output has been verified with analytical solutions and
|
|
191
|
-
Nevertheless, accuracy of results
|
|
192
|
+
Code output has been verified with analytical solutions when applicable, and comparative approaches otherwise.
|
|
193
|
+
Nevertheless, accuracy of results is not guaranteed.
|
|
192
194
|
|
|
193
195
|
--------------------------------------------------------
|
|
194
196
|
|
|
Binary file
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
\begin{document}
|
|
33
33
|
\title{Formulation of the optimization model in Owl}
|
|
34
34
|
\author{Martin-D. Lacasse}
|
|
35
|
-
\date{
|
|
35
|
+
\date{June 13, 2025}
|
|
36
36
|
\maketitle
|
|
37
37
|
\thispagestyle{fancy}
|
|
38
38
|
\fancyfoot[R]{\copyright\ 2024 - Martin-D. Lacasse}
|
|
@@ -709,6 +709,24 @@ add the market returns to the savings balances.
|
|
|
709
709
|
x_{in} \le \min(b_{i1n}, x_{max}).
|
|
710
710
|
\end{equation}
|
|
711
711
|
|
|
712
|
+
Roth conversions are also governed by a 5-year maturation rule for withdrawals. This means
|
|
713
|
+
that withdrawals will need to be smaller than the balance minus the sum of all contributions
|
|
714
|
+
and conversions that happened over the last 5 years. For that purpose, the Wages and Contributions
|
|
715
|
+
file which stores $\omega_{in}, \kappa_{ijn}, \ldots$, will go back 5 years, and the Roth
|
|
716
|
+
conversions in that year range will be interpreted as having happened. We will use
|
|
717
|
+
these arrays to store previous conversions and contributions at the end of the array so that
|
|
718
|
+
they can be retrieved with negative indices in Python. Mathematically, we want that
|
|
719
|
+
\begin{equation}
|
|
720
|
+
w_{i2n} \le b_{i2n} - \sum_{n'=n-5}^n [ \kappa{i2n'} + x_{in'}.
|
|
721
|
+
\end{equation}
|
|
722
|
+
However, conversions are sometimes a variable $x_{in}$
|
|
723
|
+
and sometimes a parameter $X_{in}$, depending on the sign of $n$.
|
|
724
|
+
This leads to
|
|
725
|
+
\begin{equation}
|
|
726
|
+
b_{i2n} - w_{i2n} - \sum_{n'=\max(n-5, 0}^{n-1} x_{in'}
|
|
727
|
+
\ge \sum_{n'=n-5}^{\min(-1, n-1)} X_{in} + \sum_{n'=n-5}^{n-1} \kappa{i2n'}.
|
|
728
|
+
\end{equation}
|
|
729
|
+
|
|
712
730
|
\paragraph*{Net spending}
|
|
713
731
|
For calculating the net spending $g_n$, we consider the cash flow of all withdrawals,
|
|
714
732
|
wages, social security and pension benefits, and big-ticket items.
|
|
@@ -304,8 +304,8 @@ class Plan(object):
|
|
|
304
304
|
# Parameters from timeLists initialized to zero.
|
|
305
305
|
self.omega_in = np.zeros((self.N_i, self.N_n))
|
|
306
306
|
self.Lambda_in = np.zeros((self.N_i, self.N_n))
|
|
307
|
-
self.myRothX_in = np.zeros((self.N_i, self.N_n))
|
|
308
|
-
self.kappa_ijn = np.zeros((self.N_i, self.N_j, self.N_n))
|
|
307
|
+
self.myRothX_in = np.zeros((self.N_i, self.N_n + 5))
|
|
308
|
+
self.kappa_ijn = np.zeros((self.N_i, self.N_j, self.N_n + 5))
|
|
309
309
|
|
|
310
310
|
# Previous 3 years for Medicare.
|
|
311
311
|
self.prevMAGI = np.zeros((2))
|
|
@@ -920,14 +920,17 @@ class Plan(object):
|
|
|
920
920
|
# Now fill in parameters which are in $.
|
|
921
921
|
for i, iname in enumerate(self.inames):
|
|
922
922
|
h = self.horizons[i]
|
|
923
|
-
self.omega_in[i, :h] = self.timeLists[iname]["anticipated wages"].iloc[:h]
|
|
924
|
-
self.
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
self.kappa_ijn[i,
|
|
929
|
-
self.
|
|
930
|
-
self.
|
|
923
|
+
self.omega_in[i, :h] = self.timeLists[iname]["anticipated wages"].iloc[5:5+h]
|
|
924
|
+
self.Lambda_in[i, :h] = self.timeLists[iname]["big-ticket items"].iloc[5:5+h]
|
|
925
|
+
|
|
926
|
+
# Values for last 5 years of Roth conversion and contributions stored at the end
|
|
927
|
+
# of array and accessed with negative index.
|
|
928
|
+
self.kappa_ijn[i, 0, :h+5] = np.roll(self.timeLists[iname]["taxable ctrb"], -5)
|
|
929
|
+
self.kappa_ijn[i, 1, :h+5] = np.roll(self.timeLists[iname]["401k ctrb"], -5)
|
|
930
|
+
self.kappa_ijn[i, 1, :h+5] += np.roll(self.timeLists[iname]["IRA ctrb"], -5)
|
|
931
|
+
self.kappa_ijn[i, 2, :h+5] = np.roll(self.timeLists[iname]["Roth 401k ctrb"], -5)
|
|
932
|
+
self.kappa_ijn[i, 2, :h+5] += np.roll(self.timeLists[iname]["Roth IRA ctrb"], -5)
|
|
933
|
+
self.myRothX_in[i, :h+5] = np.roll(self.timeLists[iname]["Roth conv"], -5)
|
|
931
934
|
|
|
932
935
|
self.caseStatus = "modified"
|
|
933
936
|
|
|
@@ -984,8 +987,9 @@ class Plan(object):
|
|
|
984
987
|
]
|
|
985
988
|
for i, iname in enumerate(self.inames):
|
|
986
989
|
h = self.horizons[i]
|
|
987
|
-
df = pd.DataFrame(0, index=np.arange(h), columns=cols)
|
|
988
|
-
df["year"] = self.year_n[:h]
|
|
990
|
+
df = pd.DataFrame(0, index=np.arange(0, h+5), columns=cols)
|
|
991
|
+
# df["year"] = self.year_n[:h]
|
|
992
|
+
df["year"] = np.arange(self.year_n[0] - 5, self.year_n[h-1]+1)
|
|
989
993
|
self.timeLists[iname] = df
|
|
990
994
|
|
|
991
995
|
self.caseStatus = "modified"
|
|
@@ -1086,6 +1090,7 @@ class Plan(object):
|
|
|
1086
1090
|
self._add_standard_exemption_bounds()
|
|
1087
1091
|
self._add_defunct_constraints()
|
|
1088
1092
|
self._add_roth_conversion_constraints(options)
|
|
1093
|
+
self._add_roth_maturation_constraints()
|
|
1089
1094
|
self._add_withdrawal_limits()
|
|
1090
1095
|
self._add_conversion_limits()
|
|
1091
1096
|
self._add_objective_constraints(objective, options)
|
|
@@ -1127,6 +1132,45 @@ class Plan(object):
|
|
|
1127
1132
|
for j in range(self.N_j):
|
|
1128
1133
|
self.B.setRange(_q3(self.C["w"], self.i_d, j, n, self.N_i, self.N_j, self.N_n), 0, 0)
|
|
1129
1134
|
|
|
1135
|
+
def _add_roth_maturation_constraints(self):
|
|
1136
|
+
"""
|
|
1137
|
+
Withdrawals from Roth accounts are subject to the 5-year rule for conversion.
|
|
1138
|
+
Conversions and gains are subject to the 5-year rule since conversion.
|
|
1139
|
+
Contributions can be withdrawn at any time (without 59.5 penalty) but
|
|
1140
|
+
gains on contributions are subject to the 5-year rule since the opening of the account.
|
|
1141
|
+
A retainer is put on all conversions and associated gains, and gains on all recent contributions.
|
|
1142
|
+
"""
|
|
1143
|
+
# Assume 10% per year for contributions and conversions for past 5 years.
|
|
1144
|
+
# Future years will use the assumed returns.
|
|
1145
|
+
oldTau1 = 1.10
|
|
1146
|
+
for i in range(self.N_i):
|
|
1147
|
+
h = self.horizons[i]
|
|
1148
|
+
for n in range(h):
|
|
1149
|
+
rhs = 0
|
|
1150
|
+
# To add compounded gains to original amount.
|
|
1151
|
+
cgains = 1
|
|
1152
|
+
row = self.A.newRow()
|
|
1153
|
+
row.addElem(_q3(self.C["b"], i, 2, n, self.N_i, self.N_j, self.N_n + 1), 1)
|
|
1154
|
+
row.addElem(_q3(self.C["w"], i, 2, n, self.N_i, self.N_j, self.N_n), -1)
|
|
1155
|
+
for dn in range(1, 6):
|
|
1156
|
+
nn = n - dn
|
|
1157
|
+
if nn < 0: # Past of future is in the past:
|
|
1158
|
+
# Parameters are stored at the end of contributions and conversions arrays.
|
|
1159
|
+
cgains *= oldTau1
|
|
1160
|
+
# If only an contribution - without conversion.
|
|
1161
|
+
# rhs += (cgains - 1) * self.kappa_ijn[i, 2, nn] + cgains * self.myRothX_in[i, nn]
|
|
1162
|
+
rhs += cgains * self.kappa_ijn[i, 2, nn] + cgains * self.myRothX_in[i, nn]
|
|
1163
|
+
else: # Past of future is in the future: use variables and parameters.
|
|
1164
|
+
ksum2 = np.sum(self.alpha_ijkn[i, 2, :, nn] * self.tau_kn[:, nn], axis=0)
|
|
1165
|
+
Tau1 = 1 + ksum2
|
|
1166
|
+
cgains *= Tau1
|
|
1167
|
+
row.addElem(_q2(self.C["x"], i, nn, self.N_i, self.N_n), -cgains)
|
|
1168
|
+
# If only a contribution - without conversion.
|
|
1169
|
+
# rhs += (cgains - 1) * self.kappa_ijn[i, 2, nn]
|
|
1170
|
+
rhs += cgains * self.kappa_ijn[i, 2, nn]
|
|
1171
|
+
|
|
1172
|
+
self.A.addRow(row, rhs, np.inf)
|
|
1173
|
+
|
|
1130
1174
|
def _add_roth_conversion_constraints(self, options):
|
|
1131
1175
|
if "maxRothConversion" in options and options["maxRothConversion"] == "file":
|
|
1132
1176
|
for i in range(self.N_i):
|
|
@@ -1949,7 +1993,7 @@ class Plan(object):
|
|
|
1949
1993
|
self.Q_n = np.sum(
|
|
1950
1994
|
(
|
|
1951
1995
|
self.mu
|
|
1952
|
-
* (self.b_ijn[:, 0, :-1] - self.w_ijn[:, 0, :] + self.d_in[:, :] + 0.5 * self.kappa_ijn[:, 0, :])
|
|
1996
|
+
* (self.b_ijn[:, 0, :-1] - self.w_ijn[:, 0, :] + self.d_in[:, :] + 0.5 * self.kappa_ijn[:, 0, :Nn])
|
|
1953
1997
|
+ tau_0prev * self.w_ijn[:, 0, :]
|
|
1954
1998
|
)
|
|
1955
1999
|
* self.alpha_ijkn[:, 0, 0, :-1],
|
|
@@ -2370,7 +2414,7 @@ class Plan(object):
|
|
|
2370
2414
|
the default behavior of setDefaultPlots().
|
|
2371
2415
|
"""
|
|
2372
2416
|
value = self._checkValue(value)
|
|
2373
|
-
title = self._name + "\
|
|
2417
|
+
title = self._name + "\nFederal Income Tax"
|
|
2374
2418
|
if tag:
|
|
2375
2419
|
title += " - " + tag
|
|
2376
2420
|
# All taxes: ordinary income and dividends.
|
|
@@ -2489,16 +2533,16 @@ class Plan(object):
|
|
|
2489
2533
|
# Account balances except final year.
|
|
2490
2534
|
accDic = {
|
|
2491
2535
|
"taxable bal": self.b_ijn[:, 0, :-1],
|
|
2492
|
-
"taxable ctrb": self.kappa_ijn[:, 0, :],
|
|
2536
|
+
"taxable ctrb": self.kappa_ijn[:, 0, :self.N_n],
|
|
2493
2537
|
"taxable dep": self.d_in,
|
|
2494
2538
|
"taxable wdrwl": self.w_ijn[:, 0, :],
|
|
2495
2539
|
"tax-deferred bal": self.b_ijn[:, 1, :-1],
|
|
2496
|
-
"tax-deferred ctrb": self.kappa_ijn[:, 1, :],
|
|
2540
|
+
"tax-deferred ctrb": self.kappa_ijn[:, 1, :self.N_n],
|
|
2497
2541
|
"tax-deferred wdrwl": self.w_ijn[:, 1, :],
|
|
2498
2542
|
"(included RMDs)": self.rmd_in[:, :],
|
|
2499
2543
|
"Roth conv": self.x_in,
|
|
2500
2544
|
"tax-free bal": self.b_ijn[:, 2, :-1],
|
|
2501
|
-
"tax-free ctrb": self.kappa_ijn[:, 2, :],
|
|
2545
|
+
"tax-free ctrb": self.kappa_ijn[:, 2, :self.N_n],
|
|
2502
2546
|
"tax-free wdrwl": self.w_ijn[:, 2, :],
|
|
2503
2547
|
}
|
|
2504
2548
|
for i in range(self.N_i):
|
|
@@ -2595,12 +2639,12 @@ class Plan(object):
|
|
|
2595
2639
|
planData[self.inames[i] + " txbl dep"] = self.d_in[i, :]
|
|
2596
2640
|
planData[self.inames[i] + " txbl wrdwl"] = self.w_ijn[i, 0, :]
|
|
2597
2641
|
planData[self.inames[i] + " tx-def bal"] = self.b_ijn[i, 1, :-1]
|
|
2598
|
-
planData[self.inames[i] + " tx-def ctrb"] = self.kappa_ijn[i, 1, :]
|
|
2642
|
+
planData[self.inames[i] + " tx-def ctrb"] = self.kappa_ijn[i, 1, :self.N_n]
|
|
2599
2643
|
planData[self.inames[i] + " tx-def wdrl"] = self.w_ijn[i, 1, :]
|
|
2600
2644
|
planData[self.inames[i] + " (RMD)"] = self.rmd_in[i, :]
|
|
2601
2645
|
planData[self.inames[i] + " Roth conv"] = self.x_in[i, :]
|
|
2602
2646
|
planData[self.inames[i] + " tx-free bal"] = self.b_ijn[i, 2, :-1]
|
|
2603
|
-
planData[self.inames[i] + " tx-free ctrb"] = self.kappa_ijn[i, 2, :]
|
|
2647
|
+
planData[self.inames[i] + " tx-free ctrb"] = self.kappa_ijn[i, 2, :self.N_n]
|
|
2604
2648
|
planData[self.inames[i] + " tax-free wdrwl"] = self.w_ijn[i, 2, :]
|
|
2605
2649
|
planData[self.inames[i] + " big-ticket items"] = self.Lambda_in[i, :]
|
|
2606
2650
|
|
|
@@ -21,7 +21,7 @@ import pandas as pd
|
|
|
21
21
|
|
|
22
22
|
|
|
23
23
|
# Expected headers in each excel sheet, one per individual.
|
|
24
|
-
|
|
24
|
+
_timeHorizonItems = [
|
|
25
25
|
"year",
|
|
26
26
|
"anticipated wages",
|
|
27
27
|
"taxable ctrb",
|
|
@@ -59,14 +59,14 @@ def read(finput, inames, horizons, mylog):
|
|
|
59
59
|
raise Exception(f"Could not read file {finput}: {e}.") from e
|
|
60
60
|
streamName = f"file '{finput}'"
|
|
61
61
|
|
|
62
|
-
timeLists =
|
|
62
|
+
timeLists = _condition(dfDict, inames, horizons, mylog)
|
|
63
63
|
|
|
64
64
|
mylog.vprint(f"Successfully read time horizons from {streamName}.")
|
|
65
65
|
|
|
66
66
|
return finput, timeLists
|
|
67
67
|
|
|
68
68
|
|
|
69
|
-
def
|
|
69
|
+
def _condition(dfDict, inames, horizons, mylog):
|
|
70
70
|
"""
|
|
71
71
|
Make sure that time horizons contain all years up to life expectancy,
|
|
72
72
|
and that values are positive (except big-ticket items).
|
|
@@ -83,24 +83,24 @@ def condition(dfDict, inames, horizons, mylog):
|
|
|
83
83
|
|
|
84
84
|
df = df.loc[:, ~df.columns.str.contains("^Unnamed")]
|
|
85
85
|
for col in df.columns:
|
|
86
|
-
if col == "" or col not in
|
|
86
|
+
if col == "" or col not in _timeHorizonItems:
|
|
87
87
|
df.drop(col, axis=1, inplace=True)
|
|
88
88
|
|
|
89
|
-
for item in
|
|
89
|
+
for item in _timeHorizonItems:
|
|
90
90
|
if item not in df.columns:
|
|
91
91
|
raise ValueError(f"Item {item} not found for {iname}.")
|
|
92
92
|
|
|
93
|
-
# Only consider lines in proper year range.
|
|
94
|
-
df = df[df["year"] >= thisyear]
|
|
93
|
+
# Only consider lines in proper year range. Go back 5 years for Roth maturation.
|
|
94
|
+
df = df[df["year"] >= (thisyear - 5)]
|
|
95
95
|
df = df[df["year"] < endyear]
|
|
96
96
|
missing = []
|
|
97
|
-
for n in range(horizons[i]):
|
|
97
|
+
for n in range(-5, horizons[i]):
|
|
98
98
|
year = thisyear + n
|
|
99
99
|
if not (df[df["year"] == year]).any(axis=None):
|
|
100
100
|
df.loc[len(df)] = [year, 0, 0, 0, 0, 0, 0, 0, 0]
|
|
101
101
|
missing.append(year)
|
|
102
102
|
else:
|
|
103
|
-
for item in
|
|
103
|
+
for item in _timeHorizonItems:
|
|
104
104
|
if item != "big-ticket items" and df[item].iloc[n] < 0:
|
|
105
105
|
raise ValueError(f"Item {item} for {iname} in year {df['year'].iloc[n]} is < 0.")
|
|
106
106
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "2025.06.21"
|
|
@@ -36,7 +36,7 @@ def test_case1():
|
|
|
36
36
|
p = createJackAndJillPlan('case1')
|
|
37
37
|
p.setRates('historical', 1969)
|
|
38
38
|
p.solve('maxSpending', options={'maxRothConversion': 100, 'bequest': 500})
|
|
39
|
-
assert p.basis == pytest.approx(
|
|
39
|
+
assert p.basis == pytest.approx(81986.3, abs=0.5)
|
|
40
40
|
assert p.bequest == pytest.approx(500000, abs=0.5)
|
|
41
41
|
|
|
42
42
|
|
|
@@ -45,7 +45,7 @@ def test_case2():
|
|
|
45
45
|
p.setRates('historical', 1969)
|
|
46
46
|
p.solve('maxBequest', options={'maxRothConversion': 100, 'netSpending': 80})
|
|
47
47
|
assert p.basis == pytest.approx(80000, abs=0.5)
|
|
48
|
-
assert p.bequest == pytest.approx(
|
|
48
|
+
assert p.bequest == pytest.approx(595786.5, abs=0.5)
|
|
49
49
|
|
|
50
50
|
|
|
51
51
|
def test_config1():
|
|
@@ -54,7 +54,7 @@ def test_config1():
|
|
|
54
54
|
p.setRates('historical', 1969)
|
|
55
55
|
p.solve('maxBequest', options={'maxRothConversion': 100, 'netSpending': 80})
|
|
56
56
|
assert p.basis == pytest.approx(80000, abs=0.5)
|
|
57
|
-
assert p.bequest == pytest.approx(
|
|
57
|
+
assert p.bequest == pytest.approx(595786.5, abs=0.5)
|
|
58
58
|
p.saveConfig()
|
|
59
59
|
base_filename = 'case_' + name
|
|
60
60
|
full_filename = 'case_' + name + '.toml'
|
|
@@ -62,11 +62,11 @@ def test_config1():
|
|
|
62
62
|
p2 = owl.readConfig(base_filename)
|
|
63
63
|
p2.solve('maxBequest', options={'maxRothConversion': 100, 'netSpending': 80})
|
|
64
64
|
assert p2.basis == pytest.approx(80000, abs=0.5)
|
|
65
|
-
assert p2.bequest == pytest.approx(
|
|
65
|
+
assert p2.bequest == pytest.approx(595786.5, abs=0.5)
|
|
66
66
|
p3 = owl.readConfig(full_filename)
|
|
67
67
|
p3.solve('maxBequest', options={'maxRothConversion': 100, 'netSpending': 80})
|
|
68
68
|
assert p3.basis == pytest.approx(80000, abs=0.5)
|
|
69
|
-
assert p3.bequest == pytest.approx(
|
|
69
|
+
assert p3.bequest == pytest.approx(595786.5, abs=0.5)
|
|
70
70
|
os.remove(full_filename)
|
|
71
71
|
|
|
72
72
|
|
|
@@ -76,14 +76,14 @@ def test_config2():
|
|
|
76
76
|
p.setRates('historical', 1969)
|
|
77
77
|
p.solve('maxBequest', options={'maxRothConversion': 100, 'netSpending': 80})
|
|
78
78
|
assert p.basis == pytest.approx(80000, abs=0.5)
|
|
79
|
-
assert p.bequest == pytest.approx(
|
|
79
|
+
assert p.bequest == pytest.approx(595786.5, abs=0.5)
|
|
80
80
|
iostring = StringIO()
|
|
81
81
|
p.saveConfig(iostring)
|
|
82
82
|
# print('iostream:', iostream.getvalue())
|
|
83
83
|
p2 = owl.readConfig(iostring)
|
|
84
84
|
p2.solve('maxBequest', options={'maxRothConversion': 100, 'netSpending': 80})
|
|
85
85
|
assert p2.basis == pytest.approx(80000, abs=0.5)
|
|
86
|
-
assert p2.bequest == pytest.approx(
|
|
86
|
+
assert p2.bequest == pytest.approx(595786.5, abs=0.5)
|
|
87
87
|
|
|
88
88
|
|
|
89
89
|
def test_clone1():
|
|
@@ -92,23 +92,23 @@ def test_clone1():
|
|
|
92
92
|
p.setRates('historical', 1969)
|
|
93
93
|
p.solve('maxBequest', options={'maxRothConversion': 100, 'netSpending': 80})
|
|
94
94
|
assert p.basis == pytest.approx(80000, abs=0.5)
|
|
95
|
-
assert p.bequest == pytest.approx(
|
|
95
|
+
assert p.bequest == pytest.approx(595786.5, abs=0.5)
|
|
96
96
|
name2 = 'testclone1.2'
|
|
97
97
|
p2 = owl.clone(p, name2)
|
|
98
98
|
p2.solve('maxBequest', options={'maxRothConversion': 100, 'netSpending': 80})
|
|
99
99
|
assert p2.basis == pytest.approx(80000, abs=0.5)
|
|
100
|
-
assert p2.bequest == pytest.approx(
|
|
100
|
+
assert p2.bequest == pytest.approx(595786.5, abs=0.5)
|
|
101
101
|
|
|
102
102
|
|
|
103
103
|
def test_clone2():
|
|
104
104
|
name = 'testclone2.1'
|
|
105
105
|
p = createJackAndJillPlan(name)
|
|
106
106
|
p.setRates('historical', 1969)
|
|
107
|
-
p.solve('maxSpending', options={'maxRothConversion': 100, 'bequest':
|
|
108
|
-
assert p.basis == pytest.approx(
|
|
109
|
-
assert p.bequest == pytest.approx(
|
|
107
|
+
p.solve('maxSpending', options={'maxRothConversion': 100, 'bequest': 10})
|
|
108
|
+
assert p.basis == pytest.approx(92121.5, abs=0.5)
|
|
109
|
+
assert p.bequest == pytest.approx(10000, abs=0.5)
|
|
110
110
|
name2 = 'testclone2.2'
|
|
111
111
|
p2 = owl.clone(p, name2)
|
|
112
|
-
p2.solve('maxSpending', options={'maxRothConversion': 100, 'bequest':
|
|
113
|
-
assert p2.basis == pytest.approx(
|
|
114
|
-
assert p2.bequest == pytest.approx(
|
|
112
|
+
p2.solve('maxSpending', options={'maxRothConversion': 100, 'bequest': 10})
|
|
113
|
+
assert p2.basis == pytest.approx(92121.5, abs=0.5)
|
|
114
|
+
assert p2.bequest == pytest.approx(10000, abs=0.5)
|
|
@@ -37,22 +37,23 @@ Copyright © 2025 - Martin-D. Lacasse
|
|
|
37
37
|
and [Streamlit](https://streamlit.io) for the front-end.
|
|
38
38
|
- Contributors: Josh Williams (noimjosh) for Docker image code,
|
|
39
39
|
Dale Seng (sengsational) for great insights and suggestions,
|
|
40
|
-
Robert E. Anderson (NH-RedAnt) for bug fixes and suggestions
|
|
40
|
+
Robert E. Anderson (NH-RedAnt) for bug fixes and suggestions,
|
|
41
|
+
Clark Jefcoat (hubcity) for fruitful interactions.
|
|
41
42
|
- Owl image is from [freepik](https://freepik.com).
|
|
42
43
|
|
|
43
44
|
#### :orange[Bugs and Feature Requests]
|
|
44
|
-
|
|
45
|
+
Please submit bugs and feature requests through
|
|
45
46
|
[GitHub](https://github.com/mdlacasse/owl/issues) if you have a GitHub account
|
|
46
|
-
or directly by [email](mailto
|
|
47
|
+
or directly by [email](mailto:martin.d.lacasse@gmail.com).
|
|
47
48
|
Or just drop me a line to report your experience with the tool. :thumbsup:
|
|
48
49
|
|
|
49
50
|
#### :orange[Privacy]
|
|
50
|
-
|
|
51
|
+
This app does not store or forward any information. All data entered is lost
|
|
51
52
|
after a session is closed. You can choose to download selected parts of your
|
|
52
53
|
own data to your computer before closing the session.
|
|
53
54
|
|
|
54
55
|
#### :orange[License]
|
|
55
|
-
|
|
56
|
+
This software is released under the
|
|
56
57
|
[Gnu General Public License v3](https://www.gnu.org/licenses/gpl-3.0.html#license-text).
|
|
57
58
|
|
|
58
59
|
#### :orange[Disclaimer]
|
|
@@ -10,13 +10,17 @@ if ret is None or kz.caseHasNoPlan():
|
|
|
10
10
|
else:
|
|
11
11
|
st.write("#### :orange[Savings Account Balances]")
|
|
12
12
|
accounts = {"txbl": "taxable", "txDef": "tax-deferred", "txFree": "tax-free"}
|
|
13
|
+
hdetails = {"txbl": "Brokerage and savings accounts excluding emergency fund. ",
|
|
14
|
+
"txDef": "IRA, 401k, 403b and the like. ",
|
|
15
|
+
"txFree": "Roth IRA, Roth 401k, Roth 403b and the like. "}
|
|
13
16
|
col1, col2, col3 = st.columns(3, gap="large", vertical_alignment="top")
|
|
14
17
|
with col1:
|
|
15
18
|
iname = kz.getKey("iname0")
|
|
16
19
|
for key in accounts:
|
|
17
20
|
nkey = key + str(0)
|
|
18
21
|
kz.initKey(nkey, 0)
|
|
19
|
-
ret = kz.getNum(f"{iname}'s {accounts[key]} account ($k)", nkey,
|
|
22
|
+
ret = kz.getNum(f"{iname}'s {accounts[key]} account ($k)", nkey,
|
|
23
|
+
help=hdetails[key]+kz.help1000)
|
|
20
24
|
|
|
21
25
|
today = date.today()
|
|
22
26
|
thisyear = today.year
|
|
@@ -32,32 +36,34 @@ else:
|
|
|
32
36
|
for key in accounts:
|
|
33
37
|
nkey = key + str(1)
|
|
34
38
|
kz.initKey(nkey, 0)
|
|
35
|
-
ret = kz.getNum(f"{iname1}'s {accounts[key]} account ($k)", nkey,
|
|
39
|
+
ret = kz.getNum(f"{iname1}'s {accounts[key]} account ($k)", nkey,
|
|
40
|
+
help=hdetails[key]+kz.help1000)
|
|
36
41
|
|
|
37
42
|
if kz.getKey("status") == "married":
|
|
38
43
|
st.divider()
|
|
39
|
-
st.write("
|
|
44
|
+
st.write("#### :orange[Survivor's Spousal Beneficiary Fractions]")
|
|
40
45
|
col1, col2, col3 = st.columns(3, gap="large", vertical_alignment="top")
|
|
41
46
|
with col1:
|
|
42
47
|
nkey = "benf" + str(0)
|
|
43
48
|
kz.initKey(nkey, 1)
|
|
44
49
|
helpmsg = "Fraction of account left to surviving spouse."
|
|
45
|
-
ret = kz.getNum(accounts["txbl"].capitalize(), nkey, format="%.2f", max_value=1.0,
|
|
50
|
+
ret = kz.getNum(accounts["txbl"].capitalize(), nkey, format="%.2f", max_value=1.0,
|
|
51
|
+
step=0.05, help=helpmsg)
|
|
46
52
|
|
|
47
53
|
with col2:
|
|
48
54
|
nkey = "benf" + str(1)
|
|
49
55
|
kz.initKey(nkey, 1)
|
|
50
|
-
ret = kz.getNum(accounts["txDef"].capitalize(), nkey, format="%.2f", max_value=1.0,
|
|
56
|
+
ret = kz.getNum(accounts["txDef"].capitalize(), nkey, format="%.2f", max_value=1.0,
|
|
57
|
+
step=0.05, help=helpmsg)
|
|
51
58
|
|
|
52
59
|
with col3:
|
|
53
60
|
nkey = "benf" + str(2)
|
|
54
61
|
kz.initKey(nkey, 1)
|
|
55
|
-
ret = kz.getNum(
|
|
56
|
-
|
|
57
|
-
)
|
|
62
|
+
ret = kz.getNum(accounts["txFree"].capitalize(), nkey, format="%.2f", max_value=1.0,
|
|
63
|
+
step=0.05, help=helpmsg)
|
|
58
64
|
|
|
59
65
|
st.write("#####")
|
|
60
|
-
st.write("
|
|
66
|
+
st.write("#### :orange[Surplus Deposit Fraction]")
|
|
61
67
|
col1, col2, col3 = st.columns(3, gap="large", vertical_alignment="top")
|
|
62
68
|
with col1:
|
|
63
69
|
kz.initKey("surplusFraction", 0.5)
|