owlplanner 2025.6.3__py3-none-any.whl → 2025.7.1__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/config.py CHANGED
@@ -65,7 +65,6 @@ def saveConfig(myplan, file, mylog):
65
65
  # Rates Selection.
66
66
  diconf["Rates Selection"] = {
67
67
  "Heirs rate on tax-deferred estate": float(100 * myplan.nu),
68
- "Long-term capital gain tax rate": float(100 * myplan.psi),
69
68
  "Dividend rate": float(100 * myplan.mu),
70
69
  "TCJA expiration year": myplan.yTCJA,
71
70
  "Method": myplan.rateMethod,
@@ -228,7 +227,6 @@ def readConfig(file, *, verbose=True, logstreams=None, readContributions=True):
228
227
 
229
228
  # Rates Selection.
230
229
  p.setDividendRate(float(diconf["Rates Selection"].get("Dividend rate", 1.8))) # Fix for mod.
231
- p.setLongTermCapitalTaxRate(float(diconf["Rates Selection"]["Long-term capital gain tax rate"]))
232
230
  p.setHeirsTaxRate(float(diconf["Rates Selection"]["Heirs rate on tax-deferred estate"]))
233
231
  p.yTCJA = int(diconf["Rates Selection"]["TCJA expiration year"])
234
232
 
owlplanner/plan.py CHANGED
@@ -283,7 +283,7 @@ class Plan(object):
283
283
  self.i_s = -1
284
284
 
285
285
  # Default parameters:
286
- self.psi = 0.15 # Long-term income tax rate on capital gains (decimal)
286
+ self.psi_n = np.zeros(self.N_n) # Long-term income tax rate on capital gains (decimal)
287
287
  self.chi = 0.6 # Survivor fraction
288
288
  self.mu = 0.018 # Dividend rate (decimal)
289
289
  self.nu = 0.30 # Heirs tax rate (decimal)
@@ -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))
@@ -485,17 +485,6 @@ class Plan(object):
485
485
  self.caseStatus = "modified"
486
486
  self._adjustedParameters = False
487
487
 
488
- def setLongTermCapitalTaxRate(self, psi):
489
- """
490
- Set long-term income tax rate. Rate is in percent. Default 15%.
491
- """
492
- if not (0 <= psi <= 100):
493
- raise ValueError("Rate must be between 0 and 100.")
494
- psi /= 100
495
- self.mylog.vprint(f"Long-term capital gain income tax set to {u.pc(psi, f=0)}.")
496
- self.psi = psi
497
- self.caseStatus = "modified"
498
-
499
488
  def setBeneficiaryFractions(self, phi):
