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.
Files changed (111) hide show
  1. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/PKG-INFO +6 -4
  2. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/README.md +5 -3
  3. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/docs/owl.pdf +0 -0
  4. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/docs/owl.tex +19 -1
  5. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/pyproject.toml +1 -1
  6. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/requirements.txt +1 -1
  7. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/src/owlplanner/plan.py +63 -19
  8. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/src/owlplanner/timelists.py +9 -9
  9. owlplanner-2025.6.21/src/owlplanner/version.py +1 -0
  10. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/tests/test_repro.py +15 -15
  11. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/ui/About_Owl.py +6 -5
  12. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/ui/Current_Assets.py +15 -9
  13. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/ui/Documentation.py +69 -40
  14. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/ui/Optimization_Parameters.py +6 -6
  15. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/ui/Output_Files.py +13 -13
  16. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/ui/Quick_Start.py +4 -4
  17. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/ui/Rates_Selection.py +2 -2
  18. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/ui/Wages_and_Contributions.py +74 -72
  19. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/ui/main.py +1 -1
  20. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/ui/owlbridge.py +3 -3
  21. owlplanner-2025.6.3/src/owlplanner/version.py +0 -1
  22. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/.devcontainer/devcontainer.json +0 -0
  23. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/.flake8 +0 -0
  24. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/.gitattributes +0 -0
  25. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/.github/workflows/github-actions-runtests.yml +0 -0
  26. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/.gitignore +0 -0
  27. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/.streamlit/config.toml +0 -0
  28. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/.streamlit/fullconfig.toml +0 -0
  29. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/INSTALL.md +0 -0
  30. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/LICENSE +0 -0
  31. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/USER_GUIDE.md +0 -0
  32. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/docker/Dockerfile.build +0 -0
  33. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/docker/Dockerfile.run +0 -0
  34. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/docker/README.md +0 -0
  35. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/docker/buildentrypoint.sh +0 -0
  36. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/docker/docker-compose.yml +0 -0
  37. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/docker/runentrypoint.sh +0 -0
  38. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/docs/images/AD-taxDef.png +0 -0
  39. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/docs/images/AD-taxFree.png +0 -0
  40. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/docs/images/AD-taxable.png +0 -0
  41. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/docs/images/Hist_Bequest.png +0 -0
  42. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/docs/images/Hist_Spending.png +0 -0
  43. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/docs/images/MC-tutorial2a.png +0 -0
  44. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/docs/images/MC-tutorial2b.png +0 -0
  45. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/docs/images/OwlUI.png +0 -0
  46. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/docs/images/allocations.png +0 -0
  47. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/docs/images/owl.png +0 -0
  48. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/docs/images/profile.png +0 -0
  49. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/docs/images/ratesCorrelations.png +0 -0
  50. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/docs/images/ratesPlot.png +0 -0
  51. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/docs/images/savingsPlot.png +0 -0
  52. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/docs/images/sourcesPlot.png +0 -0
  53. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/docs/images/spendingPlot.png +0 -0
  54. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/docs/images/taxIncomePlot.png +0 -0
  55. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/docs/images/taxesPlot.png +0 -0
  56. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/examples/case_jack+jill.toml +0 -0
  57. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/examples/case_joe.toml +0 -0
  58. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/examples/case_john+sally.toml +0 -0
  59. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/examples/case_jon+jane.toml +0 -0
  60. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/examples/case_kim+sam-bequest.toml +0 -0
  61. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/examples/case_kim+sam-spending.toml +0 -0
  62. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/examples/jack+jill.xlsx +0 -0
  63. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/examples/joe.xlsx +0 -0
  64. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/examples/john+sally.xlsx +0 -0
  65. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/examples/jon+jane.xlsx +0 -0
  66. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/examples/template.xlsx +0 -0
  67. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/notebooks/john+sally.ipynb +0 -0
  68. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/notebooks/kim+sam.ipynb +0 -0
  69. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/notebooks/template.ipynb +0 -0
  70. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/notebooks/tutorial_1.ipynb +0 -0
  71. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/notebooks/tutorial_2.ipynb +0 -0
  72. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/notebooks/tutorial_3.ipynb +0 -0
  73. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/owlplanner.cmd +0 -0
  74. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/owlplanner.sh +0 -0
  75. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/pytest.ini +0 -0
  76. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/src/owlplanner/__init__.py +0 -0
  77. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/src/owlplanner/abcapi.py +0 -0
  78. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/src/owlplanner/config.py +0 -0
  79. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/src/owlplanner/data/__init__.py +0 -0
  80. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/src/owlplanner/data/rates.csv +0 -0
  81. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/src/owlplanner/mylogging.py +0 -0
  82. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/src/owlplanner/plotting/__init__.py +0 -0
  83. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/src/owlplanner/plotting/base.py +0 -0
  84. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/src/owlplanner/plotting/factory.py +0 -0
  85. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/src/owlplanner/plotting/matplotlib_backend.py +0 -0
  86. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/src/owlplanner/plotting/plotly_backend.py +0 -0
  87. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/src/owlplanner/progress.py +0 -0
  88. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/src/owlplanner/rates.py +0 -0
  89. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/src/owlplanner/tax2025.py +0 -0
  90. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/src/owlplanner/utils.py +0 -0
  91. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/tests/test_logger.py +0 -0
  92. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/tests/test_regressions.py +0 -0
  93. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/tests/test_toml_cases.py +0 -0
  94. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/tests/test_ui_asset_allocation.py +0 -0
  95. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/tests/test_ui_compare_summaries.py +0 -0
  96. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/tests/test_ui_sskeys.py +0 -0
  97. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/tests/test_units.py +0 -0
  98. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/ui/Asset_Allocation.py +0 -0
  99. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/ui/Create_Case.py +0 -0
  100. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/ui/Fixed_Income.py +0 -0
  101. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/ui/Graphs.py +0 -0
  102. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/ui/Historical_Range.py +0 -0
  103. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/ui/Logs.py +0 -0
  104. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/ui/Monte_Carlo.py +0 -0
  105. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/ui/README.md +0 -0
  106. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/ui/Settings.py +0 -0
  107. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/ui/Worksheets.py +0 -0
  108. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/ui/__init__.py +0 -0
  109. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/ui/progress.py +0 -0
  110. {owlplanner-2025.6.3 → owlplanner-2025.6.21}/ui/sskeys.py +0 -0
  111. {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
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 other approaches.
897
- Nevertheless, accuracy of results are not guaranteed.
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 other approaches.
191
- Nevertheless, accuracy of results are not guaranteed.
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
 
@@ -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{March 14, 2025}
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.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "owlplanner"
7
- version = "2025.06.03"
7
+ version = "2025.06.21"
8
8
  authors = [
9
9
  { name="Martin-D. Lacasse", email="martin.d.lacasse@gmail.com" },
10
10
  ]
@@ -7,5 +7,5 @@ odfpy
7
7
  plotly
8
8
  pulp
9
9
  scipy
10
- streamlit
10
+ streamlit >= 1.46
11
11
  toml
@@ -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.kappa_ijn[i, 0, :h] = self.timeLists[iname]["taxable ctrb"].iloc[:h]
925
- self.kappa_ijn[i, 1, :h] = self.timeLists[iname]["401k ctrb"].iloc[:h]
926
- self.kappa_ijn[i, 2, :h] = self.timeLists[iname]["Roth 401k ctrb"].iloc[:h]
927
- self.kappa_ijn[i, 1, :h] += self.timeLists[iname]["IRA ctrb"].iloc[:h]
928
- self.kappa_ijn[i, 2, :h] += self.timeLists[iname]["Roth IRA ctrb"].iloc[:h]
929
- self.myRothX_in[i, :h] = self.timeLists[iname]["Roth conv"].iloc[:h]
930
- self.Lambda_in[i, :h] = self.timeLists[iname]["big-ticket items"].iloc[:h]
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 + "\nIncome Tax"
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
- timeHorizonItems = [
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 = condition(dfDict, inames, horizons, mylog)
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 condition(dfDict, inames, horizons, mylog):
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 timeHorizonItems:
86
+ if col == "" or col not in _timeHorizonItems:
87
87
  df.drop(col, axis=1, inplace=True)
88
88
 
89
- for item in timeHorizonItems:
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 timeHorizonItems:
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(81989.0, abs=0.5)
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(595953.5, abs=0.5)
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(595953.5, abs=0.5)
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(595953.5, abs=0.5)
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(595953.5, abs=0.5)
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(595953.5, abs=0.5)
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(595953.5, abs=0.5)
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(595953.5, abs=0.5)
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(595953.5, abs=0.5)
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': 0})
108
- assert p.basis == pytest.approx(92328.1, abs=0.5)
109
- assert p.bequest == pytest.approx(0, abs=0.5)
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': 0})
113
- assert p2.basis == pytest.approx(92328.1, abs=0.5)
114
- assert p2.bequest == pytest.approx(0, abs=0.5)
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 &copy; 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
- - Please submit bugs and feature requests through
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://martin.d.lacasse@gmail.com).
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
- - This app does not store or forward any information. All data entered is lost
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
- - This software is released under the
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, help=kz.help1000)
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, help=kz.help1000)
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("##### :orange[Survivor's Spousal Beneficiary Fractions]")
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, step=0.05, help=helpmsg)
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, step=0.05, help=helpmsg)
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
- accounts["txFree"].capitalize(), nkey, format="%.2f", max_value=1.0, step=0.05, help=helpmsg
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("##### :orange[Surplus Deposit Fraction]")
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)