owlplanner 2025.8.1__py3-none-any.whl → 2025.11.2__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 +4 -0
- owlplanner/plan.py +11 -12
- owlplanner/tax2025.py +2 -2
- owlplanner/tax2026.py +339 -0
- owlplanner/timelists.py +1 -1
- owlplanner/version.py +1 -1
- {owlplanner-2025.8.1.dist-info → owlplanner-2025.11.2.dist-info}/METADATA +20 -14
- {owlplanner-2025.8.1.dist-info → owlplanner-2025.11.2.dist-info}/RECORD +10 -9
- {owlplanner-2025.8.1.dist-info → owlplanner-2025.11.2.dist-info}/WHEEL +0 -0
- {owlplanner-2025.8.1.dist-info → owlplanner-2025.11.2.dist-info}/licenses/LICENSE +0 -0
owlplanner/config.py
CHANGED
|
@@ -296,6 +296,10 @@ def readConfig(file, *, verbose=True, logstreams=None, readContributions=True):
|
|
|
296
296
|
# Solver Options.
|
|
297
297
|
p.solverOptions = diconf["Solver Options"]
|
|
298
298
|
|
|
299
|
+
# Address legacy case files.
|
|
300
|
+
if diconf["Solver Options"].get("withMedicare", None) is True:
|
|
301
|
+
p.solverOptions["withMedicare"] = "loop"
|
|
302
|
+
|
|
299
303
|
# Check consistency of noRothConversions.
|
|
300
304
|
name = p.solverOptions.get("noRothConversions", "None")
|
|
301
305
|
if name != "None" and name not in p.inames:
|
owlplanner/plan.py
CHANGED
|
@@ -1021,7 +1021,7 @@ class Plan(object):
|
|
|
1021
1021
|
Refer to companion document for explanations.
|
|
1022
1022
|
All binary variables must be lumped at the end of the vector.
|
|
1023
1023
|
"""
|
|
1024
|
-
medi = options.get("
|
|
1024
|
+
medi = options.get("withMedicare", "loop") == "optimize"
|
|
1025
1025
|
|
|
1026
1026
|
# Stack all variables in a single block vector with all binary variables at the end.
|
|
1027
1027
|
C = {}
|
|
@@ -1223,8 +1223,8 @@ class Plan(object):
|
|
|
1223
1223
|
|
|
1224
1224
|
for i in range(self.N_i):
|
|
1225
1225
|
for j in range(self.N_j):
|
|
1226
|
-
backTau = 1
|
|
1227
|
-
rhs = self.beta_ij[i, j]
|
|
1226
|
+
backTau = 1 + yearSpent * np.sum(self.tau_kn[:, 0] * self.alpha_ijkn[i, j, :, 0])
|
|
1227
|
+
rhs = self.beta_ij[i, j] / backTau
|
|
1228
1228
|
self.B.setRange(_q3(self.C["b"], i, j, 0, self.N_i, self.N_j, self.N_n + 1), rhs, rhs)
|
|
1229
1229
|
|
|
1230
1230
|
def _add_surplus_deposit_linking(self):
|
|
@@ -1386,7 +1386,7 @@ class Plan(object):
|
|
|
1386
1386
|
self.B.setRange(_q3(self.C["zx"], i, n, 1, self.N_i, self.N_n, self.N_zx), 0, 0)
|
|
1387
1387
|
|
|
1388
1388
|
def _configure_Medicare_binary_variables(self, options):
|
|
1389
|
-
if
|
|
1389
|
+
if options.get("withMedicare", "loop") != "optimize":
|
|
1390
1390
|
return
|
|
1391
1391
|
|
|
1392
1392
|
bigM = options.get("bigM", 5e6)
|
|
@@ -1444,7 +1444,7 @@ class Plan(object):
|
|
|
1444
1444
|
self.A.addRow(row2, -np.inf, rhs2)
|
|
1445
1445
|
|
|
1446
1446
|
def _add_Medicare_costs(self, options):
|
|
1447
|
-
if
|
|
1447
|
+
if options.get("withMedicare", "loop") != "optimize":
|
|
1448
1448
|
return
|
|
1449
1449
|
|
|
1450
1450
|
for n in range(self.nm):
|
|
@@ -1627,7 +1627,7 @@ class Plan(object):
|
|
|
1627
1627
|
"netSpending",
|
|
1628
1628
|
"noRothConversions",
|
|
1629
1629
|
"oppCostX",
|
|
1630
|
-
"
|
|
1630
|
+
"withMedicare",
|
|
1631
1631
|
"previousMAGIs",
|
|
1632
1632
|
"solver",
|
|
1633
1633
|
"spendingSlack",
|
|
@@ -1635,7 +1635,6 @@ class Plan(object):
|
|
|
1635
1635
|
"units",
|
|
1636
1636
|
"xorConstraints",
|
|
1637
1637
|
"withSCLoop",
|
|
1638
|
-
"withMedicare", # Ignore keyword.
|
|
1639
1638
|
]
|
|
1640
1639
|
# We might modify options if required.
|
|
1641
1640
|
options = {} if options is None else options
|
|
@@ -1713,7 +1712,7 @@ class Plan(object):
|
|
|
1713
1712
|
"""
|
|
1714
1713
|
Self-consistent loop, regardless of solver.
|
|
1715
1714
|
"""
|
|
1716
|
-
|
|
1715
|
+
includeMedicare = options.get("withMedicare", "loop") == "loop"
|
|
1717
1716
|
withSCLoop = options.get("withSCLoop", True)
|
|
1718
1717
|
|
|
1719
1718
|
if objective == "maxSpending":
|
|
@@ -1724,7 +1723,7 @@ class Plan(object):
|
|
|
1724
1723
|
it = 0
|
|
1725
1724
|
old_x = np.zeros(self.nvars)
|
|
1726
1725
|
old_objfns = [np.inf]
|
|
1727
|
-
self._computeNLstuff(None,
|
|
1726
|
+
self._computeNLstuff(None, includeMedicare)
|
|
1728
1727
|
while True:
|
|
1729
1728
|
objfn, xx, solverSuccess, solverMsg = solverMethod(objective, options)
|
|
1730
1729
|
|
|
@@ -1735,7 +1734,7 @@ class Plan(object):
|
|
|
1735
1734
|
if not withSCLoop:
|
|
1736
1735
|
break
|
|
1737
1736
|
|
|
1738
|
-
self._computeNLstuff(xx,
|
|
1737
|
+
self._computeNLstuff(xx, includeMedicare)
|
|
1739
1738
|
|
|
1740
1739
|
delta = xx - old_x
|
|
1741
1740
|
absSolDiff = np.sum(np.abs(delta), axis=0)/100
|
|
@@ -1955,7 +1954,7 @@ class Plan(object):
|
|
|
1955
1954
|
|
|
1956
1955
|
return J_n
|
|
1957
1956
|
|
|
1958
|
-
def _computeNLstuff(self, x,
|
|
1957
|
+
def _computeNLstuff(self, x, includeMedicare):
|
|
1959
1958
|
"""
|
|
1960
1959
|
Compute MAGI, Medicare costs, long-term capital gain tax rate, and
|
|
1961
1960
|
net investment income tax (NIIT).
|
|
@@ -1972,7 +1971,7 @@ class Plan(object):
|
|
|
1972
1971
|
self.J_n = self._computeNIIT(self.MAGI_n, self.I_n, self.Q_n)
|
|
1973
1972
|
self.psi_n = tx.capitalGainTaxRate(self.N_i, self.MAGI_n, self.gamma_n[:-1], self.n_d, self.N_n)
|
|
1974
1973
|
# Compute Medicare through self-consistent loop.
|
|
1975
|
-
if
|
|
1974
|
+
if includeMedicare:
|
|
1976
1975
|
self.M_n = tx.mediCosts(self.yobs, self.horizons, self.MAGI_n, self.prevMAGI, self.gamma_n[:-1], self.N_n)
|
|
1977
1976
|
|
|
1978
1977
|
return None
|
owlplanner/tax2025.py
CHANGED
|
@@ -70,7 +70,7 @@ taxBrackets_preTCJA = np.array(
|
|
|
70
70
|
]
|
|
71
71
|
)
|
|
72
72
|
|
|
73
|
-
# These are 2025 current
|
|
73
|
+
# These are 2025 current.
|
|
74
74
|
stdDeduction_OBBBA = np.array([15750, 31500]) # Single, MFJ
|
|
75
75
|
# These are speculated (adjusted for inflation).
|
|
76
76
|
stdDeduction_preTCJA = np.array([8300, 16600]) # Single, MFJ
|
|
@@ -78,7 +78,7 @@ stdDeduction_preTCJA = np.array([8300, 16600]) # Single, MFJ
|
|
|
78
78
|
# These are current (adjusted for inflation) per individual.
|
|
79
79
|
extra65Deduction = np.array([2000, 1600]) # Single, MFJ
|
|
80
80
|
|
|
81
|
-
# Thresholds
|
|
81
|
+
# Thresholds setting capital gains brackets 0%, 15%, 20% (adjusted for inflation).
|
|
82
82
|
capGainRates = np.array(
|
|
83
83
|
[
|
|
84
84
|
[48350, 533400],
|
owlplanner/tax2026.py
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
"""
|
|
2
|
+
|
|
3
|
+
Owl/tax2026
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
A retirement planner using linear programming optimization.
|
|
7
|
+
|
|
8
|
+
See companion document for a complete explanation and description
|
|
9
|
+
of all variables and parameters.
|
|
10
|
+
|
|
11
|
+
Module to handle all tax calculations.
|
|
12
|
+
|
|
13
|
+
Copyright © 2026 - Martin-D. Lacasse
|
|
14
|
+
|
|
15
|
+
Disclaimers: This code is for educational purposes only and does not constitute financial advice.
|
|
16
|
+
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
from datetime import date
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
##############################################################################
|
|
24
|
+
# Prepare the data.
|
|
25
|
+
|
|
26
|
+
taxBracketNames = ["10%", "12/15%", "22/25%", "24/28%", "32/33%", "35%", "37/40%"]
|
|
27
|
+
|
|
28
|
+
rates_OBBBA = np.array([0.10, 0.12, 0.22, 0.24, 0.32, 0.35, 0.370])
|
|
29
|
+
rates_preTCJA = np.array([0.10, 0.15, 0.25, 0.28, 0.33, 0.35, 0.396])
|
|
30
|
+
|
|
31
|
+
###############################################################################
|
|
32
|
+
# Start of section where rates need to be actualized every year.
|
|
33
|
+
###############################################################################
|
|
34
|
+
# Single [0] and married filing jointly [1].
|
|
35
|
+
|
|
36
|
+
# These are 2025 current.
|
|
37
|
+
taxBrackets_OBBBA = np.array(
|
|
38
|
+
[
|
|
39
|
+
[12400, 50400, 105700, 201775, 256225, 640600, 9999999],
|
|
40
|
+
[24800, 100800, 211400, 403550, 512450, 768700, 9999999],
|
|
41
|
+
]
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
irmaaBrackets = np.array(
|
|
45
|
+
[
|
|
46
|
+
[0, 106000, 133000, 167000, 200000, 500000],
|
|
47
|
+
[0, 212000, 266000, 334000, 400000, 750000],
|
|
48
|
+
]
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# Index [0] stores the standard Medicare part B premium.
|
|
52
|
+
# Following values are incremental IRMAA part B monthly fees.
|
|
53
|
+
irmaaFees = 12 * np.array([185.00, 74.00, 111.00, 110.90, 111.00, 37.00])
|
|
54
|
+
|
|
55
|
+
# Make projection for pre-TCJA using 2017 to current year.
|
|
56
|
+
# taxBrackets_2017 = np.array(
|
|
57
|
+
# [ [9325, 37950, 91900, 191650, 416700, 418400, 9999999],
|
|
58
|
+
# [18650, 75900, 153100, 233350, 416700, 470700, 9999999],
|
|
59
|
+
# ])
|
|
60
|
+
#
|
|
61
|
+
# stdDeduction_2017 = [6350, 12700]
|
|
62
|
+
#
|
|
63
|
+
# For 2025, I used a 30.5% adjustment from 2017, rounded to closest 50.
|
|
64
|
+
#
|
|
65
|
+
# These are speculated.
|
|
66
|
+
taxBrackets_preTCJA = np.array(
|
|
67
|
+
[
|
|
68
|
+
[12150, 49550, 119950, 250200, 544000, 546200, 9999999], # Single
|
|
69
|
+
[24350, 99100, 199850, 304600, 543950, 614450, 9999999], # MFJ
|
|
70
|
+
]
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
# These are 2025 current.
|
|
74
|
+
stdDeduction_OBBBA = np.array([16100, 32200]) # Single, MFJ
|
|
75
|
+
# These are speculated (adjusted for inflation to 2026). TODO
|
|
76
|
+
stdDeduction_preTCJA = np.array([8300, 16600]) # Single, MFJ
|
|
77
|
+
|
|
78
|
+
# These are current (adjusted for inflation) per individual.
|
|
79
|
+
extra65Deduction = np.array([2000, 1600]) # Single, MFJ
|
|
80
|
+
|
|
81
|
+
# Thresholds setting capital gains brackets 0%, 15%, 20% (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
|
|
92
|
+
|
|
93
|
+
# Thresholds for 65+ bonus for circumventing tax on social security.
|
|
94
|
+
bonusThreshold = np.array([75000, 150000])
|
|
95
|
+
|
|
96
|
+
###############################################################################
|
|
97
|
+
# End of section where rates need to be actualized every year.
|
|
98
|
+
###############################################################################
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def mediVals(yobs, horizons, gamma_n, Nn, Nq):
|
|
102
|
+
"""
|
|
103
|
+
Return tuple (nm, L, C) of year index when Medicare starts and vectors L, and C
|
|
104
|
+
defining end points of constant piecewise linear functions representing IRMAA fees.
|
|
105
|
+
"""
|
|
106
|
+
thisyear = date.today().year
|
|
107
|
+
assert Nq == len(irmaaFees), f"Inconsistent value of Nq: {Nq}."
|
|
108
|
+
assert Nq == len(irmaaBrackets[0]), "Inconsistent IRMAA brackets array."
|
|
109
|
+
Ni = len(yobs)
|
|
110
|
+
# What index year will Medicare start? 65 - age.
|
|
111
|
+
nm = 65 - (thisyear - yobs)
|
|
112
|
+
nm = np.min(nm)
|
|
113
|
+
# Has it already started?
|
|
114
|
+
nm = max(0, nm)
|
|
115
|
+
Nmed = Nn - nm
|
|
116
|
+
|
|
117
|
+
L = np.zeros((Nmed, Nq-1))
|
|
118
|
+
C = np.zeros((Nmed, Nq))
|
|
119
|
+
|
|
120
|
+
# Year starts at offset nm in the plan.
|
|
121
|
+
for nn in range(Nmed):
|
|
122
|
+
imed = 0
|
|
123
|
+
n = nm + nn
|
|
124
|
+
if thisyear + n - yobs[0] >= 65 and n < horizons[0]:
|
|
125
|
+
imed += 1
|
|
126
|
+
if Ni == 2 and thisyear + n - yobs[1] >= 65 and n < horizons[1]:
|
|
127
|
+
imed += 1
|
|
128
|
+
if imed:
|
|
129
|
+
status = 0 if Ni == 1 else 1 if n < horizons[0] and n < horizons[1] else 0
|
|
130
|
+
L[nn] = gamma_n[n] * irmaaBrackets[status][1:]
|
|
131
|
+
C[nn] = imed * gamma_n[n] * irmaaFees
|
|
132
|
+
else:
|
|
133
|
+
raise RuntimeError("mediVals: This should never happen.")
|
|
134
|
+
|
|
135
|
+
return nm, L, C
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def capitalGainTaxRate(Ni, magi_n, gamma_n, nd, Nn):
|
|
139
|
+
"""
|
|
140
|
+
Return an array of decimal rates for capital gains.
|
|
141
|
+
Parameter nd is the index year of first passing of a spouse, if applicable,
|
|
142
|
+
nd == Nn for single individuals.
|
|
143
|
+
"""
|
|
144
|
+
status = Ni - 1
|
|
145
|
+
cgRate_n = np.zeros(Nn)
|
|
146
|
+
|
|
147
|
+
for n in range(Nn):
|
|
148
|
+
if status and n == nd:
|
|
149
|
+
status -= 1
|
|
150
|
+
|
|
151
|
+
if magi_n[n] > gamma_n[n] * capGainRates[status][1]:
|
|
152
|
+
cgRate_n[n] = 0.20
|
|
153
|
+
elif magi_n[n] > gamma_n[n] * capGainRates[status][0]:
|
|
154
|
+
cgRate_n[n] = 0.15
|
|
155
|
+
|
|
156
|
+
return cgRate_n
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def mediCosts(yobs, horizons, magi, prevmagi, gamma_n, Nn):
|
|
160
|
+
"""
|
|
161
|
+
Compute Medicare costs directly.
|
|
162
|
+
"""
|
|
163
|
+
thisyear = date.today().year
|
|
164
|
+
Ni = len(yobs)
|
|
165
|
+
costs = np.zeros(Nn)
|
|
166
|
+
for n in range(Nn):
|
|
167
|
+
status = 0 if Ni == 1 else 1 if n < horizons[0] and n < horizons[1] else 0
|
|
168
|
+
for i in range(Ni):
|
|
169
|
+
if thisyear + n - yobs[i] >= 65 and n < horizons[i]:
|
|
170
|
+
# Start with the (inflation-adjusted) basic Medicare part B premium.
|
|
171
|
+
costs[n] += gamma_n[n] * irmaaFees[0]
|
|
172
|
+
if n < 2:
|
|
173
|
+
mymagi = prevmagi[n]
|
|
174
|
+
else:
|
|
175
|
+
mymagi = magi[n - 2]
|
|
176
|
+
for q in range(1, 6):
|
|
177
|
+
if mymagi > gamma_n[n] * irmaaBrackets[status][q]:
|
|
178
|
+
costs[n] += gamma_n[n] * irmaaFees[q]
|
|
179
|
+
|
|
180
|
+
return costs
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def taxParams(yobs, i_d, n_d, N_n, gamma_n, MAGI_n, yOBBBA=2099):
|
|
184
|
+
"""
|
|
185
|
+
Input is year of birth, index of shortest-lived individual,
|
|
186
|
+
lifespan of shortest-lived individual, total number of years
|
|
187
|
+
in the plan, and the year that preTCJA rates might come back.
|
|
188
|
+
|
|
189
|
+
It returns 3 time series:
|
|
190
|
+
1) Standard deductions at year n (sigma_n).
|
|
191
|
+
2) Tax rate in year n (theta_tn)
|
|
192
|
+
3) Delta from top to bottom of tax brackets (Delta_tn)
|
|
193
|
+
This is pure speculation on future values.
|
|
194
|
+
Returned values are not indexed for inflation.
|
|
195
|
+
"""
|
|
196
|
+
# Compute the deltas in-place between brackets, starting from the end.
|
|
197
|
+
deltaBrackets_OBBBA = np.array(taxBrackets_OBBBA)
|
|
198
|
+
deltaBrackets_preTCJA = np.array(taxBrackets_preTCJA)
|
|
199
|
+
for t in range(6, 0, -1):
|
|
200
|
+
for i in range(2):
|
|
201
|
+
deltaBrackets_OBBBA[i, t] -= deltaBrackets_OBBBA[i, t - 1]
|
|
202
|
+
deltaBrackets_preTCJA[i, t] -= deltaBrackets_preTCJA[i, t - 1]
|
|
203
|
+
|
|
204
|
+
# Prepare the 3 arrays to return - use transpose for easy slicing.
|
|
205
|
+
sigmaBar = np.zeros((N_n))
|
|
206
|
+
Delta = np.zeros((N_n, 7))
|
|
207
|
+
theta = np.zeros((N_n, 7))
|
|
208
|
+
|
|
209
|
+
filingStatus = len(yobs) - 1
|
|
210
|
+
souls = list(range(len(yobs)))
|
|
211
|
+
thisyear = date.today().year
|
|
212
|
+
|
|
213
|
+
for n in range(N_n):
|
|
214
|
+
# First check if shortest-lived individual is still with us.
|
|
215
|
+
if n == n_d:
|
|
216
|
+
souls.remove(i_d)
|
|
217
|
+
filingStatus -= 1
|
|
218
|
+
|
|
219
|
+
if thisyear + n < yOBBBA:
|
|
220
|
+
sigmaBar[n] = stdDeduction_OBBBA[filingStatus] * gamma_n[n]
|
|
221
|
+
Delta[n, :] = deltaBrackets_OBBBA[filingStatus, :]
|
|
222
|
+
else:
|
|
223
|
+
sigmaBar[n] = stdDeduction_preTCJA[filingStatus] * gamma_n[n]
|
|
224
|
+
Delta[n, :] = deltaBrackets_preTCJA[filingStatus, :]
|
|
225
|
+
|
|
226
|
+
# Add 65+ additional exemption(s) and "bonus" phasing out.
|
|
227
|
+
for i in souls:
|
|
228
|
+
if thisyear + n - yobs[i] >= 65:
|
|
229
|
+
sigmaBar[n] += extra65Deduction[filingStatus] * gamma_n[n]
|
|
230
|
+
if thisyear + n <= 2028:
|
|
231
|
+
sigmaBar[n] += 6000 * max(0, 1 - 0.06*max(0, MAGI_n[n] - bonusThreshold[filingStatus]))
|
|
232
|
+
|
|
233
|
+
# Fill in future tax rates for year n.
|
|
234
|
+
if thisyear + n < yOBBBA:
|
|
235
|
+
theta[n, :] = rates_OBBBA[:]
|
|
236
|
+
else:
|
|
237
|
+
theta[n, :] = rates_preTCJA[:]
|
|
238
|
+
|
|
239
|
+
Delta = Delta.transpose()
|
|
240
|
+
theta = theta.transpose()
|
|
241
|
+
|
|
242
|
+
# Return series unadjusted for inflation, except for sigmaBar, in STD order.
|
|
243
|
+
return sigmaBar, theta, Delta
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def taxBrackets(N_i, n_d, N_n, yOBBBA=2099):
|
|
247
|
+
"""
|
|
248
|
+
Return dictionary containing future tax brackets
|
|
249
|
+
unadjusted for inflation for plotting.
|
|
250
|
+
"""
|
|
251
|
+
if not (0 < N_i <= 2):
|
|
252
|
+
raise ValueError(f"Cannot process {N_i} individuals.")
|
|
253
|
+
|
|
254
|
+
n_d = min(n_d, N_n)
|
|
255
|
+
status = N_i - 1
|
|
256
|
+
|
|
257
|
+
# Number of years left in OBBBA from this year.
|
|
258
|
+
thisyear = date.today().year
|
|
259
|
+
if yOBBBA < thisyear:
|
|
260
|
+
raise ValueError(f"Expiration year {yOBBBA} cannot be in the past.")
|
|
261
|
+
|
|
262
|
+
ytc = yOBBBA - thisyear
|
|
263
|
+
|
|
264
|
+
data = {}
|
|
265
|
+
for t in range(len(taxBracketNames) - 1):
|
|
266
|
+
array = np.zeros(N_n)
|
|
267
|
+
for n in range(N_n):
|
|
268
|
+
stat = status if n < n_d else 0
|
|
269
|
+
array[n] = taxBrackets_OBBBA[stat][t] if n < ytc else taxBrackets_preTCJA[stat][t]
|
|
270
|
+
|
|
271
|
+
data[taxBracketNames[t]] = array
|
|
272
|
+
|
|
273
|
+
return data
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def rho_in(yobs, N_n):
|
|
277
|
+
"""
|
|
278
|
+
Return Required Minimum Distribution fractions for each individual.
|
|
279
|
+
This implementation does not support spouses with more than
|
|
280
|
+
10-year difference.
|
|
281
|
+
It starts at age 73 until it goes to 75 in 2033.
|
|
282
|
+
"""
|
|
283
|
+
# Notice that table starts at age 72.
|
|
284
|
+
rmdTable = [
|
|
285
|
+
27.4,
|
|
286
|
+
26.5,
|
|
287
|
+
25.5,
|
|
288
|
+
24.6,
|
|
289
|
+
23.7,
|
|
290
|
+
22.9,
|
|
291
|
+
22.0,
|
|
292
|
+
21.1,
|
|
293
|
+
20.2,
|
|
294
|
+
19.4,
|
|
295
|
+
18.5,
|
|
296
|
+
17.7,
|
|
297
|
+
16.8,
|
|
298
|
+
16.0,
|
|
299
|
+
15.2,
|
|
300
|
+
14.4,
|
|
301
|
+
13.7,
|
|
302
|
+
12.9,
|
|
303
|
+
12.2,
|
|
304
|
+
11.5,
|
|
305
|
+
10.8,
|
|
306
|
+
10.1,
|
|
307
|
+
9.5,
|
|
308
|
+
8.9,
|
|
309
|
+
8.4,
|
|
310
|
+
7.8,
|
|
311
|
+
7.3,
|
|
312
|
+
6.8,
|
|
313
|
+
6.4,
|
|
314
|
+
6.0,
|
|
315
|
+
5.6,
|
|
316
|
+
5.2,
|
|
317
|
+
4.9,
|
|
318
|
+
4.6,
|
|
319
|
+
]
|
|
320
|
+
|
|
321
|
+
N_i = len(yobs)
|
|
322
|
+
if N_i == 2 and abs(yobs[0] - yobs[1]) > 10:
|
|
323
|
+
raise RuntimeError("RMD: Unsupported age difference of more than 10 years.")
|
|
324
|
+
|
|
325
|
+
rho = np.zeros((N_i, N_n))
|
|
326
|
+
thisyear = date.today().year
|
|
327
|
+
for i in range(N_i):
|
|
328
|
+
agenow = thisyear - yobs[i]
|
|
329
|
+
# Account for increase of RMD age between 2023 and 2032.
|
|
330
|
+
yrmd = 70 if yobs[i] < 1949 else 72 if 1949 <= yobs[i] <= 1950 else 73 if 1951 <= yobs[i] <= 1959 else 75
|
|
331
|
+
for n in range(N_n):
|
|
332
|
+
yage = agenow + n
|
|
333
|
+
|
|
334
|
+
if yage < yrmd:
|
|
335
|
+
pass # rho[i][n] = 0
|
|
336
|
+
else:
|
|
337
|
+
rho[i][n] = 1.0 / rmdTable[yage - 72]
|
|
338
|
+
|
|
339
|
+
return rho
|
owlplanner/timelists.py
CHANGED
|
@@ -54,7 +54,7 @@ def read(finput, inames, horizons, mylog):
|
|
|
54
54
|
else:
|
|
55
55
|
# Read all worksheets in memory but only process those with proper names.
|
|
56
56
|
try:
|
|
57
|
-
dfDict = pd.read_excel(finput, sheet_name=None)
|
|
57
|
+
dfDict = pd.read_excel(finput, sheet_name=None, usecols=_timeHorizonItems)
|
|
58
58
|
except Exception as e:
|
|
59
59
|
raise Exception(f"Could not read file {finput}: {e}.") from e
|
|
60
60
|
streamName = f"file '{finput}'"
|
owlplanner/version.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "2025.
|
|
1
|
+
__version__ = "2025.11.02"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: owlplanner
|
|
3
|
-
Version: 2025.
|
|
3
|
+
Version: 2025.11.2
|
|
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
|
|
@@ -709,7 +709,7 @@ Description-Content-Type: text/markdown
|
|
|
709
709
|
|
|
710
710
|
## A retirement exploration tool based on linear programming
|
|
711
711
|
|
|
712
|
-
<img align=right src="https://
|
|
712
|
+
<img align=right src="https://github.com/mdlacasse/Owl/blob/main/docs/images/owl.png?raw=true" width="250">
|
|
713
713
|
|
|
714
714
|
-------------------------------------------------------------------------------------
|
|
715
715
|
|
|
@@ -739,12 +739,12 @@ Strictly speaking, it is not a planning tool, but more an environment for explor
|
|
|
739
739
|
It provides different realizations of a financial strategy through the rigorous
|
|
740
740
|
mathematical optimization of relevant decision variables. Two major objective goals can be set: either
|
|
741
741
|
maximize net spending, or after-tax bequest under various constraints.
|
|
742
|
-
Look at
|
|
742
|
+
Look at the *Capabilities* section below for more detail.
|
|
743
743
|
|
|
744
744
|
One can certainly have a savings plan, but due to the volatility of financial investments,
|
|
745
745
|
it is impossible to have a certain asset earnings plan. This does not mean one cannot make decisions.
|
|
746
746
|
These decisions need to be guided with an understanding of the sensitivity of the parameters.
|
|
747
|
-
This is exactly where this tool fits
|
|
747
|
+
This is exactly where this tool fits in. Given your savings capabilities and spending desires,
|
|
748
748
|
it can generate different future realizations of
|
|
749
749
|
your strategy under different market assumptions, helping to better understand your financial situation.
|
|
750
750
|
|
|
@@ -758,7 +758,7 @@ collecting your data, or academic papers that share the results without really s
|
|
|
758
758
|
the underlying mathematical models.
|
|
759
759
|
The algorithms in Owl rely on the open-source HiGHS linear programming solver. The complete formulation and
|
|
760
760
|
detailed description of the underlying
|
|
761
|
-
mathematical model can be found [here](https://
|
|
761
|
+
mathematical model can be found [here](https://github.com/mdlacasse/Owl/blob/main/docs/owl.pdf).
|
|
762
762
|
|
|
763
763
|
It is anticipated that most end users will use Owl through the graphical interface
|
|
764
764
|
either at [owlplanner.streamlit.app](https://owlplanner.streamlit.app)
|
|
@@ -768,7 +768,7 @@ as described [here](USER_GUIDE.md).
|
|
|
768
768
|
|
|
769
769
|
Not every retirement decision strategy can be framed as an easy-to-solve optimization problem.
|
|
770
770
|
In particular, if one is interested in comparing different withdrawal strategies,
|
|
771
|
-
[FI Calc](ficalc.app) is an elegant application that addresses this need.
|
|
771
|
+
[FI Calc](https://ficalc.app) is an elegant application that addresses this need.
|
|
772
772
|
If, however, you also want to optimize spending, bequest, and Roth conversions, with
|
|
773
773
|
an approach also considering Medicare and federal income tax over the next few years,
|
|
774
774
|
then Owl is definitely a tool that can help guide your decisions.
|
|
@@ -840,7 +840,9 @@ Tax status covers married filing jointly and single, depending on the number of
|
|
|
840
840
|
Maturation rules for Roth contributions and conversions are implemented as constraints
|
|
841
841
|
limiting withdrawal amounts to cover Roth account balances for 5 years after the events.
|
|
842
842
|
Medicare and IRMAA calculations are performed through a self-consistent loop on cash flow constraints.
|
|
843
|
-
|
|
843
|
+
They can also be optimized explicitly as an option, but this choice can lead to longer calculations
|
|
844
|
+
due to the use of the many additional binary variables required by the formulation.
|
|
845
|
+
Future Medicare and IRMAA values are simple projections of current values with the assumed inflation rates.
|
|
844
846
|
|
|
845
847
|
### Limitations
|
|
846
848
|
Owl is work in progress. At the current time:
|
|
@@ -851,15 +853,16 @@ These cases are detected and will generate an error message.
|
|
|
851
853
|
- Social security rule for surviving spouse assumes that benefits were taken at full retirement age.
|
|
852
854
|
- Current version has no optimization of asset allocations between individuals and/or types of savings accounts.
|
|
853
855
|
If there is interest, that could be added in the future.
|
|
854
|
-
- In the current implementation, social securiy is always taxed at 85
|
|
855
|
-
- Medicare calculations are done through a self-consistent loop
|
|
856
|
-
|
|
856
|
+
- In the current implementation, social securiy is always taxed at 85%, assuming that your taxable income will be larger than 34 k$ (single) or 44 k$ (married filing jointly).
|
|
857
|
+
- When Medicare calculations are done through a self-consistent loop,
|
|
858
|
+
the Medicare premiums are calculated after an initial solution is generated,
|
|
857
859
|
and then a new solution is re-generated with these premiums as a constraint.
|
|
858
860
|
In some situations, when the income (MAGI) is near an IRMAA bracket, oscillatory solutions can arise.
|
|
859
861
|
While the solutions generated are very close to one another, Owl will pick the smallest solution
|
|
860
|
-
for being conservative.
|
|
861
|
-
|
|
862
|
-
|
|
862
|
+
for being conservative. While sometimes computationally costly,
|
|
863
|
+
a comparison with a full Medicare optimization should always be performed.
|
|
864
|
+
- Part D is not included in the IRMAA calculations. Only Part B is taken into account,
|
|
865
|
+
which is considerably more significant.
|
|
863
866
|
- Future tax brackets are pure speculations derived from the little we know now and projected to the next 30 years.
|
|
864
867
|
Your guesses are as good as mine.
|
|
865
868
|
|
|
@@ -886,8 +889,11 @@ assets to support, even with no estate being left.
|
|
|
886
889
|
- Optimization solver from [HiGHS](https://highs.dev)
|
|
887
890
|
- Streamlit Community Cloud [Streamlit](https://streamlit.io)
|
|
888
891
|
- Contributors: Josh (noimjosh@gmail.com) for Docker image code,
|
|
892
|
+
kg333 for fixing an error in Docker's instructions,
|
|
889
893
|
Dale Seng (sengsational) for great insights and suggestions,
|
|
890
|
-
Robert E. Anderson (NH-RedAnt) for bug fixes and suggestions,
|
|
894
|
+
Robert E. Anderson (NH-RedAnt) for bug fixes and suggestions,
|
|
895
|
+
Clark Jefcoat (hubcity) for fruitful interactions,
|
|
896
|
+
Benjamin Quinn (blquinn) and Gene Wood (gene1wood) for improvements and bug fixes.
|
|
891
897
|
|
|
892
898
|
---------------------------------------------------------------------
|
|
893
899
|
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
owlplanner/__init__.py,sha256=hJ2i4m2JpHPAKyQLjYOXpJzeEsgcTcKD-Vhm0AIjjWg,592
|
|
2
2
|
owlplanner/abcapi.py,sha256=rtg7d0UbftinokR9VlB49VUjDjzUq3ONnJbhMXVIrgo,6879
|
|
3
|
-
owlplanner/config.py,sha256=
|
|
3
|
+
owlplanner/config.py,sha256=UF2Dy6E9PiX6Ua8B1R0aYCNUoIYmY46up8awf_36B_Q,12615
|
|
4
4
|
owlplanner/mylogging.py,sha256=OVGeDFO7LIZG91R6HMpZBzjno-B8PH8Fo00Jw2Pdgqw,2558
|
|
5
|
-
owlplanner/plan.py,sha256=
|
|
5
|
+
owlplanner/plan.py,sha256=NDdV0Eri76JMpiNPeuMzLYGX-lDDBEbjXd6Qg6k_CIw,115180
|
|
6
6
|
owlplanner/progress.py,sha256=dUUlFmSAKUei36rUj2BINRY10f_YEUo_e23d0es6nrc,530
|
|
7
7
|
owlplanner/rates.py,sha256=9Nmo8AKsyi5PoCUrzhr06phkSlNTv-TXzj5iYFU76AY,14113
|
|
8
|
-
owlplanner/tax2025.py,sha256=
|
|
9
|
-
owlplanner/
|
|
8
|
+
owlplanner/tax2025.py,sha256=4KYaT6TO6WU7wDjgdRW48lqfwvVCtaXs9tcw1nleKhg,10834
|
|
9
|
+
owlplanner/tax2026.py,sha256=hgCiCJWVzJITk0cA8W-zxl-a0kObijPZ1yXc0F6MAwk,10848
|
|
10
|
+
owlplanner/timelists.py,sha256=UdzH6A_-w4REn4A1po7yndSiy1R8_R-i_C-94re4JYY,4093
|
|
10
11
|
owlplanner/utils.py,sha256=afAjeO6Msf6Rn4jwz_7Ody9rHGWlBR7iQFqe1xzLNQc,2513
|
|
11
|
-
owlplanner/version.py,sha256=
|
|
12
|
+
owlplanner/version.py,sha256=7KKWNrt_Dn84IwHPr2R5et9VpN8p8GhpeUnuavkZnVA,28
|
|
12
13
|
owlplanner/data/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
13
14
|
owlplanner/data/rates.csv,sha256=6fxg56BVVORrj9wJlUGFdGXKvOX5r7CSca8uhUbbuIU,3734
|
|
14
15
|
owlplanner/plotting/__init__.py,sha256=uhxqtUi0OI-QWNOO2LkXgQViW_9yM3rYb-204Wit974,250
|
|
@@ -16,7 +17,7 @@ owlplanner/plotting/base.py,sha256=UimGKpMTV-dVm3BX5Apr_Ltorc7dlDLCRPRQ3RF_v7c,2
|
|
|
16
17
|
owlplanner/plotting/factory.py,sha256=EDopIAPQr9zHRgemObko18FlCeRNhNCoLNNFAOq-X6s,1030
|
|
17
18
|
owlplanner/plotting/matplotlib_backend.py,sha256=AOEkapD94U5hGNoS0EdbRoe8mgdMHH4oOvkXADZS914,17957
|
|
18
19
|
owlplanner/plotting/plotly_backend.py,sha256=AO33GxBHGYG5osir_H1iRRtGxdhs4AjfLV2d_xm35nY,33138
|
|
19
|
-
owlplanner-2025.
|
|
20
|
-
owlplanner-2025.
|
|
21
|
-
owlplanner-2025.
|
|
22
|
-
owlplanner-2025.
|
|
20
|
+
owlplanner-2025.11.2.dist-info/METADATA,sha256=MIS1cRREb2Fe56Qu8BJ_9tF5YXB3gFL2Ldaufm2k0Ak,54622
|
|
21
|
+
owlplanner-2025.11.2.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
22
|
+
owlplanner-2025.11.2.dist-info/licenses/LICENSE,sha256=IwGE9guuL-ryRPEKi6wFPI_zOhg7zDZbTYuHbSt_SAk,35823
|
|
23
|
+
owlplanner-2025.11.2.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|