500
489
  """
501
490
  Set fractions of savings accounts that is left to surviving spouse.
@@ -920,14 +909,17 @@ class Plan(object):
920
909
  # Now fill in parameters which are in $.
921
910
  for i, iname in enumerate(self.inames):
922
911
  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]
912
+ self.omega_in[i, :h] = self.timeLists[iname]["anticipated wages"].iloc[5:5+h]
913
+ self.Lambda_in[i, :h] = self.timeLists[iname]["big-ticket items"].iloc[5:5+h]
914
+
915
+ # Values for last 5 years of Roth conversion and contributions stored at the end
916
+ # of array and accessed with negative index.
917
+ self.kappa_ijn[i, 0, :h+5] = np.roll(self.timeLists[iname]["taxable ctrb"], -5)
918
+ self.kappa_ijn[i, 1, :h+5] = np.roll(self.timeLists[iname]["401k ctrb"], -5)
919
+ self.kappa_ijn[i, 1, :h+5] += np.roll(self.timeLists[iname]["IRA ctrb"], -5)
920
+ self.kappa_ijn[i, 2, :h+5] = np.roll(self.timeLists[iname]["Roth 401k ctrb"], -5)
921
+ self.kappa_ijn[i, 2, :h+5] += np.roll(self.timeLists[iname]["Roth IRA ctrb"], -5)
922
+ self.myRothX_in[i, :h+5] = np.roll(self.timeLists[iname]["Roth conv"], -5)
931
923
 
932
924
  self.caseStatus = "modified"
933
925
 
@@ -984,8 +976,9 @@ class Plan(object):
984
976
  ]
985
977
  for i, iname in enumerate(self.inames):
986
978
  h = self.horizons[i]
987
- df = pd.DataFrame(0, index=np.arange(h), columns=cols)
988
- df["year"] = self.year_n[:h]
979
+ df = pd.DataFrame(0, index=np.arange(0, h+5), columns=cols)
980
+ # df["year"] = self.year_n[:h]
981
+ df["year"] = np.arange(self.year_n[0] - 5, self.year_n[h-1]+1)
989
982
  self.timeLists[iname] = df
990
983
 
991
984
  self.caseStatus = "modified"
@@ -1086,6 +1079,7 @@ class Plan(object):
1086
1079
  self._add_standard_exemption_bounds()
1087
1080
  self._add_defunct_constraints()
1088
1081
  self._add_roth_conversion_constraints(options)
1082
+ self._add_roth_maturation_constraints()
1089
1083
  self._add_withdrawal_limits()
1090
1084
  self._add_conversion_limits()
1091
1085
  self._add_objective_constraints(objective, options)
@@ -1127,6 +1121,43 @@ class Plan(object):
1127
1121
  for j in range(self.N_j):
1128
1122
  self.B.setRange(_q3(self.C["w"], self.i_d, j, n, self.N_i, self.N_j, self.N_n), 0, 0)
1129
1123
 
1124
+ def _add_roth_maturation_constraints(self):
1125
+ """
1126
+ Withdrawals from Roth accounts are subject to the 5-year rule for conversion.
1127
+ Conversions and gains are subject to the 5-year rule since conversion.
1128
+ Contributions can be withdrawn at any time (without 59.5 penalty) but
1129
+ gains on contributions are subject to the 5-year rule since the opening of the account.
1130
+ A retainer is put on all conversions and associated gains, and gains on all recent contributions.
1131
+ """
1132
+ # Assume 10% per year for contributions and conversions for past 5 years.
1133
+ # Future years will use the assumed returns.
1134
+ oldTau1 = 1.10
1135
+ for i in range(self.N_i):
1136
+ h = self.horizons[i]
1137
+ for n in range(h):
1138
+ rhs = 0
1139
+ # To add compounded gains to original amount.
1140
+ cgains = 1
1141
+ row = self.A.newRow()
1142
+ row.addElem(_q3(self.C["b"], i, 2, n, self.N_i, self.N_j, self.N_n + 1), 1)
1143
+ row.addElem(_q3(self.C["w"], i, 2, n, self.N_i, self.N_j, self.N_n), -1)
1144
+ for dn in range(1, 6):
1145
+ nn = n - dn
1146
+ if nn >= 0: # Past of future is now or in the future: use variables and parameters.
1147
+ Tau1 = 1 + np.sum(self.alpha_ijkn[i, 2, :, nn] * self.tau_kn[:, nn], axis=0)
1148
+ cgains *= Tau1
1149
+ row.addElem(_q2(self.C["x"], i, nn, self.N_i, self.N_n), -cgains)
1150
+ # If a contribution - it can be withdrawn but not the gains.
1151
+ rhs += (cgains - 1) * self.kappa_ijn[i, 2, nn]
1152
+ else: # Past of future is in the past:
1153
+ # Parameters are stored at the end of contributions and conversions arrays.
1154
+ cgains *= oldTau1
1155
+ # If a contribution, it has no penalty, but assume a conversion.
1156
+ # rhs += (cgains - 1) * self.kappa_ijn[i, 2, nn] + cgains * self.myRothX_in[i, nn]
1157
+ rhs += cgains * self.kappa_ijn[i, 2, nn] + cgains * self.myRothX_in[i, nn]
1158
+
1159
+ self.A.addRow(row, rhs, np.inf)
1160
+
1130
1161
  def _add_roth_conversion_constraints(self, options):
1131
1162
  if "maxRothConversion" in options and options["maxRothConversion"] == "file":
1132
1163
  for i in range(self.N_i):
@@ -1279,11 +1310,11 @@ class Plan(object):
1279
1310
  tau_0prev = np.roll(self.tau_kn[0, :], 1)
1280
1311
  tau_0prev[tau_0prev < 0] = 0
1281
1312
  for n in range(self.N_n):
1282
- rhs = -self.M_n[n]
1313
+ rhs = -self.M_n[n] - self.J_n[n]
1283
1314
  row = self.A.newRow({_q1(self.C["g"], n, self.N_n): 1})
1284
1315
  row.addElem(_q1(self.C["s"], n, self.N_n), 1)
1285
1316
  for i in range(self.N_i):
1286
- fac = self.psi * self.alpha_ijkn[i, 0, 0, n]
1317
+ fac = self.psi_n[n] * self.alpha_ijkn[i, 0, 0, n]
1287
1318
  rhs += (
1288
1319
  self.omega_in[i, n]
1289
1320
  + self.zetaBar_in[i, n]
@@ -1534,7 +1565,7 @@ class Plan(object):
1534
1565
 
1535
1566
  # Check objective and required options.
1536
1567
  knownObjectives = ["maxBequest", "maxSpending"]
1537
- knownSolvers = ["HiGHS", "PuLP/CBC", "MOSEK"]
1568
+ knownSolvers = ["HiGHS", "PuLP/CBC", "PuLP/HiGHS", "MOSEK"]
1538
1569
 
1539
1570
  knownOptions = [
1540
1571
  "bequest",
@@ -1593,18 +1624,22 @@ class Plan(object):
1593
1624
  raise ValueError(f"Slack value out of range {lambdha}.")
1594
1625
  self.lambdha = lambdha / 100
1595
1626
 
1627
+ # Ensure parameters are adjusted for inflation.
1596
1628
  self._adjustParameters()
1597
1629
 
1630
+ # Reset long-term capital gain tax rate to zero.
1631
+ self.psi_n[:] = 0
1632
+
1598
1633
  solver = myoptions.get("solver", self.defaultSolver)
1599
1634
  if solver not in knownSolvers:
1600
1635
  raise ValueError(f"Unknown solver {solver}.")
1601
1636
 
1602
1637
  if solver == "HiGHS":
1603
1638
  solverMethod = self._milpSolve
1604
- elif solver == "PuLP/CBC":
1605
- solverMethod = self._pulpSolve
1606
1639
  elif solver == "MOSEK":
1607
1640
  solverMethod = self._mosekSolve
1641
+ elif "PuLP" in solver:
1642
+ solverMethod = self._pulpSolve
1608
1643
  else:
1609
1644
  raise RuntimeError("Internal error in defining solverMethod.")
1610
1645
 
@@ -1666,7 +1701,7 @@ class Plan(object):
1666
1701
  old_x = xx
1667
1702
 
1668
1703
  if solverSuccess:
1669
- self.mylog.vprint(f"Self-consistent Medicare loop returned after {it} iterations.")
1704
+ self.mylog.vprint(f"Self-consistent Medicare loop returned after {it+1} iterations.")
1670
1705
  self.mylog.vprint(solverMsg)
1671
1706
  self.mylog.vprint(f"Objective: {u.d(solution * objFac)}")
1672
1707
  # self.mylog.vprint('Upper bound:', u.d(-solution.mip_dual_bound))
@@ -1762,7 +1797,13 @@ class Plan(object):
1762
1797
  # solver = pulp.getSolver("MOSEK")
1763
1798
  # prob.solve(solver)
1764
1799
 
1765
- prob.solve(pulp.PULP_CBC_CMD(msg=False))
1800
+ if "HiGHS" in options["solver"]:
1801
+ solver = pulp.getSolver("HiGHS", msg=False)
1802
+ else:
1803
+ solver = pulp.getSolver("PULP_CBC_CMD", msg=False)
1804
+
1805
+ prob.solve(solver)
1806
+
1766
1807
  # Filter out None values and convert to array.
1767
1808
  xx = np.array([0 if x[i].varValue is None else x[i].varValue for i in range(self.nvars)])
1768
1809
  solution = np.dot(c, xx)
@@ -1832,26 +1873,46 @@ class Plan(object):
1832
1873
 
1833
1874
  return solution, xx, solverSuccess, solverMsg
1834
1875
 
1876
+ def _computeNIIT(self):
1877
+ """
1878
+ Compute Wages (W), Dividends (Q), Interests (I), and exemption(e).
1879
+ For accounting for rent and trust income, one can easily add a column
1880
+ to the Wages and Contributions file and add yearly amount to Q_n + I_n below.
1881
+ """
1882
+ J_n = np.zeros(self.N_n)
1883
+ status = len(self.yobs) - 1
1884
+
1885
+ for n in range(self.N_n):
1886
+ if status and n == self.n_d:
1887
+ status -= 1
1888
+
1889
+ Gmax = tx.niitThreshold[status]
1890
+ if self.MAGI_n[n] > Gmax:
1891
+ J_n[n] = tx.niitRate * min(self.MAGI_n[n] - Gmax, self.I_n[n] + self.Q_n[n])
1892
+
1893
+ return J_n
1894
+
1835
1895
  def _estimateMedicare(self, x=None, withMedicare=True):
1836
1896
  """
