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 +0 -2
- owlplanner/plan.py +153 -76
- owlplanner/tax2025.py +42 -9
- owlplanner/timelists.py +11 -10
- owlplanner/version.py +1 -1
- {owlplanner-2025.6.3.dist-info → owlplanner-2025.7.1.dist-info}/METADATA +6 -4
- {owlplanner-2025.6.3.dist-info → owlplanner-2025.7.1.dist-info}/RECORD +9 -9
- {owlplanner-2025.6.3.dist-info → owlplanner-2025.7.1.dist-info}/WHEEL +0 -0
- {owlplanner-2025.6.3.dist-info → owlplanner-2025.7.1.dist-info}/licenses/LICENSE +0 -0
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.
|
|
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.
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
self.kappa_ijn[i,
|
|
929
|
-
self.
|
|
930
|
-
self.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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 + "\
|
|
2450
|
+
title = self._name + "\nFederal Income Tax"
|
|
2374
2451
|
if tag:
|
|
2375
2452
|
title += " - " + tag
|
|
2376
|
-
# All taxes: ordinary income and
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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,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
|
|
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
|
+
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
|
|
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.
|
|
1
|
+
__version__ = "2025.07.01"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: owlplanner
|
|
3
|
-
Version: 2025.
|
|
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 © 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
|
|
|
@@ -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
|
|
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=
|
|
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=
|
|
9
|
-
owlplanner/timelists.py,sha256=
|
|
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=
|
|
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.
|
|
20
|
-
owlplanner-2025.
|
|
21
|
-
owlplanner-2025.
|
|
22
|
-
owlplanner-2025.
|
|
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,,
|
|
File without changes
|
|
File without changes
|