owlplanner 2025.6.3__py3-none-any.whl → 2025.6.21__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/plan.py CHANGED
@@ -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
 
owlplanner/timelists.py CHANGED
@@ -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
 
owlplanner/version.py CHANGED
@@ -1 +1 @@
1
- __version__ = "2025.06.03"
1
+ __version__ = "2025.06.21"
@@ -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 &copy; 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
 
@@ -2,13 +2,13 @@ owlplanner/__init__.py,sha256=hJ2i4m2JpHPAKyQLjYOXpJzeEsgcTcKD-Vhm0AIjjWg,592
2
2
  owlplanner/abcapi.py,sha256=8VCXS7nH_QZYxCUU3lwO0_UPR9Q5fuYQ6DHDLvHVLPg,6878
3
3
  owlplanner/config.py,sha256=onGIMqW2WwB9_CUZauDL6LtHGvc8O1cPUKKcK7Oh70M,12617
4
4
  owlplanner/mylogging.py,sha256=RKUr-y-1XvKZzLMcfdtm4IM30LuRpJwb2qUeXmAWqME,2557
5
- owlplanner/plan.py,sha256=FgQB0kEV5BXsQdFzcVZwukpqJUy1qR2OYP8wS_owtfo,107055
5
+ owlplanner/plan.py,sha256=VVHyKJiooLXXVLiRFJcauZ3oOYU62CCBe4DlpA08P38,109765
6
6
  owlplanner/progress.py,sha256=2DOjOLo6Mo7m21wY-9iZhoUksAyi4VCbb6UL2RegNCw,529
7
7
  owlplanner/rates.py,sha256=7jXcuHbkJ3AVIeBYZdwme18rdYslIzCuT-c0cLzvKUU,14819
8
8
  owlplanner/tax2025.py,sha256=HVYJq8po28jL5Z_il39ZY7qvf2riUEfxio15Zp7TGj8,7890
9
- owlplanner/timelists.py,sha256=HjoUJQU_ZbfdUy79DefQo5EAXpkfcuG_dOIO9qCljyM,3971
9
+ owlplanner/timelists.py,sha256=95rKYknGMi1bonDVIc3xNmiwG0zTSejKyQy_uWCLSiA,4024
10
10
  owlplanner/utils.py,sha256=6Ky8ZKfNE9x--3znsZ8VZaT2PptDinszRxWsOCPanu8,2512
11
- owlplanner/version.py,sha256=s0nlL6F_JwS9jBAKvGDeVQurTia-o1wmPv43QaJETt4,28
11
+ owlplanner/version.py,sha256=Q6lAE5sS9Y-tuy3jNnv43HcB70y7l1kmDPNzx4CR9tc,28
12
12
  owlplanner/data/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
13
  owlplanner/data/rates.csv,sha256=6fxg56BVVORrj9wJlUGFdGXKvOX5r7CSca8uhUbbuIU,3734
14
14
  owlplanner/plotting/__init__.py,sha256=uhxqtUi0OI-QWNOO2LkXgQViW_9yM3rYb-204Wit974,250
@@ -16,7 +16,7 @@ owlplanner/plotting/base.py,sha256=UimGKpMTV-dVm3BX5Apr_Ltorc7dlDLCRPRQ3RF_v7c,2
16
16
  owlplanner/plotting/factory.py,sha256=EDopIAPQr9zHRgemObko18FlCeRNhNCoLNNFAOq-X6s,1030
17
17
  owlplanner/plotting/matplotlib_backend.py,sha256=AOEkapD94U5hGNoS0EdbRoe8mgdMHH4oOvkXADZS914,17957
18
18
  owlplanner/plotting/plotly_backend.py,sha256=AO33GxBHGYG5osir_H1iRRtGxdhs4AjfLV2d_xm35nY,33138
19
- owlplanner-2025.6.3.dist-info/METADATA,sha256=WhszwtjjNOjd2rfx3QHl6_E52ypcg1DpdJ6JJz3G1vY,53785
20
- owlplanner-2025.6.3.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
21
- owlplanner-2025.6.3.dist-info/licenses/LICENSE,sha256=IwGE9guuL-ryRPEKi6wFPI_zOhg7zDZbTYuHbSt_SAk,35823
22
- owlplanner-2025.6.3.dist-info/RECORD,,
19
+ owlplanner-2025.6.21.dist-info/METADATA,sha256=bZI1gHxPSbxJvJP2DuSqiB6Y33x4tdiVgHlKK73wGD4,54045
20
+ owlplanner-2025.6.21.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
21
+ owlplanner-2025.6.21.dist-info/licenses/LICENSE,sha256=IwGE9guuL-ryRPEKi6wFPI_zOhg7zDZbTYuHbSt_SAk,35823
22
+ owlplanner-2025.6.21.dist-info/RECORD,,