1837
- Compute rough MAGI and Medicare costs.
1897
+ Compute MAGI, Medicare costs, long-term capital gain tax rate, and
1898
+ net investment income tax (NIIT).
1838
1899
  """
1839
- if withMedicare is False:
1900
+ if x is None or withMedicare is False:
1901
+ self.MAGI_n = np.zeros(self.N_n)
1902
+ self.J_n = np.zeros(self.N_n)
1840
1903
  self.M_n = np.zeros(self.N_n)
1904
+ self.psi_n = np.zeros(self.N_n)
1841
1905
  return
1842
1906
 
1843
- if x is None:
1844
- MAGI_n = np.zeros(self.N_n)
1845
- else:
1846
- self.F_tn = np.array(x[self.C["F"] : self.C["g"]])
1847
- self.F_tn = self.F_tn.reshape((self.N_t, self.N_n))
1848
- MAGI_n = np.sum(self.F_tn, axis=0) + np.array(x[self.C["e"] : self.C["F"]])
1907
+ self._aggregateResults(x, short=True)
1849
1908
 
1850
- self.M_n = tx.mediCosts(self.yobs, self.horizons, MAGI_n, self.prevMAGI, self.gamma_n[:-1], self.N_n)
1909
+ self.J_n = self._computeNIIT()
1910
+ self.M_n = tx.mediCosts(self.yobs, self.horizons, self.MAGI_n, self.prevMAGI, self.gamma_n[:-1], self.N_n)
1911
+ self.psi_n = tx.capitalGainTaxRate(self.N_i, self.MAGI_n, self.gamma_n[:-1], self.n_d, self.N_n)
1851
1912
 
1852
1913
  return None
1853
1914
 
1854
- def _aggregateResults(self, x):
1915
+ def _aggregateResults(self, x, short=False):
1855
1916
  """
1856
1917
  Utility function to aggregate results from solver.
1857
1918
  Process all results from solution vector.
@@ -1906,7 +1967,42 @@ class Plan(object):
1906
1967
  # self.z_inz = self.z_inz.reshape((Ni, Nn, Nz))
1907
1968
  # print(self.z_inz)
1908
1969
 
1909
- # Partial distribution at the passing of first spouse.
1970
+ self.G_n = np.sum(self.F_tn, axis=0)
1971
+
1972
+ tau_0 = np.array(self.tau_kn[0, :])
1973
+ tau_0[tau_0 < 0] = 0
1974
+ # Last year's rates.
1975
+ tau_0prev = np.roll(tau_0, 1)
1976
+ self.Q_n = np.sum(
1977
+ (
1978
+ self.mu
1979
+ * (self.b_ijn[:, 0, :-1] - self.w_ijn[:, 0, :] + self.d_in[:, :] + 0.5 * self.kappa_ijn[:, 0, :Nn])
1980
+ + tau_0prev * self.w_ijn[:, 0, :]
1981
+ )
1982
+ * self.alpha_ijkn[:, 0, 0, :-1],
1983
+ axis=0,
1984
+ )
1985
+ self.U_n = self.psi_n * self.Q_n
1986
+
1987
+ self.MAGI_n = self.G_n + self.e_n + self.Q_n
1988
+
1989
+ I_in = ((self.b_ijn[:, 0, :-1] + self.d_in - self.w_ijn[:, 0, :])
1990
+ * np.sum(self.alpha_ijkn[:, 0, 1:, :-1] * self.tau_kn[1:, :], axis=1))
1991
+ self.I_n = np.sum(I_in, axis=0)
1992
+
1993
+ # Stop after building minimu required for self-consistent loop.
1994
+ if short:
1995
+ return
1996
+
1997
+ self.T_tn = self.F_tn * self.theta_tn
1998
+ self.T_n = np.sum(self.T_tn, axis=0)
1999
+ self.P_n = np.zeros(Nn)
2000
+ # Add early withdrawal penalty if any.
2001
+ for i in range(Ni):
2002
+ self.P_n[0:self.n59[i]] += 0.1*(self.w_ijn[i, 1, 0:self.n59[i]] + self.w_ijn[i, 2, 0:self.n59[i]])
2003
+
2004
+ self.T_n += self.P_n
2005
+ # Compute partial distribution at the passing of first spouse.
1910
2006
  if Ni == 2 and n_d < Nn:
1911
2007
  nx = n_d - 1
1912
2008
  i_d = self.i_d
@@ -1932,30 +2028,6 @@ class Plan(object):
1932
2028
  self.rmd_in = self.rho_in * self.b_ijn[:, 1, :-1]
1933
2029
  self.dist_in = self.w_ijn[:, 1, :] - self.rmd_in
1934
2030
  self.dist_in[self.dist_in < 0] = 0
1935
- self.G_n = np.sum(self.F_tn, axis=0)
1936
- self.T_tn = self.F_tn * self.theta_tn
1937
- self.T_n = np.sum(self.T_tn, axis=0)
1938
- self.P_n = np.zeros(Nn)
1939
- # Add early withdrawal penalty if any.
1940
- for i in range(Ni):
1941
- self.P_n[0:self.n59[i]] += 0.1*(self.w_ijn[i, 1, 0:self.n59[i]] + self.w_ijn[i, 2, 0:self.n59[i]])
1942
-
1943
- self.T_n += self.P_n
1944
-
1945
- tau_0 = np.array(self.tau_kn[0, :])
1946
- tau_0[tau_0 < 0] = 0
1947
- # Last year's rates.
1948
- tau_0prev = np.roll(tau_0, 1)
1949
- self.Q_n = np.sum(
1950
- (
1951
- self.mu
1952
- * (self.b_ijn[:, 0, :-1] - self.w_ijn[:, 0, :] + self.d_in[:, :] + 0.5 * self.kappa_ijn[:, 0, :])
1953
- + tau_0prev * self.w_ijn[:, 0, :]
1954
- )
1955
- * self.alpha_ijkn[:, 0, 0, :-1],
1956
- axis=0,
1957
- )
1958
- self.U_n = self.psi * self.Q_n
1959
2031
 
1960
2032
  # Make derivative variables.
1961
2033
  # Putting it all together in a dictionary.
@@ -2093,6 +2165,11 @@ class Plan(object):
2093
2165
  dic[" Total tax paid on gains and dividends"] = f"{u.d(taxPaidNow)}"
2094
2166
  dic["[Total tax paid on gains and dividends]"] = f"{u.d(taxPaid)}"
2095
2167
 
2168
+ taxPaid = np.sum(self.J_n, axis=0)
2169
+ taxPaidNow = np.sum(self.J_n / self.gamma_n[:-1], axis=0)
2170
+ dic[" Total net investment income tax paid"] = f"{u.d(taxPaidNow)}"
2171
+ dic["[Total net investment income tax paid]"] = f"{u.d(taxPaid)}"
2172
+
2096
2173
  taxPaid = np.sum(self.M_n, axis=0)
2097
2174
  taxPaidNow = np.sum(self.M_n / self.gamma_n[:-1], axis=0)
2098
2175
  dic[" Total Medicare premiums paid"] = f"{u.d(taxPaidNow)}"
@@ -2370,11 +2447,11 @@ class Plan(object):
2370
2447
  the default behavior of setDefaultPlots().
2371
2448
  """
2372
2449
  value = self._checkValue(value)
2373
- title = self._name + "\nIncome Tax"
2450
+ title = self._name + "\nFederal Income Tax"
2374
2451
  if tag:
2375
2452
  title += " - " + tag
2376
- # All taxes: ordinary income and dividends.
2377
- allTaxes = self.T_n + self.U_n
2453
+ # All taxes: ordinary income, dividends, and NIIT.
2454
+ allTaxes = self.T_n + self.U_n + self.J_n
2378
2455
  fig = self._plotter.plot_taxes(self.year_n, allTaxes, self.M_n, self.gamma_n,
2379
2456
  value, title, self.inames)
2380
2457
  if figure:
@@ -2446,7 +2523,7 @@ class Plan(object):
2446
2523
  "net spending": self.g_n,
2447
2524
  "taxable ord. income": self.G_n,
2448
2525
  "taxable gains/divs": self.Q_n,
2449
- "Tax bills + Med.": self.T_n + self.U_n + self.M_n,
2526
+ "Tax bills + Med.": self.T_n + self.U_n + self.M_n + self.J_n,
2450
2527
  }
2451
2528
 
2452
2529
  fillsheet(ws, incomeDic, "currency")
@@ -2460,7 +2537,7 @@ class Plan(object):
2460
2537
  "all BTI's": np.sum(self.Lambda_in, axis=0),
2461
2538
  "all wdrwls": np.sum(self.w_ijn, axis=(0, 1)),
2462
2539
  "all deposits": -np.sum(self.d_in, axis=0),
2463
- "ord taxes": -self.T_n,
2540
+ "ord taxes": -self.T_n - self.J_n,
2464
2541
  "div taxes": -self.U_n,
2465
2542
  "Medicare": -self.M_n,
2466
2543
  }
@@ -2489,16 +2566,16 @@ class Plan(object):
2489
2566
  # Account balances except final year.
2490
2567
  accDic = {
2491
2568
  "taxable bal": self.b_ijn[:, 0, :-1],
2492
- "taxable ctrb": self.kappa_ijn[:, 0, :],
2569
+ "taxable ctrb": self.kappa_ijn[:, 0, :self.N_n],
2493
2570
  "taxable dep": self.d_in,
2494
2571
  "taxable wdrwl": self.w_ijn[:, 0, :],
2495
2572
  "tax-deferred bal": self.b_ijn[:, 1, :-1],
2496
- "tax-deferred ctrb": self.kappa_ijn[:, 1, :],
2573
+ "tax-deferred ctrb": self.kappa_ijn[:, 1, :self.N_n],
2497
2574
  "tax-deferred wdrwl": self.w_ijn[:, 1, :],
2498
2575
  "(included RMDs)": self.rmd_in[:, :],
2499
2576
  "Roth conv": self.x_in,
2500
2577
  "tax-free bal": self.b_ijn[:, 2, :-1],
2501
- "tax-free ctrb": self.kappa_ijn[:, 2, :],
2578
+ "tax-free ctrb": self.kappa_ijn[:, 2, :self.N_n],
2502
2579
  "tax-free wdrwl": self.w_ijn[:, 2, :],
2503
2580
  }
2504
2581
  for i in range(self.N_i):
@@ -2595,12 +2672,12 @@ class Plan(object):
2595
2672
  planData[self.inames[i] + " txbl dep"] = self.d_in[i, :]
2596
2673
  planData[self.inames[i] + " txbl wrdwl"] = self.w_ijn[i, 0, :]
2597
2674
  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, :]
2675
+ planData[self.inames[i] + " tx-def ctrb"] = self.kappa_ijn[i, 1, :self.N_n]
2599
2676
  planData[self.inames[i] + " tx-def wdrl"] = self.w_ijn[i, 1, :]
2600
2677
  planData[self.inames[i] + " (RMD)"] = self.rmd_in[i, :]
2601
2678
  planData[self.inames[i] + " Roth conv"] = self.x_in[i, :]
2602
2679
  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, :]
2680
+ planData[self.inames[i] + " tx-free ctrb"] = self.kappa_ijn[i, 2, :self.N_n]
2604
2681
  planData[self.inames[i] + " tax-free wdrwl"] = self.w_ijn[i, 2, :]
2605
2682
  planData[self.inames[i] + " big-ticket items"] = self.Lambda_in[i, :]
2606
2683
 
owlplanner/tax2025.py CHANGED
@@ -33,7 +33,7 @@ rates_nonTCJA = np.array([0.10, 0.15, 0.25, 0.28, 0.33, 0.35, 0.396])
33
33
  ###############################################################################
34
34
  # Single [0] and married filing jointly [1].
35
35
 
36
- # These are current.
36
+ # These are 2025 current.
37
37
  taxBrackets_TCJA = np.array(
38
38
  [
39
39
  [11925, 48475, 103350, 197300, 250525, 626350, 9999999],
@@ -65,24 +65,57 @@ irmaaFees = 12 * np.array([185.00, 74.00, 111.00, 110.90, 111.00, 37.00])
65
65
  # These are speculated.
66
66
  taxBrackets_nonTCJA = np.array(
67
67
  [
68
- [12150, 49550, 119950, 250200, 544000, 546200, 9999999],
69
- [24350, 99100, 199850, 304600, 543950, 614450, 9999999],
68
+ [12150, 49550, 119950, 250200, 544000, 546200, 9999999], # Single
69
+ [24350, 99100, 199850, 304600, 543950, 614450, 9999999], # MFJ
70
70
  ]
71
71
  )
72
72
 
73
- # These are current.
74
- stdDeduction_TCJA = np.array([15000, 30000])
75
- # These are speculated.
76
- stdDeduction_nonTCJA = np.array([8300, 16600])
73
+ # These are 2025 current (adjusted for inflation).
74
+ stdDeduction_TCJA = np.array([15000, 30000]) # Single, MFJ
75
+ # These are speculated (adjusted for inflation).
76
+ stdDeduction_nonTCJA = np.array([8300, 16600]) # Single, MFJ
77
77
 
78
- # These are current.
79
- extra65Deduction = np.array([2000, 1600])
78
+ # These are current (adjusted for inflation).
79
+ extra65Deduction = np.array([2000, 1600]) # Single, MFJ
80
+
81
+ # Thresholds for capital gains (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
80
92
 
81
93
  ###############################################################################
82
94
  # End of section where rates need to be actualized every year.
83
95
  ###############################################################################
84
96
 
85
97
 
98
+ def capitalGainTaxRate(Ni, magi_n, gamma_n, nd, Nn):
99
+ """
100
+ Return an array of decimal rates for capital gains.
101
+ Parameter nd is the index year of first passing of a spouse, if applicable,
102
+ nd == Nn for single individuals.
103
+ """
104
+ status = Ni - 1
105
+ cgRate_n = np.zeros(Nn)
106
+
107
+ for n in range(Nn):
108
+ if n == nd:
109
+ status -= 1
110
+
111
+ if magi_n[n] > gamma_n[n] * capGainRates[status][1]:
112
+ cgRate_n[n] = 0.20
113
+ elif magi_n[n] > gamma_n[n] * capGainRates[status][0]:
114
+ cgRate_n[n] = 0.15
115
+
116
+ return cgRate_n
117
+
118
+
86
119
  def mediCosts(yobs, horizons, magi, prevmagi, gamma_n, Nn):
87
120
  """
88
121
  Compute Medicare costs directly.
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",
@@ -42,7 +42,7 @@ def read(finput, inames, horizons, mylog):
42
42
  year, anticipated wages, taxable ctrb, 401k ctrb, Roth 401k ctrb,
43
43
  IRA ctrb, Roth IRA ctrb, Roth conv, and big-ticket items.
44
44
  Supports xls, xlsx, xlsm, xlsb, odf, ods, and odt file extensions.
45
- Returs a dictionary of dataframes by individual's names.
45
+ Return a dictionary of dataframes by individual's names.
46
46
  """
47
47
 
48
48
  mylog.vprint("Reading wages, contributions, conversions, and big-ticket items over time...")
@@ -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,25 @@ 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
+ df = df.drop_duplicates("year")
96
97
  missing = []
97
- for n in range(horizons[i]):
98
+ for n in range(-5, horizons[i]):
98
99
  year = thisyear + n
99
100
  if not (df[df["year"] == year]).any(axis=None):
100
101
  df.loc[len(df)] = [year, 0, 0, 0, 0, 0, 0, 0, 0]
101
102
  missing.append(year)
102
103
  else:
103
- for item in timeHorizonItems:
104
+ for item in _timeHorizonItems:
104
105
  if item != "big-ticket items" and df[item].iloc[n] < 0:
105
106
  raise ValueError(f"Item {item} for {iname} in year {df['year'].iloc[n]} is < 0.")
106
107
 
owlplanner/version.py CHANGED
@@ -1 +1 @@
1
- __version__ = "2025.06.03"
1
+ __version__ = "2025.07.01"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: owlplanner
3
- Version: 2025.6.3
3
+ Version: 2025.7.1
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
 
@@ -1,14 +1,14 @@
1
1
  owlplanner/__init__.py,sha256=hJ2i4m2JpHPAKyQLjYOXpJzeEsgcTcKD-Vhm0AIjjWg,592
2
2
  owlplanner/abcapi.py,sha256=8VCXS7nH_QZYxCUU3lwO0_UPR9Q5fuYQ6DHDLvHVLPg,6878
3
- owlplanner/config.py,sha256=onGIMqW2WwB9_CUZauDL6LtHGvc8O1cPUKKcK7Oh70M,12617
3
+ owlplanner/config.py,sha256=-sSz37hwlnmI9_oXZn-R1rpmY0Vyk5L4X--NxGpgEMA,12446
4
4
  owlplanner/mylogging.py,sha256=RKUr-y-1XvKZzLMcfdtm4IM30LuRpJwb2qUeXmAWqME,2557
5
- owlplanner/plan.py,sha256=FgQB0kEV5BXsQdFzcVZwukpqJUy1qR2OYP8wS_owtfo,107055
5
+ owlplanner/plan.py,sha256=rivQ9lSFJx6Eahx83VyTOm6n4uxjbr6LiwiyChRhAnc,111133
6
6
  owlplanner/progress.py,sha256=2DOjOLo6Mo7m21wY-9iZhoUksAyi4VCbb6UL2RegNCw,529
7
7
  owlplanner/rates.py,sha256=7jXcuHbkJ3AVIeBYZdwme18rdYslIzCuT-c0cLzvKUU,14819
8
- owlplanner/tax2025.py,sha256=HVYJq8po28jL5Z_il39ZY7qvf2riUEfxio15Zp7TGj8,7890
9
- owlplanner/timelists.py,sha256=HjoUJQU_ZbfdUy79DefQo5EAXpkfcuG_dOIO9qCljyM,3971
8
+ owlplanner/tax2025.py,sha256=3uDJfKiSRFUp5WDcouAnTEQqEY7LnDKxqxDSKiTsOSQ,8927
9
+ owlplanner/timelists.py,sha256=4pRumdoFlEmmh07wpGhDqauHl2doLG5JcRkvi41fvR4,4065
10
10
  owlplanner/utils.py,sha256=6Ky8ZKfNE9x--3znsZ8VZaT2PptDinszRxWsOCPanu8,2512
11
- owlplanner/version.py,sha256=s0nlL6F_JwS9jBAKvGDeVQurTia-o1wmPv43QaJETt4,28
11
+ owlplanner/version.py,sha256=J00-UKwWWUboYr10jyhzSLOwp2iWtGoC1DRPgnuINV0,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.7.1.dist-info/METADATA,sha256=_T6vNe7aESAIt668fK9IVb0VDEv5P3Z16bYPyTvr9QY,54044
20
+ owlplanner-2025.7.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
21
+ owlplanner-2025.7.1.dist-info/licenses/LICENSE,sha256=IwGE9guuL-ryRPEKi6wFPI_zOhg7zDZbTYuHbSt_SAk,35823
22
+ owlplanner-2025.7.1.dist-info/RECORD,,